Merge remote-tracking branch 'origin/main' into remove/run-metadata-branches

Resolve conflicts between the metadata-branch removal and the
sandbox-driver adoption on main:

- fabro-sandbox docker.rs, sandbox.rs, daytona/mod.rs: take main's driver
  rewrite. The Sandbox trait is gone, so the PR's push_token_source
  removal now applies to RunSandbox instead; drop that accessor and the
  RepoCredentials::source helper that only served it.
- run_metadata.rs: keep deleted. Main's edits there were adaptations to
  the driver API and the run git identity field.
- lifecycle/git.rs, finalize.rs: keep the PR's removal of metadata
  snapshots and write_finalize_commit; carry main's RunSandbox,
  GitRetryPolicy, git_identity, local_sandbox, and test catalog changes.
- sandbox_git.rs: take main's version and drop the shadow_sha parameter
  and Fabro-Checkpoint trailer.
- git_integration.rs: remove meta_branch from the new git identity test.
- Cargo.toml: main's dependency set with fabro-dump kept as a
  dev-dependency.
- checkpoints.mdx: keep both the git identity paragraph and the durable
  execution state section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-12 16:28:01 -06:00
commit e5d5c534ab
No known key found for this signature in database
849 changed files with 39669 additions and 144635 deletions

View file

@ -23,11 +23,10 @@ Which source files affect which doc pages. Use this as guidance — also apply j
| `lib/components/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` |
| `lib/components/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` |
| `lib/components/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` |
| `lib/components/fabro-agent/src/tools.rs`, `lib/components/fabro-agent/src/tool_registry.rs`, `lib/components/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` |
| `lib/components/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` |
| `lib/components/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` |
| `lib/components/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` |
| `lib/components/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` |
| `lib/components/fabro-sandbox/src/environment.rs`, pebble's `pebble-coding-agent` tools | `docs/public/agents/tools.mdx` |
| `lib/apps/fabro-cli/src/commands/exec.rs` | `docs/public/agents/permissions.mdx` |
| pebble's `pebble-coding-agent` subagents | `docs/public/agents/subagents.mdx` |
| `lib/components/fabro-workflow/src/handler/llm/sandbox_mcp.rs`, `lib/components/fabro-mcp/src/connection_manager.rs` | `docs/public/agents/mcp.mdx` |
| `lib/components/fabro-llm/src/catalog.rs`, `lib/components/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` |
| `lib/components/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` |
| `lib/components/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` |

View file

@ -15,9 +15,11 @@ leak-timeout = "500ms"
filter = "package(fabro-workflow)"
slow-timeout = { period = "2s", terminate-after = 3 }
# Real descendant regressions include bounded reaping and process probes.
# Leave room for their own watchdogs to run fail-safe fixture cleanup.
[[profile.default.overrides]]
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
slow-timeout = { period = "30s", terminate-after = 1 }
filter = "package(fabro-proc) & binary(lifecycle)"
slow-timeout = { period = "10s", terminate-after = 3 }
[profile.e2e]
# E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s
@ -48,6 +50,3 @@ leak-timeout = "2s"
filter = "package(fabro-workflow)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
slow-timeout = { period = "60s", terminate-after = 2 }

View file

@ -125,13 +125,51 @@ jobs:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: cargo nextest run --locked --workspace --status-level slow --profile ci
# Twin-mode e2e suites. These are hermetic (in-process twin provider, no
# secrets): FABRO_TEST_MODE defaults to twin, so live-only tests
# self-skip. Scoped to the packages whose ignored tests are fully green
# in twin mode; widen as the remaining suites are fixed up for CI.
# Must not use the e2e nextest profile here: NEXTEST_PROFILE=e2e implies
# strict mode, which fails (rather than skips) live tests without keys.
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm) + package(twin-openai)'
# The twin-mode ignored suites this job once ran belonged to fabro-agent,
# which pebble's coding agent replaced; the agent loop's workflow-level
# tests run in the suite above, and pebble's own suite covers the loop.
# Re-add a `--run-ignored only -E 'package(...)'` step here when a
# package has ignored suites that are fully green in twin mode.
sandbox-plugins:
name: Sandbox plugins (stdio)
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
env:
# The plugin scenarios skip when an executable or daemon is missing;
# in CI a skip is a failure.
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: docker pull buildpack-deps:noble
# The driver's own Host and Docker executables, installed at the rev the
# workspace pins so the plugins and the in-process providers are one
# build; the CLI scenarios find them on PATH and launch them over stdio.
- name: Install the sandbox-driver plugin executables
run: |
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
test -n "$rev"
cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "$rev" sandbox-driver-host sandbox-driver-docker
# Host and Docker served as plugins through the workflow scenarios. The
# scenarios are e2e tests (ignored by default); the key-free ones run
# here, the LLM-backed ones self-skip without credentials.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)'
# The stdio plugin proof (not ignored: it skips without the executable,
# which the environment above forbids) and the driver-backed Docker
# integration tests.
- run: cargo nextest run --locked --profile ci --status-level slow -p fabro-sandbox --test plugin_provider
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)'
test-macos:
name: Test (macOS)

View file

@ -32,17 +32,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted.
- The sandbox layer also accepts an optional exact commit for future admitted
runs. An exact commit always requires a non-empty branch. Docker initializes
an empty repository, shallow-fetches the SHA at the same depth as a branch
clone, and checks it out; Daytona uses its official SDK clone with both
`branch` and `commit_id`. Both providers then point the admitted branch at
the commit and verify HEAD, so the workspace still reports the admitted
branch name. Keep those provider transports distinct, never fall back to a
newer branch HEAD, and do not wire this capability directly from legacy
`GitContext.sha`. The sandbox layer does not verify that the commit is
reachable from the branch; admission owns that check. Current production
callers remain branch-only until the RunIntent admission cutover supplies a
validated branch/SHA pair.
runs. An exact commit always requires a non-empty branch. The sandbox driver
performs the pin the same way on every provider: it initializes an empty
repository, fetches the SHA directly at the requested depth, and attaches
the admitted branch to it, so the workspace reports the admitted branch
name. Daytona's native toolbox clone serves plain branch clones only; its
commit pin checks the branch head out first, so the driver does not use
it. A successful clone has the pin checked out; the driver's
conformance suite verifies that on every provider, and fabro does not
re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire
this capability directly from legacy `GitContext.sha`. The sandbox layer
does not verify that the commit is reachable from the branch; admission
owns that check. Current production callers remain branch-only until the
RunIntent admission cutover supplies a validated branch/SHA pair.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
@ -122,8 +124,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
@ -139,7 +140,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **OpenAPI-first**`fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator)
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed

828
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ members = [
"lib/apps/*",
"lib/components/*",
"lib/foundation/*",
"test/twin/openai",
"test/twin/github",
]
default-members = ["lib/apps/fabro-cli"]
@ -11,7 +10,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.347.0-nightly.0"
version = "0.354.0-nightly.0"
license = "MIT"
[workspace.dependencies]
@ -28,7 +27,6 @@ serde_json = { version = "1", features = ["preserve_order"] }
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled", "migrate", "macros"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] }
sse-stream = "0.2"
ulid = "1"
uuid = { version = "1", features = ["v4", "v7", "v8"] }
rand = "0.9"
@ -62,13 +60,13 @@ clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }
chrono = { version = "0.4", features = ["clock", "serde"] }
dashmap = "6"
bollard = "0.18"
tar = "0.4"
cli-table = { version = "0.5", default-features = false }
console = "0.15"
dialoguer = "0.12"
git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2", "vendored-openssl", "https"] }
tracing = "0.1"
unicase = "2"
unicode-normalization = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tracing-appender = "0.2"
rmcp = { version = "1.4", default-features = false }
@ -93,12 +91,41 @@ sha2 = "0.10"
hex = "0.4"
insta = "1"
fabro-test = { path = "lib/foundation/fabro-test" }
twin-openai = { path = "test/twin/openai" }
# Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is
# published to crates.io.
lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "a1e3fd37b7153870411701327ac117606753fe90", default-features = false }
# Deterministic OpenAI twin used by twin-mode E2E tests; the same revision
# lithos-llm verifies its codecs against.
twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" }
# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and
# Daytona providers link in-process; third-party providers run as stdio
# plugins through sandbox-driver-protocol. Pinned by rev; currently the head of
# the sandbox-driver `section-4-driver-items` branch (provider-owned scopes, the
# supervisor as provider, Host attach by directory, git retry and verbs in the
# driver, status image/snapshot/network, Daytona snapshot caching, services port
# wait and list, RFC 3339 timestamps), to move to main on merge. The CI plugin
# job installs the driver executables at the same rev, read from this file.
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble
# `main`. The `PreviewUrls` trait objects fabro hands pebble's MCP servers
# only cross when fabro and pebble's `mcp` feature name the same
# sandbox-driver revision, so move the two pins together. Pebble pins the
# same lithos-llm rev as fabro, and its lockfile policy is that every shared
# crate resolves to the version lithos-llm locks.
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
EnvironmentApiDockerfileSourceInlineTypeEnum,
EnvironmentNetworkMode,
EnvironmentProvider,
} from "@qltysh/fabro-api-client";
import type {
CreateEnvironmentRequest,
@ -15,6 +14,7 @@ import type {
ReplaceEnvironmentRequest,
} from "@qltysh/fabro-api-client";
import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers";
import { Label, Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
import {
@ -25,11 +25,15 @@ import {
} from "./key-value-editor";
// Parse the `provider` query param used by the create flow into a creatable
// provider, defaulting to Docker for anything unexpected.
export function parseCreatableProvider(value: string | null): EnvironmentProvider {
return value === EnvironmentProvider.DAYTONA
? EnvironmentProvider.DAYTONA
: EnvironmentProvider.DOCKER;
// provider, defaulting to Docker for anything that cannot back a managed
// environment. Kind names are validated server-side on create.
const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
export function parseCreatableProvider(value: string | null): string {
if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) {
return value;
}
return DOCKER_PROVIDER;
}
// Environment ids are server-managed file names: lowercase, digits, hyphens.
@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile";
export interface EnvironmentFormValues {
id: string;
provider: EnvironmentProvider;
provider: string;
imageSource: ImageSource;
dockerRef: string;
dockerfile: string;
@ -69,7 +73,7 @@ export interface EnvironmentFormValues {
export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = {
id: "",
provider: EnvironmentProvider.DOCKER,
provider: DOCKER_PROVIDER,
imageSource: "image",
dockerRef: "",
dockerfile: "",

View file

@ -174,7 +174,7 @@ describe("RunSummaryPanelView", () => {
const tree = render({
run: makeRun(),
sandboxState: "running",
sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any,
sandboxResources: { cpu_cores: 4, memory_mb: 8 * 1024 },
});
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB");
});

View file

@ -80,10 +80,10 @@ function SandboxValue({
}) {
const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown;
const cpu = resources?.cpu_cores;
const memory = resources?.memory_bytes;
const memoryMb = resources?.memory_mb;
const valueText =
cpu != null && memory != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}`
cpu != null && memoryMb != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memoryMb * 1024 * 1024)}`
: display.label;
return (
@ -221,8 +221,8 @@ export function RunSummaryPanel({ runId }: { runId: string }) {
<RunSummaryPanelView
run={runQuery.data ?? null}
runLoading={runQuery.isLoading && !runQuery.data}
sandboxState={sandboxQuery.data?.state ?? null}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxState={sandboxQuery.data?.status.state ?? null}
sandboxResources={sandboxQuery.data?.status.resources ?? null}
sandboxLoading={sandboxReady && sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}

View file

@ -3,13 +3,13 @@ import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import {
AgentSkillActivationSource,
AgentToolCategory,
StageContextWindowCategory,
StageContextWindowCountMethod,
StageContextWindowStaleness,
ContextWindowCategory,
ContextWindowCountMethod,
ContextWindowStaleness,
SkillActivationSource,
TodoListKind,
TodoStatus,
ToolCategory,
} from "@qltysh/fabro-api-client";
import type {
StageContextWindow,
@ -43,14 +43,14 @@ function makeContextWindow(overrides: Partial<StageContextWindow> = {}): StageCo
context_window_tokens: 200_000,
input_tokens: 62_000,
usage_percent: 31,
count_method: StageContextWindowCountMethod.PROVIDER_API_SCALED_BREAKDOWN,
staleness: StageContextWindowStaleness.LIVE,
count_method: ContextWindowCountMethod.PROVIDER_API_SCALED_BREAKDOWN,
staleness: ContextWindowStaleness.LIVE,
generated_at: new Date().toISOString(),
event_seq: 42,
breakdown: [
{ category: StageContextWindowCategory.SYSTEM_PROMPT, tokens: 8_000, usage_percent: 4 },
{ category: StageContextWindowCategory.TOOLS, tokens: 12_000, usage_percent: 6 },
{ category: StageContextWindowCategory.CONVERSATION, tokens: 42_000, usage_percent: 21 },
{ category: ContextWindowCategory.SYSTEM_PROMPT, tokens: 8_000, usage_percent: 4 },
{ category: ContextWindowCategory.TOOLS, tokens: 12_000, usage_percent: 6 },
{ category: ContextWindowCategory.CONVERSATION, tokens: 42_000, usage_percent: 21 },
],
warnings: [],
...overrides,
@ -139,7 +139,7 @@ describe("StageInsightsSidebar", () => {
available: false,
usage_percent: null,
input_tokens: null,
staleness: StageContextWindowStaleness.UNAVAILABLE,
staleness: ContextWindowStaleness.UNAVAILABLE,
unavailable_reason: null,
});
const dom = render(makeStage(), cw);
@ -155,14 +155,14 @@ describe("StageInsightsSidebar", () => {
name: "apply_patch",
description: "Apply a unified diff patch",
source: { kind: "native" },
category: AgentToolCategory.WRITE,
category: ToolCategory.WRITE,
invoked: true,
},
{
name: "grep",
description: "Search file contents",
source: { kind: "native" },
category: AgentToolCategory.READ,
category: ToolCategory.READ,
invoked: false,
},
],
@ -215,13 +215,33 @@ describe("StageInsightsSidebar", () => {
expect(dom).toContain("Failed");
});
test("renders a disconnected mcp server as disconnected, still counted as used", () => {
const dom = render(
makeStage({
mcp_servers: [
{
server_name: "github",
tool_count: 4,
status: { kind: "disconnected", error: "transport closed" },
invoked: true,
},
],
}),
null,
);
expect(dom).toContain("1/1");
expect(dom).toContain("github");
expect(dom).toContain("Disconnected");
expect(dom).not.toContain("Failed");
});
test("shows skill activated/available ratio with source label", () => {
const dom = render(
makeStage({
skills: {
activated: [
{ name: "frontend-design", source: AgentSkillActivationSource.SLASH },
{ name: "debug", source: AgentSkillActivationSource.TOOL },
{ name: "frontend-design", source: SkillActivationSource.SLASH },
{ name: "debug", source: SkillActivationSource.TOOL },
],
available: [
{ name: "frontend-design", description: "" },

View file

@ -7,6 +7,7 @@ import {
import {
ArrowPathIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
import {
@ -19,21 +20,21 @@ import {
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import {
AgentSkillActivationSource,
StageContextWindowCategory,
StageContextWindowStaleness,
ContextWindowCategory,
ContextWindowStaleness,
SkillActivationSource,
TodoStatus,
} from "@qltysh/fabro-api-client";
import type {
ActivatedSkill,
AgentSkillSummary,
AgentToolSummary,
ContextWindowBreakdownItem,
McpServerProjection,
SkillSummary,
StageContextWindow,
StageContextWindowBreakdownItem,
StageProjection,
TodoListProjection,
TodoProjection,
ToolSummary,
} from "@qltysh/fabro-api-client";
import { formatTokenCount } from "../lib/format";
@ -388,7 +389,7 @@ function ContextBreakdown({ snapshot }: { snapshot: StageContextWindow | null })
if (!snapshot) {
return <p className="mt-2 px-2 text-xs text-fg-muted">Context usage not yet available.</p>;
}
if (snapshot.staleness === StageContextWindowStaleness.UNAVAILABLE) {
if (snapshot.staleness === ContextWindowStaleness.UNAVAILABLE) {
return <p className="mt-2 px-2 text-xs text-fg-muted">Context usage unavailable for this stage.</p>;
}
const totalTokens = snapshot.input_tokens ?? 0;
@ -431,7 +432,7 @@ function ContextBreakdown({ snapshot }: { snapshot: StageContextWindow | null })
);
}
function nonZeroBreakdown(items: StageContextWindowBreakdownItem[]): StageContextWindowBreakdownItem[] {
function nonZeroBreakdown(items: ContextWindowBreakdownItem[]): ContextWindowBreakdownItem[] {
return items.filter((i) => i.usage_percent > 0);
}
@ -443,41 +444,41 @@ function nonZeroBreakdown(items: StageContextWindowBreakdownItem[]): StageContex
* Palette is chosen so the typical chunks (Conversation big + System +
* Tools) read as three distinct hues rather than three adjacent teals.
*/
function categoryColor(category: StageContextWindowCategory): string {
function categoryColor(category: ContextWindowCategory): string {
switch (category) {
case StageContextWindowCategory.SYSTEM_PROMPT:
case ContextWindowCategory.SYSTEM_PROMPT:
return "var(--color-teal-700)";
case StageContextWindowCategory.TOOLS:
case ContextWindowCategory.TOOLS:
return "var(--color-amber)";
case StageContextWindowCategory.MCP_TOOLS:
case ContextWindowCategory.MCP_TOOLS:
return "var(--color-mint)";
case StageContextWindowCategory.SKILLS:
case ContextWindowCategory.SKILLS:
return "var(--color-teal-500)";
case StageContextWindowCategory.MEMORY:
case ContextWindowCategory.MEMORY:
return "var(--color-coral)";
case StageContextWindowCategory.CONVERSATION:
case ContextWindowCategory.CONVERSATION:
return "var(--color-teal-300)";
case StageContextWindowCategory.OTHER:
case ContextWindowCategory.OTHER:
default:
return "var(--color-fg-muted)";
}
}
function categoryLabel(category: StageContextWindowCategory): string {
function categoryLabel(category: ContextWindowCategory): string {
switch (category) {
case StageContextWindowCategory.SYSTEM_PROMPT:
case ContextWindowCategory.SYSTEM_PROMPT:
return "System prompt";
case StageContextWindowCategory.TOOLS:
case ContextWindowCategory.TOOLS:
return "Tools";
case StageContextWindowCategory.MCP_TOOLS:
case ContextWindowCategory.MCP_TOOLS:
return "MCP tools";
case StageContextWindowCategory.SKILLS:
case ContextWindowCategory.SKILLS:
return "Skills";
case StageContextWindowCategory.MEMORY:
case ContextWindowCategory.MEMORY:
return "Memory";
case StageContextWindowCategory.CONVERSATION:
case ContextWindowCategory.CONVERSATION:
return "Conversation";
case StageContextWindowCategory.OTHER:
case ContextWindowCategory.OTHER:
default:
return "Other";
}
@ -487,7 +488,7 @@ function categoryLabel(category: StageContextWindowCategory): string {
interface SkillsSectionProps {
activated: ActivatedSkill[];
available: AgentSkillSummary[];
available: SkillSummary[];
activatedNames: Set<string>;
}
@ -517,13 +518,13 @@ function SkillsSection({ activated, available, activatedNames }: SkillsSectionPr
}
function SkillSourceIcon({ source }: { source: ActivatedSkill["source"] }) {
const Icon = source === AgentSkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon;
const Icon = source === SkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon;
return <Icon className="size-3.5 shrink-0 text-fg-muted" />;
}
// ---------- Tools ----------
function AgentToolsSection({ tools }: { tools: AgentToolSummary[] }) {
function AgentToolsSection({ tools }: { tools: ToolSummary[] }) {
if (tools.length === 0) return <p className="text-xs text-fg-muted">No tools reported.</p>;
return (
<ul className="space-y-1.5">
@ -554,27 +555,15 @@ function McpSection({ servers }: { servers: McpServerProjection[] }) {
<ul className="space-y-1">
{servers.map((server) => {
// Dim unused servers so the eye lands on the invoked ones first;
// failed servers stay coral regardless.
// failed and disconnected servers keep their tone regardless.
const nameClass = server.status.kind === "ready" && !server.invoked
? "min-w-0 flex-1 truncate text-xs text-fg-muted"
: "min-w-0 flex-1 truncate text-xs text-fg-2";
return (
<li key={server.server_name} className="flex items-center gap-1.5">
{server.status.kind === "ready" ? (
<CheckCircleIcon className="size-3.5 shrink-0 text-mint" aria-label="Ready" />
) : (
<XCircleIcon className="size-3.5 shrink-0 text-coral" aria-label="Failed" />
)}
<McpStatusIcon status={server.status} />
<span className={nameClass}>{server.server_name}</span>
{server.status.kind === "ready" ? (
<span className="font-mono text-[10px] tabular-nums text-fg-muted">
{server.invoked
? "used"
: `${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`}
</span>
) : (
<span className="text-[10px] uppercase tracking-wider text-coral">Failed</span>
)}
<McpStatusBadge server={server} />
</li>
);
})}
@ -582,6 +571,41 @@ function McpSection({ servers }: { servers: McpServerProjection[] }) {
);
}
function McpStatusIcon({ status }: { status: McpServerProjection["status"] }) {
switch (status.kind) {
case "ready":
return <CheckCircleIcon className="size-3.5 shrink-0 text-mint" aria-label="Ready" />;
case "disconnected":
return (
<ExclamationTriangleIcon
className="size-3.5 shrink-0 text-amber"
aria-label="Disconnected"
/>
);
case "failed":
return <XCircleIcon className="size-3.5 shrink-0 text-coral" aria-label="Failed" />;
}
}
function McpStatusBadge({ server }: { server: McpServerProjection }) {
switch (server.status.kind) {
case "ready":
return (
<span className="font-mono text-[10px] tabular-nums text-fg-muted">
{server.invoked
? "used"
: `${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`}
</span>
);
case "disconnected":
return (
<span className="text-[10px] uppercase tracking-wider text-amber">Disconnected</span>
);
case "failed":
return <span className="text-[10px] uppercase tracking-wider text-coral">Failed</span>;
}
}
// ---------- helpers ----------
type IconType = (props: { className?: string }) => ReactNode;

View file

@ -66,8 +66,8 @@ describe("createScriptedAdapter", () => {
.map((p) => p.text ?? "")
.join("");
const expectedText = SCRIPTED_REPLIES[0]!.content
.filter((p) => p.kind === "text")
.map((p) => p.data.text)
.filter((p) => p.type === "text")
.map((p) => (p.type === "text" ? p.text : ""))
.join("");
expect(finalText).toBe(expectedText);
});
@ -89,7 +89,7 @@ describe("createScriptedAdapter", () => {
describe("toThreadMessages", () => {
test("converts a user text message", () => {
const out = toThreadMessages([
{ role: "user", content: [{ kind: "text", data: { text: "hi" } }] },
{ role: "user", content: [{ type: "text", text: "hi" }] },
]);
expect(out).toEqual([
{ role: "user", content: [{ type: "text", text: "hi" }] },
@ -102,16 +102,15 @@ describe("toThreadMessages", () => {
role: "assistant",
content: [
{
kind: "tool_call",
data: {
tool_call_id: "t1",
name: "search",
arguments: { q: "hello" },
},
type: "tool_call",
id: "t1",
name: "search",
input: { type: "function", arguments: { q: "hello" } },
},
{
kind: "tool_result",
data: { tool_call_id: "t1", content: { ok: true } },
type: "tool_result",
tool_call_id: "t1",
content: [{ type: "text", text: "{\"ok\":true}" }],
},
],
},
@ -126,6 +125,6 @@ describe("toThreadMessages", () => {
expect(first?.type).toBe("tool-call");
if (first?.type !== "tool-call") throw new Error("expected tool-call part");
expect(first.toolCallId).toBe("t1");
expect(first.result).toEqual({ ok: true });
expect(first.result).toEqual('{"ok":true}');
});
});

View file

@ -5,7 +5,12 @@ import type {
ThreadMessageLike,
} from "@assistant-ui/react";
import type { Chat, ChatContentPart, ChatMessage } from "./chats-types";
import type {
Chat,
ChatContentPart,
ChatMessage,
JsonValue,
} from "./chats-types";
import { pickReply } from "./chats-script";
const STREAM_CHUNK_CHARS = 28;
@ -29,29 +34,38 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
});
}
function toolResultValue(content: readonly ChatContentPart[]): JsonValue {
const texts = content.flatMap((part) =>
part.type === "text" ? [part.text] : [],
);
return texts.length === content.length
? texts.join("")
: (JSON.parse(JSON.stringify(content)) as JsonValue);
}
function toAssistantParts(
content: readonly ChatContentPart[],
): ThreadAssistantMessagePart[] {
const out: ThreadAssistantMessagePart[] = [];
for (const part of content) {
if (part.kind === "text") {
out.push({ type: "text", text: part.data.text });
} else if (part.kind === "tool_call") {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "tool_call") {
out.push({
type: "tool-call",
toolCallId: part.data.tool_call_id,
toolName: part.data.name,
args: part.data.arguments,
argsText: JSON.stringify(part.data.arguments),
toolCallId: part.id,
toolName: part.name,
args: part.input.arguments,
argsText: JSON.stringify(part.input.arguments),
});
} else if (part.kind === "tool_result") {
} else if (part.type === "tool_result") {
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i];
if (
candidate?.type === "tool-call" &&
candidate.toolCallId === part.data.tool_call_id
candidate.toolCallId === part.tool_call_id
) {
out[i] = { ...candidate, result: part.data.content };
out[i] = { ...candidate, result: toolResultValue(part.content) };
break;
}
}
@ -71,16 +85,16 @@ export function createScriptedAdapter(args: {
const accumulated: ChatContentPart[] = [];
for (const part of reply.content) {
if (part.kind === "text") {
const text = part.data.text;
if (part.type === "text") {
const text = part.text;
let cursor = 0;
accumulated.push({ kind: "text", data: { text: "" } });
accumulated.push({ type: "text", text: "" });
const accIndex = accumulated.length - 1;
while (cursor < text.length) {
cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length);
accumulated[accIndex] = {
kind: "text",
data: { text: text.slice(0, cursor) },
type: "text",
text: text.slice(0, cursor),
};
yield buildUpdate(accumulated);
if (cursor < text.length) {
@ -110,8 +124,8 @@ export function toThreadMessages(
if (msg.role === "user") {
const content = [];
for (const part of msg.content) {
if (part.kind === "text") {
content.push({ type: "text", text: part.data.text } as const);
if (part.type === "text") {
content.push({ type: "text", text: part.text } as const);
}
}
return {

View file

@ -1,4 +1,16 @@
import type { ChatMessage } from "./chats-types";
import type { ChatContentPart, ChatMessage } from "./chats-types";
function text(value: string): ChatContentPart {
return { type: "text", text: value };
}
function toolResult(toolCallId: string, value: unknown): ChatContentPart {
return {
type: "tool_result",
tool_call_id: toolCallId,
content: [text(JSON.stringify(value))],
};
}
/**
* Scripted assistant replies cycled through per chat. Generic content,
@ -10,176 +22,130 @@ export const SCRIPTED_REPLIES: ChatMessage[] = [
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" +
"- Markdown rendering (lists, **bold**, *italics*, `code`)\n" +
"- Streaming text appearing incrementally\n" +
"- Tool calls with arguments and results\n" +
"- Multi-paragraph responses with code blocks\n\n" +
"Send another message to see the next response in the bank.",
},
},
text(
"Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" +
"- Markdown rendering (lists, **bold**, *italics*, `code`)\n" +
"- Streaming text appearing incrementally\n" +
"- Tool calls with arguments and results\n" +
"- Multi-paragraph responses with code blocks\n\n" +
"Send another message to see the next response in the bank.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Here's a TypeScript snippet that debounces a function:\n\n" +
"```ts\n" +
"export function debounce<T extends (...args: any[]) => void>(\n" +
" fn: T,\n" +
" ms: number,\n" +
"): (...args: Parameters<T>) => void {\n" +
" let handle: ReturnType<typeof setTimeout> | undefined;\n" +
" return (...args) => {\n" +
" if (handle) clearTimeout(handle);\n" +
" handle = setTimeout(() => fn(...args), ms);\n" +
" };\n" +
"}\n" +
"```\n\n" +
"The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.",
},
},
text(
"Here's a TypeScript snippet that debounces a function:\n\n" +
"```ts\n" +
"export function debounce<T extends (...args: any[]) => void>(\n" +
" fn: T,\n" +
" ms: number,\n" +
"): (...args: Parameters<T>) => void {\n" +
" let handle: ReturnType<typeof setTimeout> | undefined;\n" +
" return (...args) => {\n" +
" if (handle) clearTimeout(handle);\n" +
" handle = setTimeout(() => fn(...args), ms);\n" +
" };\n" +
"}\n" +
"```\n\n" +
"The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.",
),
],
},
{
role: "assistant",
content: [
text("Let me search for that real quick."),
{
kind: "text",
data: {
text: "Let me search for that real quick.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_search_1",
name: "search_web",
type: "tool_call",
id: "call_search_1",
name: "search_web",
input: {
type: "function",
arguments: {
query: "current best practices for rate limiting an HTTP API",
max_results: 5,
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_search_1",
content: {
results: [
{
title: "Token bucket vs leaky bucket",
url: "https://example.com/rate-limit-algorithms",
snippet:
"Token bucket allows bursts, leaky bucket smooths traffic.",
},
{
title: "Distributed rate limiting with Redis",
url: "https://example.com/redis-rate-limit",
snippet:
"INCR + EXPIRE is the simplest fixed-window approach.",
},
],
toolResult("call_search_1", {
results: [
{
title: "Token bucket vs leaky bucket",
url: "https://example.com/rate-limit-algorithms",
snippet: "Token bucket allows bursts, leaky bucket smooths traffic.",
},
},
},
{
kind: "text",
data: {
text:
"\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.",
},
},
{
title: "Distributed rate limiting with Redis",
url: "https://example.com/redis-rate-limit",
snippet: "INCR + EXPIRE is the simplest fixed-window approach.",
},
],
}),
text(
"\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"## The 4-fold path of refactoring a hook\n\n" +
"When a React hook starts feeling tangled, work the corners in order:\n\n" +
"### 1. Extract pure computation\n" +
"Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" +
"### 2. Collapse derived state into `useMemo`\n" +
"State that is computable from other state shouldn't be its own state.\n\n" +
"### 3. Split orthogonal concerns into sibling hooks\n" +
"If two effects don't share dependencies, they don't belong in the same hook.\n\n" +
"### 4. Promote to a reducer\n" +
"Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" +
"> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.",
},
},
text(
"## The 4-fold path of refactoring a hook\n\n" +
"When a React hook starts feeling tangled, work the corners in order:\n\n" +
"### 1. Extract pure computation\n" +
"Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" +
"### 2. Collapse derived state into `useMemo`\n" +
"State that is computable from other state shouldn't be its own state.\n\n" +
"### 3. Split orthogonal concerns into sibling hooks\n" +
"If two effects don't share dependencies, they don't belong in the same hook.\n\n" +
"### 4. Promote to a reducer\n" +
"Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" +
"> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.",
),
],
},
{
role: "assistant",
content: [
text("I'll compute that for you."),
{
kind: "text",
data: {
text: "I'll compute that for you.",
},
},
{
kind: "tool_call",
data: {
tool_call_id: "call_calc_1",
name: "run_calculation",
type: "tool_call",
id: "call_calc_1",
name: "run_calculation",
input: {
type: "function",
arguments: {
expression: "compound_interest(principal=10000, rate=0.05, years=10)",
},
},
},
{
kind: "tool_result",
data: {
tool_call_id: "call_calc_1",
content: {
value: 16288.95,
currency: "USD",
note: "Annual compounding; rounded to cents.",
},
},
},
{
kind: "text",
data: {
text:
"\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.",
},
},
toolResult("call_calc_1", {
value: 16288.95,
currency: "USD",
note: "Annual compounding; rounded to cents.",
}),
text(
"\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.",
),
],
},
{
role: "assistant",
content: [
{
kind: "text",
data: {
text:
"Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" +
"If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" +
"Next step: tell me about your read/write ratio and I can be more specific.",
},
},
text(
"Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" +
"If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" +
"Next step: tell me about your read/write ratio and I can be more specific.",
),
],
},
];
const FALLBACK_REPLY: ChatMessage = {
role: "assistant",
content: [{ kind: "text", data: { text: "(No reply available.)" } }],
content: [text("(No reply available.)")],
};
export function pickReply(scriptIndex: number): ChatMessage {

View file

@ -38,8 +38,8 @@ describe("chats-store reducer", () => {
expect(chat?.seedMessages).toHaveLength(1);
expect(chat?.seedMessages[0]?.role).toBe("user");
expect(chat?.seedMessages[0]?.content[0]).toEqual({
kind: "text",
data: { text: "Help me with React" },
type: "text",
text: "Help me with React",
});
});

View file

@ -38,7 +38,7 @@ function deriveTitle(text: string): string {
function userMessage(text: string): ChatMessage {
return {
role: "user",
content: [{ kind: "text", data: { text } }],
content: [{ type: "text", text }],
};
}

View file

@ -1,26 +1,22 @@
/**
* Stricter discriminated-union view over @qltysh/fabro-api-client's
* `CompletionContentPart` ({ kind: string; data: any }). Each variant in our
* union is assignable to the API client type at the boundary, but inside the
* chat code we get exhaustive switch checking.
* `CompletionContentPart`, the lithos `ContentPart` wire shape discriminated
* by `type`. Each variant in our union is assignable to the API client type
* at the boundary, but inside the chat code we get exhaustive switch checking.
*/
export type ChatContentPart =
| { kind: "text"; data: { text: string } }
| { type: "text"; text: string }
| {
kind: "tool_call";
data: {
tool_call_id: string;
name: string;
arguments: { [key: string]: JsonValue };
};
type: "tool_call";
id: string;
name: string;
input: { type: "function"; arguments: { [key: string]: JsonValue } };
}
| {
kind: "tool_result";
data: {
tool_call_id: string;
content: JsonValue;
is_error?: boolean;
};
type: "tool_result";
tool_call_id: string;
content: ChatContentPart[];
is_error?: boolean;
};
export type JsonValue =

View file

@ -1,17 +1,44 @@
import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client";
import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client";
// Providers a managed environment can be created with. `local` is a reserved,
// in-memory environment, never a managed-environment provider, so it is never
// offered. The provider is fixed at creation time and cannot be changed.
export const CREATABLE_PROVIDERS = [
EnvironmentProvider.DOCKER,
EnvironmentProvider.DAYTONA,
] as const;
// The providers linked into the server. Any other provider kind names a
// sandbox-driver plugin the operator configured under
// `server.sandbox.providers.<kind>`.
export const LOCAL_PROVIDER = "local";
export const DOCKER_PROVIDER = "docker";
export const DAYTONA_PROVIDER = "daytona";
export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const;
export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings };
// `local` runs in the caller's directory and never clones. Every other
// provider owns an isolated workspace that Fabro clones into.
export function isCloneBasedProvider(provider: string): boolean {
return provider !== LOCAL_PROVIDER;
}
// Whether a server-managed environment can back Git-targeted work such as
// automations: only the clone-based (creatable) providers qualify.
// automations: only clone-based providers qualify.
export function isCloneBasedEnvironment(environment: Environment): boolean {
return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider);
return isCloneBasedProvider(environment.provider);
}
// Providers a managed environment can be created with: every enabled
// clone-based provider. `local` is a reserved, in-memory environment, never a
// managed-environment provider, so it is never offered.
export function creatableProviders(providers: ProviderSettingsMap): string[] {
return Object.keys(providers)
.filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled)
.sort(compareProviderKinds);
}
// Bundled kinds first, in their canonical order, then plugins alphabetically.
export function compareProviderKinds(left: string, right: string): number {
const rank = (kind: string) => {
const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind);
return index === -1 ? BUNDLED_PROVIDERS.length : index;
};
return rank(left) - rank(right) || left.localeCompare(right);
}
export function providerLabel(provider: string): string {

View file

@ -11,29 +11,31 @@ export interface SandboxStateDisplay {
text: string;
}
const PENDING = { dot: "bg-amber", text: "text-amber" } as const;
const QUIET = { dot: "bg-fg-muted", text: "text-fg-muted" } as const;
const GONE = { dot: "bg-coral", text: "text-coral" } as const;
/**
* Display metadata for every normalized sandbox lifecycle state. Shared by the
* Display metadata for every sandbox driver lifecycle state. Shared by the
* run overview summary panel and the dedicated sandbox page so the dot color,
* label, and hover copy stay consistent.
* label, and hover copy stay consistent. A state this build does not know
* renders as `unknown`.
*/
export const SANDBOX_STATE_DISPLAY: Record<SandboxState, SandboxStateDisplay> = {
unknown: {
label: "Unknown",
description: "The sandbox state could not be determined.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
provisioning: {
label: "Provisioning",
description: "The sandbox is being provisioned.",
dot: "bg-amber",
text: "text-amber",
creating: {
label: "Creating",
description: "The sandbox is being created.",
...PENDING,
},
starting: {
label: "Starting",
description: "The sandbox is starting up.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
running: {
label: "Running",
@ -44,55 +46,71 @@ export const SANDBOX_STATE_DISPLAY: Record<SandboxState, SandboxStateDisplay> =
stopping: {
label: "Stopping",
description: "The sandbox is shutting down.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
stopped: {
label: "Stopped",
description: "The sandbox is stopped.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
pausing: {
label: "Pausing",
description: "The sandbox is being paused.",
...PENDING,
},
paused: {
label: "Paused",
description: "The sandbox is paused.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
deleting: {
label: "Deleting",
description: "The sandbox is being deleted.",
dot: "bg-amber",
text: "text-amber",
resuming: {
label: "Resuming",
description: "The sandbox is resuming.",
...PENDING,
},
deleted: {
label: "Deleted",
description: "The sandbox has been deleted.",
dot: "bg-coral",
text: "text-coral",
archiving: {
label: "Archiving",
description: "The sandbox is being archived.",
...PENDING,
},
archived: {
label: "Archived",
description: "The sandbox has been archived.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
restoring: {
label: "Restoring",
description: "The sandbox is being restored.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
resizing: {
label: "Resizing",
description: "The sandbox resources are being resized.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
forking: {
label: "Forking",
description: "The sandbox is being forked.",
...PENDING,
},
snapshotting: {
label: "Snapshotting",
description: "A snapshot of the sandbox is being taken.",
...PENDING,
},
deleting: {
label: "Deleting",
description: "The sandbox is being deleted.",
...PENDING,
},
deleted: {
label: "Deleted",
description: "The sandbox has been deleted.",
...GONE,
},
error: {
label: "Error",
description: "The sandbox encountered an error.",
dot: "bg-coral",
text: "text-coral",
...GONE,
},
};

View file

@ -35,7 +35,7 @@ function formatUsdMicrosOrDash(usdMicros?: number | null): string {
function formatModelRef(model?: BillingModelRef | null): string | null {
if (!model) return null;
const speed = model.speed && model.speed !== "standard" ? ` · ${model.speed}` : "";
const speed = model.speed ? ` · ${model.speed}` : "";
return `${model.provider}:${model.model_id}${speed}`;
}

View file

@ -103,14 +103,15 @@ mock.restore();
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function sandboxDetails(
overrides: Partial<SandboxDetails> & {
overrides: {
sandbox?: Partial<SandboxDetails["sandbox"]> & {
runtime?: Partial<NonNullable<SandboxDetails["sandbox"]["runtime"]>>;
};
status?: Partial<SandboxDetails["status"]>;
} = {},
): SandboxDetails {
const sandbox = overrides.sandbox ?? {};
const { sandbox: _sandboxOverride, ...detailOverrides } = overrides;
const status = overrides.status ?? {};
return {
sandbox: {
provider: "docker",
@ -126,34 +127,27 @@ function sandboxDetails(
},
...sandbox,
},
state: "running",
native_state: null,
region: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
network: networkDetails(),
labels: {},
timestamps: { created_at: null, last_activity_at: null },
...detailOverrides,
status: {
id: sandbox.runtime?.id ?? "",
state: "running",
provider_state: "",
error_reason: null,
resources: null,
sandbox_kind: null,
region: null,
labels: {},
image: null,
snapshot: null,
network: null,
workspace_ownership: null,
web_url: null,
created_at: null,
updated_at: null,
...status,
},
};
}
function networkDetails(
overrides: Partial<SandboxDetails["network"]> = {},
): SandboxDetails["network"] {
return {
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
...overrides,
};
}
function networkPolicy(
mode: SandboxDetails["network"]["egress"]["mode"],
cidrs: string[] = [],
): SandboxDetails["network"]["egress"] {
return { mode, cidrs };
}
function textContent(renderer: TestRenderer.ReactTestRenderer): string {
return renderer.root
.findAll((node) => typeof node.type === "string")
@ -230,22 +224,18 @@ describe("RunSandbox route", () => {
working_directory: "/workspace",
},
},
state: "running",
native_state: "running",
region: undefined,
resources: {
cpu_cores: 2,
memory_bytes: 4 * 1024 * 1024 * 1024,
disk_bytes: undefined,
},
network: networkDetails({
egress: networkPolicy("open"),
ingress: networkPolicy("blocked"),
}),
labels: { run: "abc" },
timestamps: {
created_at: "2026-05-09T12:00:00Z",
last_activity_at: undefined,
status: {
state: "running",
provider_state: "running",
resources: {
cpu_cores: 2,
memory_mb: 4 * 1024,
disk_mb: null,
gpus: null,
},
network: "allow_all",
labels: { run: "abc" },
created_at: "2026-05-09T12:00:00Z",
},
});
const renderer = renderRoute();
@ -256,8 +246,8 @@ describe("RunSandbox route", () => {
.filter((text): text is string => typeof text === "string");
expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]);
const copy = textContent(renderer);
expect(copy).toContain("Open");
expect(copy).toContain("Blocked");
expect(copy).toContain("Allow all");
expect(copy).toContain("4 GiB");
});
test("links to the provider dashboard when a sandbox web URL is present", () => {
@ -269,8 +259,10 @@ describe("RunSandbox route", () => {
working_directory: "/workspace",
},
},
web_url:
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
status: {
web_url:
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
},
});
const renderer = renderRoute();
@ -296,18 +288,12 @@ describe("RunSandbox route", () => {
working_directory: "/tmp/project",
},
},
state: "unknown",
native_state: undefined,
region: undefined,
resources: {
cpu_cores: undefined,
memory_bytes: undefined,
disk_bytes: undefined,
},
labels: {},
timestamps: {
created_at: undefined,
last_activity_at: undefined,
status: {
state: "unknown",
resources: { cpu_cores: null, memory_mb: null, disk_mb: null, gpus: null },
labels: {},
created_at: null,
updated_at: null,
},
});
const renderer = renderRoute();
@ -328,35 +314,29 @@ describe("RunSandbox route", () => {
expect(noLabelsCopy).toHaveLength(1);
});
test("renders unknown network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
}),
});
test("renders an unknown network policy", () => {
currentDetails = sandboxDetails({ status: { network: null } });
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("Network");
expect(copy).toContain("Egress");
expect(copy).toContain("Ingress");
expect(copy).toContain("Policy");
expect(copy).toContain("Unknown");
});
test("renders blocked, essentials, and CIDR network policies", () => {
test("renders blocked and CIDR allow list network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]),
ingress: networkPolicy("essentials_only"),
}),
status: { network: { cidr_allow_list: { cidrs: ["10.0.0.0/8", "192.168.0.0/16"] } } },
});
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("CIDR allow list");
expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16");
expect(copy).toContain("Essentials only");
currentDetails = sandboxDetails({ status: { network: "block" } });
const blocked = renderRoute();
expect(textContent(blocked)).toContain("Blocked");
});
test("shows the empty state when no sandbox is reported", () => {

View file

@ -22,7 +22,7 @@ import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state";
import type {
RunSandbox,
SandboxDetails,
SandboxNetwork,
SandboxNetworkPolicy,
SandboxResources,
} from "@qltysh/fabro-api-client";
import FilesystemPanel from "./run-sandbox/filesystem-panel";
@ -57,27 +57,47 @@ function nullableTimestamp(value: string | null | undefined): string {
return value ? formatAbsoluteTs(value) : EMPTY_VALUE;
}
function nullableMemory(bytes: number | null | undefined): string {
return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE;
function nullableMegabytes(megabytes: number | null | undefined): string {
return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE;
}
function nullableCpu(cores: number | null | undefined): string {
return cores != null ? formatCpuCores(cores) : EMPTY_VALUE;
}
type SandboxNetworkPolicy = SandboxNetwork["egress"];
type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"];
function nullableCount(count: number | null | undefined): string {
return count != null ? String(count) : EMPTY_VALUE;
}
const NETWORK_POLICY_DISPLAY: Record<SandboxNetworkPolicyMode, string> = {
unknown: "Unknown",
open: "Open",
blocked: "Blocked",
cidr_allow_list: "CIDR allow list",
essentials_only: "Essentials only",
const NETWORK_POLICY_DISPLAY: Record<string, string> = {
provider_default: "Provider default",
allow_all: "Allow all",
block: "Blocked",
};
function networkPolicySummary(policy: SandboxNetworkPolicy): string {
return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode;
/** The policy's name, and the entries of an allow list when it carries one. */
function describeNetworkPolicy(
policy: SandboxNetworkPolicy | null | undefined,
): { summary: string; entries: { label: string; values: string[] } | null } {
if (policy == null) {
return { summary: "Unknown", entries: null };
}
if (typeof policy === "string") {
return { summary: NETWORK_POLICY_DISPLAY[policy] ?? policy, entries: null };
}
if ("cidr_allow_list" in policy) {
return {
summary: "CIDR allow list",
entries: { label: "Allowed CIDRs", values: policy.cidr_allow_list.cidrs },
};
}
if ("domain_allow_list" in policy) {
return {
summary: "Domain allow list",
entries: { label: "Allowed domains", values: policy.domain_allow_list.domains },
};
}
return { summary: "Unknown", entries: null };
}
interface RowProps {
@ -142,11 +162,12 @@ function Panel({ title, children }: PanelProps) {
}
function StatusStrip({ details }: { details: SandboxDetails }) {
const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown;
const status = details.status;
const display = SANDBOX_STATE_DISPLAY[status.state] ?? SANDBOX_STATE_DISPLAY.unknown;
const provider = details.sandbox.provider;
const providerState = status.provider_state ?? "";
const showNative =
details.native_state &&
details.native_state.toLowerCase() !== details.state.toLowerCase();
providerState.length > 0 && providerState.toLowerCase() !== status.state.toLowerCase();
return (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-md border border-line bg-panel/60 px-4 py-3 text-sm">
<span className="font-mono text-xs text-fg-muted uppercase tracking-wide">
@ -158,7 +179,7 @@ function StatusStrip({ details }: { details: SandboxDetails }) {
</span>
{showNative && (
<span className="font-mono text-xs text-fg-muted">
({details.native_state})
({providerState})
</span>
)}
</div>
@ -167,20 +188,25 @@ function StatusStrip({ details }: { details: SandboxDetails }) {
function OverviewPanel({ details }: { details: SandboxDetails }) {
const sandbox = details.sandbox;
const status = details.status;
const runtime = sandbox.runtime;
return (
<Panel title="Overview">
<Row label="ID" value={nullable(runtime?.id)} />
<Row label="ID" value={nullable(status.id || runtime?.id)} />
<Row label="Working directory" value={nullable(runtime?.working_directory)} />
<Row
label="Region"
value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
value={status.region ? status.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
/>
<Row label="Image" value={nullable(sandbox.image ?? sandbox.snapshot)} />
{details.web_url && (
<Row
label="Image"
value={nullable(status.image ?? status.snapshot ?? sandbox.image ?? sandbox.snapshot)}
/>
{status.sandbox_kind && <Row label="Kind" value={status.sandbox_kind} />}
{status.web_url && (
<LinkRow
label="Provider"
href={details.web_url}
href={status.web_url}
text={
sandbox.provider === "daytona"
? "Open in Daytona"
@ -192,29 +218,25 @@ function OverviewPanel({ details }: { details: SandboxDetails }) {
);
}
function ResourcesPanel({ resources }: { resources: SandboxResources }) {
function ResourcesPanel({ resources }: { resources: SandboxResources | null | undefined }) {
return (
<Panel title="Resources">
<Row label="CPU" value={nullableCpu(resources.cpu_cores)} />
<Row label="Memory" value={nullableMemory(resources.memory_bytes)} />
<Row label="Disk" value={nullableMemory(resources.disk_bytes)} />
<Row label="CPU" value={nullableCpu(resources?.cpu_cores)} />
<Row label="Memory" value={nullableMegabytes(resources?.memory_mb)} />
<Row label="Disk" value={nullableMegabytes(resources?.disk_mb)} />
{resources?.gpus != null && <Row label="GPUs" value={nullableCount(resources.gpus)} />}
</Panel>
);
}
function NetworkPanel({ network }: { network: SandboxNetwork }) {
const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [
{ label: "Egress CIDRs", policy: network.egress },
{ label: "Ingress CIDRs", policy: network.ingress },
].filter(({ policy }) => policy.mode === "cidr_allow_list");
function NetworkPanel({ network }: { network: SandboxNetworkPolicy | null | undefined }) {
const { summary, entries } = describeNetworkPolicy(network);
return (
<Panel title="Network">
<Row label="Egress" value={networkPolicySummary(network.egress)} />
<Row label="Ingress" value={networkPolicySummary(network.ingress)} />
{cidrRows.map(({ label, policy }) => (
<Row key={label} label={label} value={policy.cidrs.join(", ") || EMPTY_VALUE} />
))}
<Row label="Policy" value={summary} />
{entries && (
<Row label={entries.label} value={entries.values.join(", ") || EMPTY_VALUE} />
)}
</Panel>
);
}
@ -237,11 +259,8 @@ function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | un
function TimestampsPanel({ details }: { details: SandboxDetails }) {
return (
<Panel title="Timestamps">
<Row label="Created" value={nullableTimestamp(details.timestamps.created_at)} />
<Row
label="Last activity"
value={nullableTimestamp(details.timestamps.last_activity_at)}
/>
<Row label="Created" value={nullableTimestamp(details.status.created_at)} />
<Row label="Last updated" value={nullableTimestamp(details.status.updated_at)} />
</Panel>
);
}
@ -259,9 +278,9 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
<div className="space-y-4">
<StatusStrip details={details} />
<OverviewPanel details={details} />
<ResourcesPanel resources={details.resources} />
<NetworkPanel network={details.network} />
<LabelsPanel labels={details.labels} />
<ResourcesPanel resources={details.status.resources} />
<NetworkPanel network={details.status.network} />
<LabelsPanel labels={details.status.labels} />
<TimestampsPanel details={details} />
</div>
);

View file

@ -26,10 +26,7 @@ function makeIdlePreview(): PreviewMutationShape {
}
function makeServicesData(data: SandboxService[]) {
return {
data,
meta: { source: "ss" as const },
};
return { data };
}
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
@ -116,46 +113,6 @@ describe("ServicesPanelView", () => {
expect(titles).toHaveLength(1);
});
test("shows an iproute2 tip when services were discovered from procfs", () => {
const service: SandboxService = {
port: 3000,
addresses: ["0.0.0.0:3000"],
processes: [],
preview_supported: true,
};
const renderer = renderView({
servicesQuery: {
...makeIdleQuery(),
data: {
data: [service],
meta: { source: "procfs" },
},
},
previewMutation: makeIdlePreview(),
});
const tipLabels = renderer.root.findAll(
(node) =>
node.type === "span" &&
Array.isArray(node.children) &&
node.children.includes("Tip:"),
);
expect(tipLabels).toHaveLength(1);
const commands = renderer.root.findAll(
(node) =>
node.type === "code" &&
Array.isArray(node.children) &&
node.children.includes("apt-get install iproute2"),
);
expect(commands).toHaveLength(1);
const tipText = JSON.stringify(renderer.toJSON());
expect(tipText).toContain("Install ");
expect(tipText).toContain("ss");
expect(tipText).toContain(" in the sandbox for improved services listing:");
});
test("shows API error state with the error message", () => {
const renderer = renderView({
servicesQuery: {

View file

@ -77,7 +77,6 @@ export function ServicesPanelView({
const [previewError, setPreviewError] = useState<string | null>(null);
const services = servicesQuery.data?.data ?? [];
const discoverySource = servicesQuery.data?.meta.source;
const queryErrorMessage = describeQueryError(servicesQuery.error);
const showLoading = servicesQuery.isLoading && !servicesQuery.data;
const showError = queryErrorMessage !== null && !servicesQuery.data;
@ -150,7 +149,6 @@ export function ServicesPanelView({
<EmptyState title="No services" />
) : (
<>
{discoverySource === "procfs" ? <ProcfsDiscoveryTip /> : null}
<ServicesTable
services={services}
pendingPort={pendingPort}
@ -170,17 +168,6 @@ function describeQueryError(error: unknown): string | null {
return "Could not load services.";
}
function ProcfsDiscoveryTip() {
return (
<div className="mb-3 rounded-md border border-line bg-panel/60 px-3 py-2 text-xs leading-5 text-fg-3">
<span className="font-medium text-fg-2">Tip:</span>{" "}
Install <code className="font-mono text-fg-2">ss</code> in the sandbox
for improved services listing:{" "}
<code className="font-mono text-fg-2">apt-get install iproute2</code>
</div>
);
}
function ServicesTable({
services,
pendingPort,

View file

@ -90,13 +90,17 @@ describe("eventsToActivity", () => {
event: "agent.message",
stage_id: "verify@1",
node_id: "verify",
properties: { text: "first visit reply" },
properties: {
event: { AssistantMessage: { text: "first visit reply" } },
},
}),
envelope(4, {
event: "agent.message",
stage_id: "verify@2",
node_id: "verify",
properties: { text: "second visit reply" },
properties: {
event: { AssistantMessage: { text: "second visit reply" } },
},
}),
];
@ -204,19 +208,19 @@ describe("eventsToActivity", () => {
event: "agent.tool.started",
node_id: "detect-drift",
properties: {
tool_call_id: "call-1",
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "read_file",
arguments: { path: "config.toml" },
arguments: { path: "config.toml" } } },
},
}),
envelope(2, {
event: "agent.tool.completed",
node_id: "detect-drift",
properties: {
tool_call_id: "call-1",
event: { ToolCallCompleted: { tool_call_id: "call-1",
tool_name: "read_file",
output: "[redis]",
is_error: false,
is_error: false } },
},
}),
];
@ -242,13 +246,17 @@ describe("eventsToActivity", () => {
event: "agent.steering.injected",
stage_id: "nap@1",
node_id: "nap",
properties: { text: "say hello", visit: 1 },
properties: {
event: { SteeringInjected: { text: "say hello" } },
},
}),
envelope(3, {
event: "agent.steering.injected",
stage_id: "other@1",
node_id: "other",
properties: { text: "wrong stage", visit: 1 },
properties: {
event: { SteeringInjected: { text: "wrong stage" } },
},
}),
];
@ -410,8 +418,8 @@ describe("eventsToActivity", () => {
stage_id: "simplify@1",
node_id: "simplify",
properties: {
text: "Done.",
billing: { input_tokens: 10, output_tokens: 5 },
event: { AssistantMessage: { text: "Done.",
usage: { input: 10, output: 5 } } },
},
}),
envelope(3, {
@ -482,7 +490,7 @@ describe("eventsToActivity", () => {
event: "agent.message",
stage_id: "plan@1",
node_id: "plan",
properties,
properties: { event: { AssistantMessage: properties } },
}),
],
"plan@1",
@ -549,7 +557,9 @@ describe("eventsToActivity", () => {
envelope(2, {
event: "agent.message",
node_id: "detect-drift",
properties: { text: "signal" },
properties: {
event: { AssistantMessage: { text: "signal" } },
},
}),
envelope(3, {
event: "run.running",
@ -559,7 +569,9 @@ describe("eventsToActivity", () => {
envelope(4, {
event: "agent.message",
node_id: "other-stage",
properties: { text: "wrong stage" },
properties: {
event: { AssistantMessage: { text: "wrong stage" } },
},
}),
];
@ -942,9 +954,9 @@ describe("buildStageActivity pending tools", () => {
stage_id: "plan@1",
node_id: "plan",
properties: {
tool_call_id: "call-1",
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" },
arguments: { command: "cargo build" } } },
},
}),
envelope(2, {
@ -952,16 +964,18 @@ describe("buildStageActivity pending tools", () => {
stage_id: "plan@1",
node_id: "plan",
properties: {
tool_call_id: "call-2",
event: { ToolCallStarted: { tool_call_id: "call-2",
tool_name: "read_file",
arguments: { file_path: "/tmp/x" },
arguments: { file_path: "/tmp/x" } } },
},
}),
envelope(3, {
event: "agent.tool.completed",
stage_id: "plan@1",
node_id: "plan",
properties: { tool_call_id: "call-1", output: "ok" },
properties: {
event: { ToolCallCompleted: { tool_call_id: "call-1", output: "ok" } },
},
}),
];
expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([
@ -980,9 +994,9 @@ describe("buildStageActivity pending tools", () => {
stage_id: "plan@2",
node_id: "plan",
properties: {
tool_call_id: "call-1",
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: {},
arguments: {} } },
},
}),
];
@ -995,18 +1009,18 @@ describe("buildStageActivity pending tools", () => {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
tool_call_id: "call-1",
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" },
arguments: { command: "cargo build" } } },
},
}),
envelope(2, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
tool_call_id: "call-2",
event: { ToolCallStarted: { tool_call_id: "call-2",
tool_name: "shell",
arguments: { command: "cargo test" },
arguments: { command: "cargo test" } } },
},
}),
];
@ -1030,21 +1044,25 @@ describe("buildStageActivity pending tools", () => {
envelope(1, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: { tool_name: "shell", arguments: { command: "ignored" } },
properties: {
event: { ToolCallStarted: { tool_name: "shell", arguments: { command: "ignored" } } },
},
}),
envelope(2, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
tool_call_id: "call-1",
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "kept" },
arguments: { command: "kept" } } },
},
}),
envelope(3, {
event: "agent.tool.completed",
stage_id: "plan@1",
properties: { output: "must not clear call-1" },
properties: {
event: { ToolCallCompleted: { output: "must not clear call-1" } },
},
}),
];
@ -1253,9 +1271,9 @@ describe("tool-call-only agent responses", () => {
stage_id: "code@1",
node_id: "code",
properties: {
text: "",
billing: { input_tokens: 4200, output_tokens: 96 },
tool_call_count: 2,
event: { AssistantMessage: { text: "",
usage: { input: 4200, output: 96 },
tool_call_count: 2 } },
},
}),
];
@ -1279,7 +1297,9 @@ describe("tool-call-only agent responses", () => {
event: "agent.message",
stage_id: "code@1",
node_id: "code",
properties: { text: "", tool_call_count: 1 },
properties: {
event: { AssistantMessage: { text: "", tool_call_count: 1 } },
},
}),
envelope(2, {
event: "prompt.completed",
@ -1341,10 +1361,10 @@ describe("tool batch boundaries", () => {
stage_id: STAGE,
node_id: "code",
properties: {
text,
billing: { input_tokens: 1000, output_tokens: 20 },
tool_call_count: toolCallCount,
},
event: { AssistantMessage: { text,
usage: { input: 1000, output: 20 },
tool_call_count: toolCallCount } },
},
});
}
@ -1362,9 +1382,9 @@ describe("tool batch boundaries", () => {
stage_id: STAGE,
node_id: "code",
properties: {
tool_call_id: callId,
event: { ToolCallStarted: { tool_call_id: callId,
tool_name: "shell",
arguments: { command },
arguments: { command } } },
},
}),
envelope(seq + 1, {
@ -1372,7 +1392,9 @@ describe("tool batch boundaries", () => {
ts: endTs,
stage_id: STAGE,
node_id: "code",
properties: { tool_call_id: callId, tool_name: "shell", output: "ok" },
properties: {
event: { ToolCallCompleted: { tool_call_id: callId, tool_name: "shell", output: "ok" } },
},
}),
];
}

View file

@ -92,6 +92,7 @@ import {
getNumber,
getObject,
getString,
isRecord,
type UnknownRecord,
} from "../lib/unknown";
import type {
@ -293,6 +294,20 @@ interface PendingCommand {
script: string;
}
/**
* The coding agent's own payload inside an `agent.*` event: `properties.event`
* is externally tagged, `{ AssistantMessage: {...} }`, so the variant's fields
* live one level down. An event with no such payload reads as empty.
*/
function agentEventPayload(props: UnknownRecord): UnknownRecord {
const event = getObject(props, "event");
if (!event) return {};
for (const value of Object.values(event)) {
if (isRecord(value)) return value;
}
return {};
}
function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null {
const reasoning = getObject(props, "reasoning");
if (!reasoning) return null;
@ -339,15 +354,17 @@ export function buildStageActivity(
// A text-free message still marks the end of a model response — it is
// the boundary between two batches of tool calls. Dropping it would
// splice unrelated batches into one tool group.
const billing = (props.billing ?? {}) as UnknownRecord;
const message = agentEventPayload(props);
const usage = getObject(message, "usage") ?? {};
turns.push({
kind: "assistant",
ts: e.ts,
content: getString(props, "text") ?? e.text ?? "",
inputTokens: getNumber(billing, "input_tokens") ?? 0,
outputTokens: getNumber(billing, "output_tokens") ?? 0,
toolCallCount: getNumber(props, "tool_call_count") ?? null,
reasoning: readTurnReasoning(props),
content: getString(message, "text") ?? "",
inputTokens: getNumber(usage, "input") ?? 0,
outputTokens:
(getNumber(usage, "output") ?? 0) + (getNumber(usage, "reasoning") ?? 0),
toolCallCount: getNumber(message, "tool_call_count") ?? null,
reasoning: readTurnReasoning(message),
});
break;
}
@ -368,7 +385,7 @@ export function buildStageActivity(
break;
}
case "agent.steering.injected": {
const text = getString(props, "text") ?? e.text ?? "";
const text = getString(agentEventPayload(props), "text") ?? "";
if (text) {
turns.push({ kind: "steer", ts: e.ts, content: text });
}
@ -403,35 +420,33 @@ export function buildStageActivity(
break;
}
case "agent.tool.started": {
const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? "";
const call = agentEventPayload(props);
const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? "";
if (!callId) break;
const args = props.arguments ?? e.arguments;
const args = call.arguments;
pendingTools.set(callId, {
ts: e.ts,
toolName: getString(props, "tool_name") ?? e.tool_name ?? "",
toolName: getString(call, "tool_name") ?? "",
input: typeof args === "string" ? args : JSON.stringify(args ?? ""),
});
break;
}
case "agent.tool.completed": {
const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? "";
const call = agentEventPayload(props);
const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? "";
if (!callId) break;
const started = pendingTools.get(callId);
pendingTools.delete(callId);
const output = props.output ?? e.output ?? "";
const output = call.output ?? "";
const result =
typeof output === "string" ? output : JSON.stringify(output, null, 2);
turns.push({
kind: "tool",
ts: started?.ts ?? e.ts,
toolName:
started?.toolName ??
getString(props, "tool_name") ??
e.tool_name ??
"",
toolName: started?.toolName ?? getString(call, "tool_name") ?? "",
input: started?.input ?? "",
result,
isError: (props.is_error ?? e.is_error) === true,
isError: call.is_error === true,
durationMs: durationBetween(started?.ts, e.ts),
});
break;

View file

@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { useEnvironments, useServerSettings } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers";
import { creatableProviders, providerLabel } from "../lib/environment-providers";
import {
Badge,
Muted,
@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS =
// environment's lifetime. `local` is never offered (it's reserved/in-memory).
function NewEnvironmentMenu() {
const { data } = useServerSettings();
const providers = data
? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled)
: [];
const providers = data ? creatableProviders(data.server.sandbox.providers) : [];
if (providers.length === 0) {
return (

View file

@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Link } from "react-router";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { ComputerDesktopIcon } from "@heroicons/react/24/outline";
import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client";
import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Dot,
@ -12,24 +12,56 @@ import {
SettingsPageIntro,
} from "../components/settings-panel";
import { plural } from "../lib/plural";
import {
DAYTONA_PROVIDER,
DOCKER_PROVIDER,
LOCAL_PROVIDER,
compareProviderKinds,
providerLabel,
type ProviderSettingsMap,
} from "../lib/environment-providers";
export function meta() {
return [{ title: "Sandboxes — Fabro" }];
}
type SandboxProviderId = "local" | "docker" | "daytona";
type SandboxProvider = {
id: SandboxProviderId;
id: string;
name: string;
description: string;
enabled: boolean;
bundled: boolean;
secretName?: string;
};
const DESCRIPTION =
"Runtime environments where workflow stages execute. Configured via settings.toml.";
// Display copy for the providers linked into the server. Any other kind is a
// sandbox-driver plugin configured under `server.sandbox.providers.<kind>`.
const BUNDLED_PROVIDER_COPY: Record<string, Omit<SandboxProvider, "id" | "enabled" | "bundled">> = {
[LOCAL_PROVIDER]: {
name: "Local",
description: "Run stages directly on the Fabro host.",
},
[DOCKER_PROVIDER]: {
name: "Docker",
description: "Run stages in isolated Docker containers on the host daemon.",
},
[DAYTONA_PROVIDER]: {
name: "Daytona",
description: "Run stages in cloud sandboxes managed by Daytona.",
secretName: "DAYTONA_API_KEY",
},
};
function pluginDescription(settings: ServerSandboxProviderSettings): string {
const path = settings.plugin?.path;
return path
? `Sandbox plugin executable at ${path}.`
: "Sandbox plugin executable resolved from PATH.";
}
export default function SettingsSandboxes() {
const query = useServerSettings();
const settings = query.data;
@ -42,29 +74,24 @@ export default function SettingsSandboxes() {
);
}
function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) {
function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) {
const providers: SandboxProvider[] = useMemo(
() => [
{
id: "local",
name: "Local",
description: "Run stages directly on the Fabro host.",
enabled: settings.local.enabled,
},
{
id: "docker",
name: "Docker",
description: "Run stages in isolated Docker containers on the host daemon.",
enabled: settings.docker.enabled,
},
{
id: "daytona",
name: "Daytona",
description: "Run stages in cloud sandboxes managed by Daytona.",
enabled: settings.daytona.enabled,
secretName: "DAYTONA_API_KEY",
},
],
() =>
Object.keys(settings)
.sort(compareProviderKinds)
.map((id) => {
const entry = settings[id];
const copy = BUNDLED_PROVIDER_COPY[id];
return copy
? { id, enabled: entry.enabled, bundled: true, ...copy }
: {
id,
enabled: entry.enabled,
bundled: false,
name: providerLabel(id),
description: pluginDescription(entry),
};
}),
[settings],
);
@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) {
"grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong";
const dim = provider.enabled ? "" : "opacity-60";
if (provider.id === "local") {
if (provider.id === LOCAL_PROVIDER) {
return (
<span className={`${chip} text-page ${dim}`}>
<ComputerDesktopIcon className="size-6" aria-hidden="true" />
@ -146,7 +173,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) {
);
}
if (failed) {
if (failed || !provider.bundled) {
return (
<span className={`${chip} text-base font-medium text-page ${dim}`}>
{provider.name.charAt(0)}

View file

@ -50,7 +50,7 @@ Relevant current Fabro sources:
- `docs-internal/events-strategy.md`
- `lib/components/fabro-workflow/src/event.rs`
- `lib/foundation/fabro-types/src/run_event/mod.rs`
- `lib/components/fabro-agent/src/types.rs`
- pebble's `CodingEvent` (`pebble-coding-agent`, re-exported from `fabro_types`)
## Comparison Matrix

View file

@ -125,7 +125,17 @@ Never build the same `RunEvent` twice if multiple sinks receive it.
### 1. Add the typed event
Add a variant to `Event`, `AgentEvent`, or `SandboxEvent` as appropriate.
Add a variant to `Event`, `AgentEvent`, or `SandboxLifecycle` as appropriate. Sandbox
facts come from two places: the pipeline emits `Initializing`, `Ready`, and
`InitializeFailed` around bringing the sandbox up, and the sandbox driver's own events
(operations and their outcome, progress inside a create such as an image pull, snapshot
builds, state observations, notices) are stored whole as `Event::SandboxDriver` by the
`DriverEventRecorder` in the `fabro-workflow::event` module. Their names derive from the
event (`fabro_types::sandbox_driver_event_name`): `<subject>.<action>.<phase>` such as
`sandbox.stop.completed` or `snapshot.create.started`, `<subject>.state`, and
`<subject>.notice`; their `properties` are the driver's event as the driver serializes
it, so the driver's `Event` is part of fabro's stored format. Fabro-sandbox emits no
events of its own.
### 2. Add tracing

View file

@ -1421,6 +1421,7 @@ Emitted when a sub-agent is spawned.
"original_name": "list_issues"
}
],
"startup_ms": 842,
"visit": 1
}
}
@ -1431,6 +1432,7 @@ Emitted when a sub-agent is spawned.
| `server_name` | string | MCP server name |
| `tool_count` | number | Number of tools available |
| `tools` | array | Names-only tool summaries for the ready server, sorted by qualified `name`. Each entry has `name` (Fabro-qualified `mcp__{server}__{tool}` identifier) and `original_name` (server-provided tool name). Descriptions and input schemas are intentionally omitted. The field is omitted from serialized JSON for legacy parity when empty. |
| `startup_ms` | number | Whole milliseconds from the server's launch to its tools being listed. Events written before the field existed read as `0`. |
| `visit` | number | Stage visit count when the server became ready |
### `agent.mcp.failed`
@ -1443,7 +1445,9 @@ Emitted when a sub-agent is spawned.
"session_id": "ses_abc",
"properties": {
"server_name": "filesystem",
"error": "Connection refused"
"error": "Connection refused",
"startup_ms": 4,
"visit": 1
}
}
```
@ -1452,6 +1456,37 @@ Emitted when a sub-agent is spawned.
|----------|------|-------------|
| `server_name` | string | MCP server name |
| `error` | string | Error message |
| `startup_ms` | number | Whole milliseconds from the server's launch to the failure. Events written before the field existed read as `0`. |
| `visit` | number | Stage visit count when the server failed |
### `agent.mcp.disconnected`
An MCP server that was ready lost its connection during the stage. Pebble
publishes the disconnect once per server, from whichever session's tool call
first observed the closed connection, so the event can originate in a
sub-agent. Every later call to that server's tools fails until the session
ends. The stage projection moves the server's status from `ready` to
`disconnected`; its `tool_count` and `invoked` flag are kept.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "agent.mcp.disconnected",
"node_id": "code", "node_label": "code",
"session_id": "ses_abc",
"properties": {
"server_name": "github",
"error": "transport closed",
"visit": 1
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `server_name` | string | MCP server name |
| `error` | string | What closed the connection, as the client observed it |
| `visit` | number | Stage visit count when the disconnect was observed |
### `agent.memory.loaded`
@ -1807,82 +1842,52 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo
| `provider` | string | Sandbox provider name |
| `error` | string | Error message |
### `sandbox.snapshot.pulling`
### Sandbox driver events
Emitted only when the Docker image cache misses and Fabro starts pulling the image.
Everything the sandbox driver reports about a run's sandbox is stored whole. The
event name derives from the driver's event: `<subject>.<action>.<phase>` for an
operation (`sandbox.start.started`, `sandbox.stop.completed`, `sandbox.delete.failed`,
`sandbox.create.progress` for an image pull inside the create, `snapshot.create.started`
and `snapshot.create.completed` for a snapshot build), `<subject>.state` for a state
observation, and `<subject>.notice` for a notice. `properties` is the driver's event as
the driver serializes it.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.pulling",
"event": "sandbox.stop.completed",
"properties": {
"name": "my-image:latest"
"id": {"source_id": "9b2f…", "sequence": 4},
"occurred_at": "2026-08-31T20:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox", "id": "container-abc123"},
"operation_id": "58a1…",
"correlation_id": "01JQ…",
"type": "operation_completed",
"action": "stop",
"duration": {"secs": 1, "nanos": 250000000}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Image/snapshot name |
| `id` | object | The driver's event id: `source_id` and `sequence` within that source |
| `occurred_at` | string | When the driver observed the event (RFC 3339) |
| `provider` | string | The driver's provider kind (`host`, `docker`, `daytona`, a plugin's kind) |
| `subject` | object | `type` (`sandbox`, `snapshot`, `volume`, `provider`) with the resource's `id` and `name` when known |
| `operation_id` | string | Groups the started, progress, and completed or failed events of one operation |
| `correlation_id` | string | The run id fabro attached |
| `type` | string | `operation_started`, `operation_progress`, `operation_completed`, `operation_failed`, `state_observed`, or `notice` |
| `action` | string | The operation (`create`, `start`, `stop`, `delete`, `snapshot`, …) on operation events |
| `progress` | object | `code` (`image.pull`, `snapshot.build`, …), `message`, and optional `completed`, `total`, `unit` on progress events |
| `duration` | object | `secs` and `nanos` on completed and failed events |
| `error` | object | `kind`, `message`, `retryable`, `causes` on failed events |
### `sandbox.snapshot.creating`
Emitted only when a Daytona snapshot cache miss or inactive snapshot requires Fabro to create or wait for the snapshot.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.creating",
"properties": {
"name": "my-snapshot"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
### `sandbox.snapshot.ready`
Emitted when an image or snapshot ensure step succeeds. Cache hits still emit this event with a near-zero `duration_ms`; explicit no-op paths such as Docker `auto_pull = false` and the Daytona default snapshot path do not.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.ready",
"properties": {
"name": "my-snapshot",
"duration_ms": 30000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `duration_ms` | number | Ensure duration |
### `sandbox.snapshot.failed`
Emitted when an image or snapshot ensure step fails.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.failed",
"properties": {
"name": "my-snapshot",
"error": "disk quota exceeded"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `error` | string | Error message |
| `causes` | string[] | Optional error cause chain |
Events stored under `sandbox.start.*`, `sandbox.stop.*`, `sandbox.delete.*`, and
`sandbox.snapshot.*` before the driver's events were kept whole carry fabro's earlier
`provider`, `name`, `duration_ms`, and `error` properties instead; readers treat them as
unknown bodies.
### `sandbox.git.started`

View file

@ -386,6 +386,7 @@ V2 keeps the current durable family surface broadly intact.
- `agent.sub.closed`
- `agent.mcp.ready`
- `agent.mcp.failed`
- `agent.mcp.disconnected`
- `agent.failover`
### Git

View file

@ -4,14 +4,13 @@ This document defines how Fabro resolves LLM credentials and constructs `fabro-l
## Core Rules
- `fabro_auth::CredentialSource` is the credential authority.
- Long-lived runtime contexts store `Arc<dyn CredentialSource>` and `Arc<Catalog>`, not `Client`.
- The lithos `CredentialProvider` trait is the credential authority; Fabro's vault, SQL secret store, and API-key stores implement it directly.
- Long-lived runtime contexts store `Arc<dyn CredentialProvider>` and `Arc<Catalog>`, not `Client`.
- Call `fabro_llm::client::Client::from_source(&source, catalog).await?` at the point of use.
- Standalone setup and tests that use default settings build a default `Arc<Catalog>` locally, then pass it explicitly.
- `GenerateParams::new(model, client)` always receives an explicit `Arc<Client>`.
- When a caller needs diagnostics in runtime request-serving paths, call `source.resolve(catalog)` directly and consume both `credentials` and `auth_issues`.
- `EnvCredentialSource` is the env-backed source for env-only or no-vault contexts.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts.
- When a caller needs diagnostics in runtime request-serving paths, read `FabroClient::ready` and `auth_issues` (from `ClientBuilder::build_ready`), or call `lithos_llm::credentials::readiness` directly.
- `VaultCredentialSource` is the normal source for vault-backed runtime contexts; `VaultCredentialSource::environment_only()` serves env-only or no-vault contexts.
## Why

View file

@ -135,7 +135,7 @@ Server auth intentionally exposes a mutable `RequestAuth` context slot for publi
### Examples by crate
**fabro-agent:**
**fabro-workflow (agent stages):**
```rust
info!(model = %model, "Starting agent session");
info!(turns = turn_count, tool_calls = total_calls, "Agent session complete");
@ -176,7 +176,7 @@ Every crate that does meaningful work should emit tracing events. The `tracing`
tracing.workspace = true
```
The subscriber is initialized once in `fabro-cli`. Library crates (`fabro-agent`, `fabro-llm`, etc.) only emit events — they never configure the subscriber. This means:
The subscriber is initialized once in `fabro-cli`. Library crates (`fabro-workflow`, `fabro-llm`, etc.) only emit events — they never configure the subscriber. This means:
- Library crates import `tracing::{info, debug, warn, error}` and call the macros
- The events go nowhere in unit tests (this is fine — tests verify behavior, not log output)

View file

@ -1,5 +1,11 @@
# Fabro MCP Server — QA Test Plan
> Historical manual QA results. The run-create source selectors and flat settings
> below predate the registered-version contract. Current calls register file contents
> with `fabro_workflow_version_create`, then pass `workflow_version_id`, an explicit
> standalone `target`, and nested `args` to `fabro_run_create`. See
> [the current MCP guide](../public/agents/mcp.mdx) for the supported contract.
One-time manual QA pass for the 5 tools exposed by `fabro-mcp-server`. Source of truth: `lib/apps/fabro-mcp-server/src/run_tools/`.
This plan is **not** a template for adding automated test coverage — it exists to drive a single hands-on sweep against a real running server. Tick boxes as scenarios pass; add notes inline for failures or surprising behavior. Open bugs/PRs for issues found; do not port these scenarios into the Rust test suite.

View file

@ -20,7 +20,7 @@ The crate-local `src/migrations.rs` module is the registry. It imports numbered
Examples:
- `fabro-config` owns settings-file migrations.
- `fabro-server` owns server startup migrations for `server.env` and vault files.
- `fabro-server` owns server startup activation migrations for SQLite blob storage and run history.
Keep migration APIs `pub(crate)` unless another crate genuinely orchestrates the migration.
@ -125,7 +125,7 @@ If a migration removes entries from a file after writing another store, write th
Choose the error policy deliberately.
Use warn-and-continue only when the normal path may still succeed and compatibility is best-effort. The legacy vault-entry migration does this because an unreadable legacy shape should not block loading an otherwise usable vault file.
Use warn-and-continue only when the normal path may still succeed and compatibility is best-effort.
Return an error when the migration found data it must move or rewrite and cannot do so safely. This gives operators a precise migration failure instead of a later, misleading startup error.

View file

@ -14,7 +14,7 @@ target node on that edge; parallel branches are not subgraph walks.
Every branch:
- receives an independent fork of the parent workflow context;
- receives the same `Arc<dyn Sandbox>` as the parent run;
- receives the same `Arc<RunSandbox>` as the parent run;
- inherits the same sandbox working directory and `internal.work_dir`;
- runs through the normal handler dispatch path, including dry-run behavior;
- retains its branch identity, lifecycle events, and hook scope.

View file

@ -43,11 +43,10 @@ the vault:
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
Provisioning into the vault is not the same as the resolver being vault-only. `CredentialResolver`
owns a documented process-env fallback that runs after the vault lookup
(`lib/foundation/fabro-auth/src/resolve.rs:198-204`), and `CredentialRef::Env(name)` is a
first-class credential source (`resolve.rs:350`). Which paths that fallback is live on is a
per-process question:
Provisioning into the vault is not the same as the resolver being vault-only. `VaultCredentialSource`
(`lib/foundation/fabro-auth/src/vault_source.rs`) reads each secret name lithos-llm asks for from
the process environment first and the vault second, under the same conventional names. Which paths
that environment lookup is live on is a per-process question:
- **Server process** — inert. `lib/apps/fabro-server/src/server.rs:2453` builds
`SqlVaultCredentialSource::vault_only(...)`, so the env lookup always returns `None`.
@ -85,11 +84,12 @@ consumption time) and `vars` (non-sensitive run variables, substituted early at
`{{ env.NAME }}` tokens still parse but never resolve; they fail loudly with a migration message. A
token whose namespace is unavailable in the resolution context also fails loudly.
The reference implementation is LLM provider `extra_headers`, resolved against the vault at
`lib/foundation/fabro-auth/src/resolve.rs:376-378`:
The reference implementation is LLM provider `default_headers`, whose `{{ secrets.* }}` values are
resolved against the vault in `lib/foundation/fabro-auth/src/vault_source.rs`
(`interpolated_headers`) and re-sent as credential headers:
```toml
[llm.providers.example.extra_headers]
[llm.providers.example.default_headers]
authorization = "Bearer {{ secrets.EXAMPLE_TOKEN }}"
```
@ -119,7 +119,7 @@ Bootstrap secrets come from one of two sources:
Optional integration secrets are provisioned into the vault, usually with `fabro secret set` or `fabro install`.
There is no startup-time secret generation. A temporary startup migration moves recognized legacy optional secrets from process env or `server.env` into the vault, removes matching `server.env` entries after writing a backup, and logs conflicts by key name only. Runtime lookup remains vault-only after that migration step. See [migrations-strategy.md](migrations-strategy.md) for the migration pattern.
There is no startup-time secret generation or import of optional integration secrets from process env or `server.env`. The compatibility migrations for those sources and pre-token/OAuth vault entries have been removed. The separate one-time import of current-format `secrets.json` entries into SQLite remains supported.
## Subprocess Boundaries

View file

@ -5,12 +5,13 @@ description: "Sandboxing workflow execution"
Sandboxes isolate agent execution from the host machine. When an agent runs a shell command, edits a file, or searches code, it does so inside a sandbox — preventing unintended side effects on the host and providing a reproducible environment for each run.
Fabro supports three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). See [Environments](/execution/environments) for full provider-specific configuration.
Fabro bundles three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). Additional providers run as [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugins configured under `[server.sandbox.providers.<kind>]`; an environment selects one by its kind name. See [Environments](/execution/environments) for full provider-specific configuration and [Server configuration](/administration/server-configuration#serversandboxproviders-section) for plugin settings.
Operators can enable or disable which providers the server may launch with
`[server.sandbox.providers.<provider>]` in `settings.toml`. Missing entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled.
Dry-run Docker/Daytona runs execute locally, so they are governed by the `local` provider policy.
`[server.sandbox.providers.<kind>]` in `settings.toml`. Missing bundled entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled,
and a plugin kind with no entry is disabled. Dry-run runs on any non-local provider execute
locally, so they are governed by the `local` provider policy.
The API can also list Fabro-managed sandboxes directly from configured providers:

View file

@ -181,9 +181,10 @@ The GitHub OAuth client ID still lives under `[server.integrations.github].clien
### `[server.sandbox.providers]` section
Controls which sandbox providers the server may launch. Missing provider entries default to
`enabled = true` for backward compatibility. Disabling a provider rejects new runs whose effective
provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by
Controls which sandbox providers the server may launch, keyed by provider kind. The bundled
providers `local`, `docker`, and `daytona` run inside the server and default to `enabled = true`
when their entry is missing. Disabling a provider rejects new runs whose effective provider is
disabled; dry-run Docker/Daytona runs use the local provider and are governed by
`server.sandbox.providers.local.enabled`.
```toml title="settings.toml"
@ -197,6 +198,34 @@ enabled = true
enabled = true
```
Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin:
an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must
be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
providers reject these plugin keys.
```toml title="settings.toml"
[server.sandbox.providers.e2b]
enabled = true
path = "/opt/fabro/plugins/fabro-sandbox-e2b" # default: `fabro-sandbox-<kind>` on PATH
sha256 = "0123…cdef" # pin the executable; `dev = true` skips it
args = []
inherit_env = ["PATH"]
[server.sandbox.providers.e2b.env]
E2B_API_URL = "https://api.e2b.example"
```
| Key | Description | Default |
|---|---|---|
| `enabled` | Whether runs may select this provider | `true` |
| `path` | Plugin executable path | `fabro-sandbox-<kind>` on `PATH` |
| `sha256` | Pinned SHA-256 of the executable, hex | none |
| `dev` | Allow launching without a checksum | `false` |
| `args` | Arguments passed to the executable | `[]` |
| `env` | Complete environment for the plugin, apart from `inherit_env` | `{}` |
| `inherit_env` | Ambient variables forwarded from the server process | `[]` |
### `[server.slatedb]` section
Configure the embedded SlateDB key-value store used for the remaining
@ -301,12 +330,22 @@ The CLI has its own `[cli.logging]` section.
### `[run.git.author]` section
Customize the git author identity used for checkpoint commits. When not set, defaults to `fabro` / `fabro@local`.
Override the Git author and committer identity for every commit a run creates: Fabro's own checkpoint and metadata commits, and any `git commit` a prepare step, command stage, or agent tool runs inside the sandbox. Fabro resolves one identity per run and injects it as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` into every workflow command, so the primary checkout, additional clones, and repositories a workflow clones itself all commit as the same identity. It does not write to any Git configuration file.
When a field is not set, Fabro derives it from the run's GitHub credential:
| Credential | Name | Email |
|---|---|---|
| GitHub App (`strategy = "app"`) | `<slug>[bot]` | `<id>+<slug>[bot]@users.noreply.github.com` |
| Token (`strategy = "token"`) | the token's user login | `<id>+<login>@users.noreply.github.com` |
| None | `Fabro` | `noreply@fabro.sh` |
Setting both `name` and `email` skips the credential lookup. Setting one field overlays it on the derived identity. A lookup failure for the selected credential fails the run at setup; Fabro never silently switches to another author. The resolved identity is recorded in the run's event stream as `git.identity.resolved` and in the run state as `git_identity`.
| Key | Description | Default |
|---|---|---|
| `name` | Git author name | `"fabro"` |
| `email` | Git author email | `"fabro@local"` |
| `name` | Git author and committer name | derived from the run's GitHub credential |
| `email` | Git author and committer email | derived from the run's GitHub credential |
### `[server.integrations.github]` section
@ -371,7 +410,7 @@ Fabro splits server-runtime secrets into two scopes:
`server.env` is not used for Slack, Daytona, Brave Search, Venice Search, LLM provider keys, `GITHUB_TOKEN`, or GitHub App private key/client secret/webhook secret. Configure those optional integrations with `fabro secret set`, `fabro provider login`, or `fabro install`.
During startup, Fabro temporarily migrates recognized legacy optional integration secrets from process env or `server.env` into the vault. When a matching `server.env` entry can be safely removed, Fabro writes a hidden backup beside `server.env` first. Process env values cannot be cleaned up automatically, so remove those from your deployment environment after the vault contains the secret.
Startup does not import optional integration secrets from process env or `server.env`, or rewrite old `credential` / `environment` vault entries. Provision these secrets with the commands above before upgrading an installation that still uses those retired sources or formats.
Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional.

View file

@ -5,7 +5,7 @@ description: "Connect MCP tools to agents and expose Fabro runs to MCP clients"
MCP ([Model Context Protocol](https://modelcontextprotocol.io/)) lets you connect external tool servers to Fabro agents. An MCP server exposes tools over a standardized protocol — databases, APIs, file systems, custom services — and Fabro discovers and registers them automatically. Agents call MCP tools the same way they call built-in tools.
Fabro can also run as an MCP server. MCP clients can use Fabro's run-management tools to create, inspect, control, wait for, and read events from workflow runs through the authenticated `fabro` CLI.
Fabro can also run as an MCP server. MCP clients can register reusable workflow versions and use Fabro's run-management tools to create, inspect, control, wait for, and read events from workflow runs through the authenticated `fabro` CLI.
Workflow agents can opt in to that same run-management tool catalog with `[run.agent] fabro_tools = true`. This is not the same as configuring external MCP servers for the agent. When a workflow agent calls `fabro_run_create`, created runs are always [child runs](/execution/child-runs) of the current run; an explicit `parent_id` must match the current run ID.
@ -39,6 +39,7 @@ fabro mcp init claude --name fabro-testing --server https://fabro-testing.exampl
| Tool | Purpose |
|---|---|
| `fabro_workflow_version_create` | Register supplied workflow contents and local dependencies as an immutable version ID, without creating a run. |
| `fabro_run_create` | Create one or more workflow runs, optionally under a parent run, starting them by default. |
| `fabro_run_search` | Search runs by ID, parent, workflow, labels, status, archive state, and creation time. |
| `fabro_run_get` | Read-only inspection of a run: returns its summary, projection, and pending questions without mutating state. |
@ -47,30 +48,96 @@ fabro mcp init claude --name fabro-testing --server https://fabro-testing.exampl
| `fabro_run_pair` | Inspect, start, message, end, or read transcript for a live run pairing session. |
| `fabro_run_events` | List, inspect, or search stored events for a run. |
For a simple create call, `fabro_run_create` accepts a workflow selector string:
### Register workflow contents from a sandbox
```json
{ "runs": ["sleeper"] }
```
Use the object form when you need create options:
Use shell and read tools in your sandbox to acquire the workflow and all its local
config, graph, prompt, script, import, and child-workflow files. For example, clone
a repository with your sandbox's shell tool, then read `workflow.fabro` and its
referenced `prompt.md`. Submit the actual contents:
```json
{
"runs": [
{
"workflow": "sleeper",
"auto_approve": true,
"dry_run": true,
"goal_file": "plans/ship-it.md",
"labels": { "source": "mcp" },
"start": true
}
]
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph W { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }",
"prompt.md": "Review the implementation."
}
}
```
Use `goal` for inline goal text or `goal_file` to read the run goal from a file. They are mutually exclusive. Relative `goal_file` paths resolve from the run's `cwd`, or from the MCP server working directory when `cwd` is omitted.
Call `fabro_workflow_version_create` with this object and keep the returned
`workflow_version_id`. You can reuse it in a `RunIntent` submitted through the
[Create Run API](/api-reference/runs/create-run). Registration packages and uploads
child workflows before their parents; callers do not calculate dependency IDs.
`entrypoint` is an exact supplied key, including when it has no extension. File
values are text, never host paths or URLs to fetch. Missing references and paths
that escape the supplied tree fail. The source tree is limited to 512 files,
512 KiB per file, and 2 MiB of text; each resulting serialized version must also
fit the existing 2 MiB API limit. Case-insensitive file and ancestor collisions
are rejected before staging. Graph nesting through child workflows and imports
is limited to 64 levels, including the entrypoint. A supplied sibling
`workflow.toml` must be valid even when a graph is the entrypoint; a valid config
that selects another graph is omitted. Collection follows declared file references; command
`script` values remain literal text, and paths embedded in shell commands are not
inspected or acquired.
Registration resolves no runtime secrets, selects no environment, and starts no
execution. It requires a user credential or a worker token with `agent:run_tools`;
ordinary worker tokens and same-run Ask Fabro sessions cannot register versions.
If an upload fails, retry the same contents: previously registered immutable
versions remain reusable. Use the returned ID with `fabro_run_create`.
### Create runs
Call `fabro_run_create` with a registered workflow version ID and an independent
workspace target. You can reuse the same ID for multiple runs without uploading
again:
```json
{
"runs": [{
"workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"target": { "kind": "git", "repo": "acme/widgets", "branch": "main" },
"environment_id": "production",
"goal": "Review the checkout implementation.",
"args": {
"inputs": { "component": "checkout" },
"labels": { "source": "mcp" },
"auto_approve": false
},
"start": false
}]
}
```
Standalone MCP calls require an explicit `target`: Git coordinates, `kind: none`
for an empty workspace, or a server-local folder for a compatible Local environment.
A folder path refers to the server's filesystem, not the MCP client's filesystem.
Workflow content does not determine the target. Native workflow-agent calls may
omit `target` to inherit the parent's execution target; see [Child Runs](/execution/child-runs).
Supplying `parent_id` in standalone MCP does not enable that inheritance.
`args` uses the same fields as RunIntent: `inputs`, `labels`, `model`, `provider`,
`dry_run`, `auto_approve`, and `preserve_sandbox`. Omitted boolean overrides stay
omitted; explicit `false` is preserved. `environment_id` selects a server environment;
omission uses the server default. `title`, literal `goal`, and an exact `parent_id`
are optional. Caller/project/machine run settings are not read by the tool.
Workflow-owned settings remain in the registered `workflow.toml`.
<Note>
Run creation accepts registered IDs only. Replace workflow strings and inline
sources with a preceding `fabro_workflow_version_create` call. Move flat run
options into `args`, use `environment_id` instead of `environment`, and send goal
text instead of `goal_file`. Obtain local or remote files using the caller's own
shell/read tools. Neither Fabro tool fetches remote sources or reads a caller path.
</Note>
Creation persists a submitted run. A separate start request follows by default;
set `start: false` to start later. Batches contain 150 items and stop at the first
failure. If creation succeeded before a start, summary lookup, or later item
failed, the error includes the already-created run IDs. Inspect those runs before
retrying to avoid creating duplicates.
Run summaries returned by the MCP server include parent metadata. Use `parent_id` on `fabro_run_create` to create a child run, `parent_id` on `fabro_run_search` to list direct children, and the `link_parent` or `unlink_parent` actions on `fabro_run_interact` to change an existing run's parent. See [Child Runs](/execution/child-runs) for the orchestration model.

View file

@ -40,7 +40,7 @@ Call `wait` again to receive the new turn's result. Call `close_agent` when the
## Depth limits
Sub-agents can themselves spawn sub-agents, creating a hierarchy. `max_subagent_depth` limits how deep that tree can grow. By default the depth limit is `1`.
Sub-agents can themselves spawn sub-agents, creating a hierarchy. The coding agent limits how many child sessions a stage can hold open at once and how deep the tree can grow; the defaults keep one level of children.
If a child tries to exceed the limit, `spawn_agent` returns an error immediately.

File diff suppressed because it is too large Load diff

View file

@ -78,97 +78,74 @@ Claude Fable 5 is available as an explicit model but is not the default Anthropi
## Configuring providers and models
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Models are nested under their provider, so two providers can expose the same model slug without overwriting each other.
Fabro's catalog is the [lithos-llm](https://docs.rs/lithos-llm) built-in catalog. The `[llm]` table in settings is a second layer over it: a lithos catalog overlay that adds providers and models or changes existing entries. Later layers win. Tables merge key by key and every other value replaces. Models are nested under their provider, so two providers can expose the same model id without overwriting each other.
Provider and model facts use lithos field names: `adapter`, `codec`, `base_url`, `auth`, `enabled`, `limits`, `capabilities`, `pricing`, `small_default`, `probe`, `family`, and the cutoffs. The coding harness a model expects lives under `metadata.agent`, a namespace lithos ships and other agents such as Pebble read too. See [Settings Configuration](/reference/user-configuration#llm) for every key.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
family = "team-code"
default = true
small_default = true
aliases = ["team-code"]
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 }
family = "team-code"
small_default = true
estimated_output_tps = 80
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.providers.proxy.models."team-code-large".features]
tools = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
The gateway's API key is `PROXY_API_KEY`: lithos derives the secret name from the provider id (upper case, `-` and `.` as `_`, then `_API_KEY`). Store it with `fabro secret set PROXY_API_KEY ...`.
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"
default_model = "litellm-gpt-5"
enabled = true
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
api_model = "gpt-5"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
```
`api_id` is the opaque model name sent to that provider's API. It defaults to the exact model slug, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
`api_model` is the model name sent to that provider's API. It defaults to the exact model id, so omit it when the two strings match. Fabro does not infer vendor prefixes or rewrite the value.
<Note>
Historical built-in catalog keys that exposed provider API IDs remain accepted as compatibility selectors. Fabro normalizes a primary or node selector such as `openai/gpt-5.6-sol` to the canonical `gpt-5.6-sol` slug before normal provider-aware selection. With no provider pin, the highest-priority ready offering wins; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Fabro also normalizes these keys in legacy top-level `[llm.models]` rows without rewriting the settings file.
A `provider/model` selector such as `openai/gpt-5.6-sol` pins the provider and names the model by id, alias, or wire id. A bare selector with no provider pin picks the highest-priority ready offering; a separate `provider = "openrouter"` pin selects the OpenRouter offering. Providers with `allow_passthrough = true` also accept `provider/model` selectors for models the catalog does not list.
</Note>
Model roles are separate: `default = true` controls normal model selection for workflow execution, while `small_default = true` marks the provider's small/cheap utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's normal default.
Model roles are separate: the provider's `default_model` controls normal model selection for workflow execution, while `small_default = true` on a model row marks the provider's small utility model for metadata tasks such as generated run titles. If a provider has no small default, Fabro falls back to that provider's default model.
Provider auth is declared in `[llm.providers.<id>.auth]` with ordered `env:<NAME>` or `vault:<NAME>` refs. The primary auth header defaults to `bearer`; override with `header = { custom = "Header-Name" }` for providers like Anthropic that use `x-api-key`. Omit the `[llm.providers.<id>.auth]` block entirely for providers that need no API key (e.g. Ollama). Custom headers for any provider — including providers that need only interpolation headers and no API-key auth — go in `extra_headers` as literal text or `{{ secrets.NAME }}` tokens. Put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Provider auth has two parts. The lithos `auth` scheme says how a credential is sent: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }` for providers that take several secret headers, `{ type = "none" }`, or `{ type = "aws" }`. lithos also says which secret names a provider reads: `OPENAI_API_KEY` for `openai`, `GEMINI_API_KEY` then `GOOGLE_API_KEY` for `gemini`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and `<PROVIDER>_API_KEY` for a provider you define. Fabro looks each name up in the process environment first and the server vault second. Custom headers for any provider go in `default_headers` as literal text or `{{ secrets.NAME }}` tokens; put credentials in secrets and reference them with `{{ secrets.NAME }}` instead of a bare literal.
Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `extra_headers` takes precedence.
Workflow runs also add `x-session-id: <run-id>` to every LLM request so compatible gateways can group requests from the same run. An explicitly configured `x-session-id` in provider `default_headers` takes precedence.
Provider `agent_profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `openai`, `gemini`, `kimi`, and `gpt56`; model-level values override provider-level values.
Provider `metadata.agent.profile` defaults from `adapter` and controls profile-specific behavior such as which tools the agent registers, project-memory filenames, CLI/ACP command selection, and native session routing. Valid values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`; model-level values override provider-level values.
Two profiles are selected per model rather than per provider, because they follow the model wherever it is served: `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna). The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Three profiles are selected per model rather than per provider, because they follow the model wherever it is served: `claude-5` for Claude 5 models, `kimi` for Kimi models, and `gpt56` for the GPT-5.6 models (Sol, Terra, Luna); `gpt6` for GPT-6 Astra runs on the same harness as `gpt56`. The `gpt56` profile uses Codex's narrow core surface — `shell_command`, `apply_patch`, and `update_plan`, plus optional credential-backed `web_search` — instead of fabro's dedicated file-read, discovery, and `web_fetch` tools. On OpenAI-compatible routes that cannot carry the freeform `apply_patch` grammar, it substitutes the JSON-schema `edit_file` tool. Session features may add their own question, skill, or subagent tools separately.
Provider `billing_policy` defaults from `adapter` and controls usage-cost estimation. Use `openai`, `anthropic`, `gemini`, or `none`. Model rows may override it for models whose billing family differs from their provider's — for example, Claude models served through OpenRouter set `billing_policy = "anthropic"` so cache reads and writes price correctly.
Costs come from the lithos `pricing` table on each model row. Each token bucket (input, output, reasoning, cache read, cache write) prices at its own rate, with optional long-context and speed tiers. Providers that return an authoritative charge, such as OpenRouter, override the catalog estimate; the billing record says which source it came from.
<Note>
Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted.
@ -180,7 +157,7 @@ Fabro ships a built-in [Venice](/integrations/venice) provider with a curated ca
### Poolside
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_model` values.
### OpenRouter
@ -197,8 +174,8 @@ Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disa
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
enabled = true
```
Store both token values in the Fabro server vault:
@ -214,8 +191,8 @@ Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with
```toml title="settings.toml"
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
enabled = true
```
### Ollama
@ -227,7 +204,7 @@ Fabro ships an Ollama provider definition that is disabled by default. Enable it
enabled = true
```
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-slug>"]` 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`.
Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.providers.ollama.models."<model-id>"]` 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
@ -235,7 +212,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
| Provider | Default model |
|---|---|
| `anthropic` | `claude-sonnet-4-6` |
| `anthropic` | `claude-sonnet-5` |
| `openai` | `gpt-5.6-sol` |
| `gemini` | `gemini-3.5-flash` |
| `moonshot` | `kimi-k3` |

View file

@ -140,3 +140,93 @@ In the web UI, the Workflows page lists all available workflows. Click into a wo
</Frame>
See the [Quick Start](/getting-started/quick-start) to try it out, or browse the [example workflows](/examples/repl-handoff) for real-world patterns.
## Select workflow source and run target
`fabro run` and `fabro create` accept the same source and target options. `create`
registers the workflow and leaves a submitted run for you to start with
`fabro start RUN`. `run` also starts it, then attaches unless you pass `--detach`.
The required positional argument selects the workflow. Local names are found in
the current checkout, then a marked project, then installed user workflows.
Explicit local paths retain their usual package roots.
```sh
fabro run review
fabro create ./review.toml --target-from ../app
fabro run acme/workflows@v1.2:review --target acme/app@release
# Equivalent explicit flags:
fabro run review --workflow-repo acme/workflows --workflow-ref v1.2 \
--target-repo acme/app --target-branch release
```
In the last example, workflow instructions come from `acme/workflows`, and the
run works on `acme/app`. Neither selection changes the other. Local workflow paths,
`--goal-file`, and other caller inputs still resolve from the invocation context.
Without target flags, Fabro keeps its existing cwd/environment-based target
inference.
Remote workflow shorthand is `OWNER/REPO[@REF]:WORKFLOW`. The workflow selector
is required: `acme/workflows:review` selects a named workflow, and
`acme/workflows@v1.2:./reviews/security.toml` selects a file. Repository default
workflows are not supported; without `:WORKFLOW`, the positional argument retains
local lookup behavior. Prefix local paths containing a colon with `./`, `../`,
or `/` to avoid shorthand parsing. Shorthand cannot be combined with
`--workflow-repo` or `--workflow-ref`. Repository slugs currently imply GitHub.com.
`--workflow-repo OWNER/REPO` acquires source using native Git on your machine. A
workflow name selects `.fabro/workflows/NAME/workflow.toml` in that repository;
you can also supply an explicit repository-relative `.toml` or `.fabro` file.
Absolute paths, traversal, and directory selectors are rejected. A missing remote
workflow never falls back to a local or installed workflow.
`--workflow-ref` requires `--workflow-repo`. Omit it, or use `HEAD`, to select the
remote default branch. You can select a branch, tag, or full 40-hex commit SHA.
If a branch and tag share a name, qualify it with `refs/heads/` or `refs/tags/`.
Fabro resolves the revision once, fetches that exact commit into a temporary
checkout, and registers its workflow-version closure. A moved or unavailable
commit never causes a fallback to a newer revision. Checkout hooks, content
filters, and implicit Git LFS expansion are disabled; submodules are not fetched.
Temporary files are removed after collection or failure. Interrupting acquisition
stops owned Git processes before cleanup; collection already in progress must
finish before its files can be removed.
For local workflows without target flags, the existing `run.scm` repository
configuration in `workflow.toml` or `.fabro/project.toml` still participates in
target inference, with workflow values overriding project values field by field.
For clone-based environments, the configured repository must match the checkout's
origin. Without that configuration, omission is equivalent to `--target-from .`.
Explicit `--target-from`, `--target-repo`, and `--target` selections take precedence
over the configured repository.
Target selection depends on the environment:
| Selection | Local environment | Clone-based environment (Docker, Daytona, or plugin) |
| --- | --- | --- |
| Default cwd or `--target-from PATH` | Uses the live directory, including uncommitted files | Uses the enclosing Git repository and exact available commit; a non-Git directory selects an empty workspace |
| `--target-repo OWNER/REPO` or `--target OWNER/REPO[@BRANCH]` | Rejected | Uses the selected repository and exact observed branch commit; cloning must be enabled |
For clone-based execution, a target path selects a repository, not a subdirectory
working-directory override. Local target files are not uploaded. Existing target
observation may push committed local changes to origin; dirty changes are
excluded from clone targets and produce a warning. Detached or unavailable exact
commits fail. Folder targets require the directory to be accessible to the
server and its Local execution environment; passing a caller-local path does not
transfer it to a remote server.
`--target-branch` requires `--target-repo` and accepts a working branch name, not a
tag or SHA. Without it, Fabro resolves the repository's default branch. The CLI
only looks up target metadata; the execution sandbox clones the target.
The shorthand `--target acme/app@release/v2` selects the working branch
`release/v2`; omit `@BRANCH` to use the remote default branch. Target suffixes
accept working branches, while workflow suffixes accept branches, tags, or SHAs.
`--target`, `--target-from`, and `--target-repo` are mutually exclusive.
`--target-branch` cannot be combined with `--target`.
Local Git credential helpers, SSH-agent access through configured URL rewrites,
and user network configuration govern source acquisition and remote target
lookup. Fabro server login does not grant local Git access. The execution
sandbox still needs its own target-clone credentials.
`--dry-run` simulates execution; it can still fetch and upload workflow source,
and existing local target observation can still publish committed changes.

View file

@ -40,6 +40,8 @@ The `git_commit_sha` in a `checkpoint.completed` event identifies the run branch
Fabro disables Git commit and tag signing for checkpoint commits created inside a sandbox. Your personal or repository-level signing settings can stay enabled, but sandbox bookkeeping does not need access to your signing key.
Checkpoint commits and any commit a workflow command or agent creates all carry the run's one resolved Git identity. Fabro derives it from the run's GitHub App bot account or token user, or from the generic `Fabro <noreply@fabro.sh>` identity when the run has no GitHub credential, and `[run.git.author]` overrides it. See [`[run.git.author]`](/administration/server-configuration#rungitauthor-section).
### Durable execution state
Fabro derives run state from persisted events. The projection includes the run spec, start and status records, checkpoints, stage results, conclusion, and sandbox information. Artifact payloads live in CAS.

View file

@ -32,6 +32,7 @@ This exposes the same Fabro run tools available through [MCP](/agents/mcp):
| Tool | Purpose |
|---|---|
| `fabro_workflow_version_create` | Register supplied workflow files and return an immutable version ID |
| `fabro_run_create` | Create one or more child runs, starting them by default |
| `fabro_run_search` | Search runs, including direct children by `parent_id` |
| `fabro_run_get` | Inspect a run without mutating it |
@ -46,57 +47,69 @@ When a workflow agent calls `fabro_run_create`, Fabro always parents the created
## Create child runs
The simplest `fabro_run_create` call names a workflow:
Acquire workflow files with the agent's shell/read tools, then call
`fabro_workflow_version_create` with their contents:
```json
{
"runs": ["implement-and-test"]
"entrypoint": "workflow.fabro",
"files": {
"workflow.fabro": "digraph Child { start [shape=Mdiamond] work [prompt=\"@prompt.md\"] exit [shape=Msquare] start -> work -> exit }",
"prompt.md": "Implement the requested change and run the relevant tests."
}
}
```
Use the object form to pass run options:
Use the returned `workflow_version_id` in `fabro_run_create`:
```json
{
"runs": [
{
"workflow": "implement-and-test",
"goal": "Implement the checkout page refactor and run the test suite.",
"labels": {
"lane": "checkout",
"source": "parent-run"
},
"start": true
}
]
"runs": [{
"workflow_version_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"goal": "Implement the checkout page refactor and run its tests.",
"args": { "labels": { "lane": "checkout", "source": "parent-run" } },
"start": true
}]
}
```
The parent can create several children in one call:
A version can be reused for several children, each with its own goal, target, and
`args`. When the version already exists, skip registration. Read goal files with
the agent's read tool and pass literal `goal` text. Workflow names, paths, inline
source objects, and `goal_file` are no longer run-create inputs.
```json
{
"runs": [
{
"workflow": "implementation",
"goal_file": "plans/api.md",
"labels": { "lane": "api" }
},
{
"workflow": "implementation",
"goal_file": "plans/web.md",
"labels": { "lane": "web" }
},
{
"workflow": "review",
"goal": "Review the current branch for security and data-integrity risks.",
"labels": { "lane": "review" }
}
]
}
```
This flow works in Local, Docker, and Daytona environments. The agent can clone a
remote workflow in its sandbox and submit the resulting contents. Fabro's native
tool handler executes outside the sandbox, so registration treats file-map keys
as virtual relative paths and never reads sandbox paths from the worker host.
Include the workflow's referenced configuration, prompts, and child workflows in
the supplied tree. See [MCP](/agents/mcp) for package limits.
By default, `fabro_run_create` requests start for each created run. Set `"start": false` when the parent should create the child now and start it later.
Workflow content and workspace target are independent. If `target` is omitted,
a native child inherits the parent's canonical target: `none` or folder as-is,
and a Git target's repository and current execution branch (normally
`fabro/run/<parent-id>`). Push parent changes before creating the child; a child
clone sees that branch's remote HEAD. The parent's original pinned SHA/tag is
not inherited. If run branches are disabled, the original input branch is used;
if an enabled execution branch is unavailable, send an explicit target.
An explicit Git, `none`, or folder target overrides inheritance while the current
run remains the forced parent. Set `sha` on an explicit Git target to pin a child.
Server admission enforces folder access: Docker/Daytona parents cannot select a
server-host folder, including by requesting a Local child environment.
Standalone MCP always requires an explicit target, even with `parent_id`.
Use `environment_id` to choose a server environment; omission uses the server
default, not the parent's environment. Run overrides belong in canonical `args`:
`inputs`, `labels`, `model`, `provider`, `auto_approve`, `dry_run`, and
`preserve_sandbox`. Omission preserves workflow/server defaults; explicit `false`
is not omitted. The tool does not apply caller, project, or machine run settings.
Keep workflow-owned configuration in the registered `workflow.toml`.
Creation and start remain separate operations. Set `start: false` to leave a
child submitted. A batch stops on its first failure; if any runs have already
been created, their IDs appear in the error so the parent can inspect them
instead of recreating them blindly.
## Start and approval

View file

@ -270,7 +270,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Every provider except `local` is clone-based, including Docker, Daytona, and sandbox-driver plugins. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -195,7 +195,7 @@ speed = "fast"
| Field | Description |
|---|---|
| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. |
| `reasoning_effort` | Native reasoning-effort value to request when the selected model allows it, such as `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, or `"max"`. |
| `speed` | Native speed value to request when the selected model declares it, such as `"fast"`. The standard speed is implicit and does not need to be set. |
#### Fallback lists with splice
@ -417,8 +417,8 @@ commit_timeout = "30s"
| Field | Description |
|---|---|
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. |
| `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks (e.g. `pre-commit`, `commit-msg`). Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. |
| `commit_timeout` | Max duration for the per-node run-branch checkpoint commit (e.g. `"30s"`, `"10m"`). This commit runs repository commit hooks unless `skip_git_hooks` is `true`. Defaults to `"30s"`. |
| `skip_git_hooks` | Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks (e.g. `pre-commit`, `commit-msg`); the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro workflow `[[run.hooks]]`. |
| `commit_timeout` | Accepted for compatibility. The per-node run-branch checkpoint commit runs under the sandbox driver's git command budget; no repository hook can prolong it. |
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics: the highest layer that sets the field wins.
@ -498,7 +498,7 @@ Configure workflow agent behavior that is not tied to a single stage.
fabro_tools = true
```
`fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to use the same Fabro run-management MCP tool catalog exposed to human MCP clients: create, search, get, interact, gather, events, and pair.
`fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to use the same Fabro run-management MCP tool catalog exposed to human MCP clients: workflow version registration, create, search, get, interact, gather, events, and pair.
One workflow-agent exception is intentional: `fabro_run_create` always creates [child runs](/execution/child-runs) parented to the current run. If an agent supplies `parent_id`, it must match the current run ID.

View file

@ -27,8 +27,8 @@ Add the provider override to `~/.fabro/settings.toml`:
_version = 1
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
enabled = true
```
The SigV4 signing region is derived from `base_url` — change it to your Region's endpoint (`https://bedrock-runtime.<region>.amazonaws.com`, FIPS and China endpoints included).
@ -37,7 +37,7 @@ The SigV4 signing region is derived from `base_url` — change it to your Region
Two auth modes, tried in order:
**Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (Fabro's `<PROVIDER>_API_KEY` convention) — use whichever you prefer.
**Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (the `<PROVIDER>_API_KEY` convention) — use whichever you prefer.
```bash
fabro secret set AWS_BEARER_TOKEN_BEDROCK bedrock-api-key-...
@ -47,22 +47,12 @@ fabro secret set BEDROCK_API_KEY bedrock-api-key-...
Runs read the bearer token from the vault only. Workers start from a cleared environment and the bearer token is not on the inherited allowlist, so exporting it in the server's shell has no effect on runs. `fabro exec` and direct `fabro-llm` SDK usage do read it from process env.
**AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically. The catalog declares this as the `aws_sigv4` credential source:
**AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically.
```toml
[llm.providers.bedrock.auth]
credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY", "aws_sigv4"]
```
The key resolves from the process environment first (either name), then the server vault (`fabro secret set`), then falls back to SigV4 — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers).
The order is fixed by lithos-llm: `AWS_BEARER_TOKEN_BEDROCK`, then `BEDROCK_API_KEY`, then the AWS default chain. Each name is read from the process environment first and the server vault (`fabro secret set`) second — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers).
<Warning>
**Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* To run Converse models on SigV4 while using a mantle-only bearer key for GPT-5.x, pin the Converse provider to SigV4 explicitly:
```toml
[llm.providers.bedrock.auth]
credentials = ["aws_sigv4"]
```
**Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* Use a key that covers both surfaces, or keep Converse on SigV4 by leaving both Bedrock secret names unset and enabling only `bedrock`.
</Warning>
## Included models
@ -83,7 +73,7 @@ The built-in catalog curates Converse-capable models, using cross-region inferen
| `moonshotai.kimi-k2.5`, `zai.glm-5` | |
| `minimax.minimax-m2.5`, `nvidia.nemotron-3-super` | |
Any other Converse-capable Bedrock model can be added as a settings model entry with `provider = "bedrock"` and the Bedrock model or inference-profile id as `api_id`.
Any other Converse-capable Bedrock model can be added under `[llm.providers.bedrock.models."<model-id>"]` with the Bedrock model or inference-profile id as `api_model`.
Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedrock-mantle`, limited preview). OpenAI's frontier models live on the companion `bedrock-openai` provider below.
@ -113,7 +103,7 @@ fabro run workflow.fabro --model deepseek.v3-2
## Prompt caching
Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price Anthropic-style via the per-model `billing_policy`.
Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price at the row's `cached_input_usd_micros_per_million` and `cache_write_usd_micros_per_million` rates.
## Converse extensions
@ -142,7 +132,7 @@ Bedrock-specific request fields pass through verbatim via `provider_options.bedr
**"data retention mode 'default' is not available for this model"** — Fable 5 / Mythos-class models require opting into data sharing first; see [Model access and approvals](#model-access-and-approvals).
**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_id` (from `aws bedrock list-inference-profiles`) on the model entry.
**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_model` (from `aws bedrock list-inference-profiles`) on the model entry.
**`ValidationException` mentioning on-demand throughput** — The model requires an inference-profile id; use the `us.`/`global.`-prefixed id from the catalog rather than the bare model id.

View file

@ -126,9 +126,9 @@ Set either `image.docker` or `image.dockerfile`. `image.docker` can name any ima
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
```
Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it does not exist, Fabro creates it automatically and polls until it reaches `Active` state for up to 30 minutes. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, Fabro reuses it immediately.
The sandbox driver builds the image or Dockerfile into a Daytona snapshot named by its inputs (the image reference or Dockerfile text, the resources, and the Daytona API key) and creates the sandbox from it. If that snapshot already exists, it is reused immediately; otherwise the driver builds it and waits for it to reach `Active` state. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests.
The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, Fabro continues to reuse the existing snapshot.
The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, the existing snapshot continues to be reused.
<Note>
If neither image source is configured, sandboxes are created from the `daytona-medium` snapshot, which includes standard dev tools such as Git. To force a new Dockerfile snapshot, change the Dockerfile text, for example by adding a comment.
@ -237,11 +237,11 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho
### Custom snapshot did not roll
Custom Daytona snapshot names are computed from the image reference or Dockerfile, resource hints, tenant scope, and Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments.<slug>.image]`.
Custom Daytona snapshot names (`sandbox-driver-<hex>`) are computed by the sandbox driver from the image reference or Dockerfile, the resources, and the Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments.<slug>.image]`.
### "Timed out waiting for snapshot to become active"
Snapshot creation took longer than 30 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
Snapshot creation took longer than the sandbox driver's build budget. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
### Git clone fails for private repositories

View file

@ -44,7 +44,7 @@ export FIREWORKS_API_KEY=fw_...
## Included models
The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_id` values:
The built-in catalog gives Fireworks offerings the same human-facing model slugs used by other providers. Fireworks account-scoped model paths remain opaque `api_model` values:
| Fabro model slug | Fireworks API ID / notes |
| --- | --- |
@ -59,21 +59,14 @@ The built-in catalog gives Fireworks offerings the same human-facing model slugs
| `gpt-oss-120b` | `accounts/fireworks/models/gpt-oss-120b` |
| `gpt-oss-20b` | `accounts/fireworks/models/gpt-oss-20b`; provider small default |
Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_id` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`):
Any other Fireworks serverless model can be added under the provider. Choose a stable Fabro model slug as the table key and put the Fireworks account-scoped path in `api_model` (dots in upstream model names become `p`, e.g. `glm-5.2` → `glm-5p2`):
```toml title="settings.toml"
[llm.providers.fireworks.models."llama-4-maverick"]
api_id = "accounts/fireworks/models/llama4-maverick-instruct-basic"
display_name = "Llama 4 Maverick"
family = "llama-4"
[llm.providers.fireworks.models."llama-4-maverick".limits]
context_window = 1000000
[llm.providers.fireworks.models."llama-4-maverick".features]
tools = true
vision = false
reasoning = false
api_model = "accounts/fireworks/models/llama4-maverick-instruct-basic"
limits = { context_tokens = 1000000, max_output_tokens = 16384 }
capabilities = { text = true, tools = true }
```
Note that Fireworks' `GET /v1/models` endpoint only returns a featured subset of serverless models; a model absent from that list may still be servable. Verify custom additions with `fabro model test`.
@ -117,7 +110,7 @@ Fireworks caches prompt prefixes automatically — no cache breakpoints or reque
## Costs
Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports `cost_source = "estimated"` from catalog rates. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog.
Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/serverless/pricing). Fireworks does not return in-band billing, so Fabro reports the cost source as `catalog`. `kimi-k3-fast` uses the published 50% Fast tier premium. Other Fast model variants and the Priority service tier are not included in the built-in catalog.
## Troubleshooting
@ -127,7 +120,7 @@ Catalog prices mirror [Fireworks serverless pricing](https://docs.fireworks.ai/s
**402 / insufficient credits** — Serverless inference requires prepaid credit; check your balance in the [Fireworks billing dashboard](https://app.fireworks.ai/settings/billing).
**Unknown model** — Confirm the model's `api_id` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model <fabro-model-id>`. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive.
**Unknown model** — Confirm the model's `api_model` matches a Fireworks account-scoped model or router path exactly (`accounts/fireworks/models/...` or `accounts/fireworks/routers/...`), then run `fabro model test --model <fabro-model-id>`. Remember that `GET /v1/models` only lists a featured subset, so absence from that list is not conclusive.
## Further reading

View file

@ -303,7 +303,7 @@ Behavior notes:
Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work.
Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
Installation Access Tokens are short-lived. Fabro's own pushes present a fresh token on each call. Git commands the agent runs inside the sandbox read the token through a credential store the sandbox driver configures for the checkout; the token never appears in the repository's remote URL or configuration. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro re-mints the token and rewrites that store before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there.
@ -313,6 +313,10 @@ The permissions table follows the standard layer-merge order (workflow > project
The upper bound on what Fabro will mint is whatever permissions the GitHub App installation has been granted. Fabro does **not** impose a separate server-side cap on the run-level `permissions` map: any value the App has been granted can be requested by run config. Operators must not run untrusted workflow, project, or user TOML against a broadly-scoped GitHub App installation. Preflight prints the resolved permission set so reviewers can see what each run will request.
### Commit attribution
Every commit a run creates is authored and committed by the run's GitHub credential identity. In App mode that is the App's bot account, `<slug>[bot] <id+slug[bot]@users.noreply.github.com>`, so GitHub attributes checkpoint commits and workflow-created commits to the App. In token mode it is the token's user with their GitHub noreply address. Fabro resolves the identity once per run and passes it to every prepare step, command stage, agent tool, and ACP agent as `GIT_AUTHOR_*` / `GIT_COMMITTER_*` variables, so a workflow that clones or initializes another repository commits as the same identity without configuring Git itself. `[run.git.author]` overrides either field; see [server configuration](/administration/server-configuration#rungitauthor-section).
### Checkpoint pushing
After each workflow stage, Fabro [checkpoints](/execution/checkpoints) by pushing the run branch to origin. Before a successful run becomes terminal, the publish stage pushes the final commit again and treats failure as a run failure. Inside remote sandboxes, the git remote URL is configured with the Installation Access Token for authenticated pushing.

View file

@ -21,26 +21,18 @@ Add the provider override and one or more model entries to `~/.fabro/settings.to
_version = 1
[llm.providers.litellm]
enabled = true
base_url = "http://localhost:4000/v1"
default_model = "litellm-gpt-5"
enabled = true
[llm.providers.litellm.models."litellm-gpt-5"]
api_id = "gpt-5"
display_name = "LiteLLM GPT-5"
family = "litellm"
default = true
[llm.providers.litellm.models."litellm-gpt-5".limits]
context_window = 128000
max_output = 8192
[llm.providers.litellm.models."litellm-gpt-5".features]
tools = true
vision = false
reasoning = false
api_model = "gpt-5"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
```
`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy.
`api_model` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy.
## Configure credentials
@ -94,22 +86,14 @@ Declare each LiteLLM-routed model explicitly so Fabro knows its provider, contex
```toml title="settings.toml"
[llm.providers.litellm.models."litellm-fast"]
api_id = "fast-model"
display_name = "LiteLLM Fast"
family = "litellm"
aliases = ["fast"]
[llm.providers.litellm.models."litellm-fast".limits]
context_window = 64000
max_output = 4096
[llm.providers.litellm.models."litellm-fast".features]
tools = true
vision = false
reasoning = false
api_model = "fast-model"
limits = { context_tokens = 64000, max_output_tokens = 4096 }
capabilities = { text = true, tools = true }
```
Only one model for a provider should set `default = true`. You may also mark one small/cheap utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted.
The provider's `default_model` names its default. You may also mark one small utility model with `small_default = true`; Fabro uses it for metadata tasks such as generated run titles and falls back to the provider default when it is omitted.
## Troubleshooting
@ -117,7 +101,7 @@ Only one model for a provider should set `default = true`. You may also mark one
**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 <fabro-model-id>`.
**Unknown model from LiteLLM** — Check that the model's `api_model` matches the model name configured in LiteLLM, then run `fabro model test --model <fabro-model-id>`.
## Further reading

View file

@ -45,8 +45,8 @@ Add the provider override to the settings file used by the Fabro server. Include
_version = 1
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
enabled = true
```
The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint.
@ -113,45 +113,25 @@ digraph Example {
## Direct SDK environment credentials
The built-in Modal provider reads its two headers from the Fabro vault. `EnvCredentialSource` does not configure Modal automatically because Modal uses two headers instead of one API-key reference.
The built-in Modal provider authenticates with two headers, `Modal-Key` and `Modal-Secret`, read from the secrets `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. Direct SDK use reads the same two names from the process environment.
For direct SDK use, enable Modal and set its endpoint URL in the catalog:
For direct SDK use, enable Modal and set its endpoint URL in the `[llm]` overlay, then build the client with `fabro_llm::build_client` over a `VaultCredentialSource` whose vault holds both secrets. The catalog you pass to the client must be built from the same settings file with `fabro_llm::build_catalog`.
```toml title="settings.toml"
[llm.providers.modal]
enabled = true
base_url = "https://your-endpoint.modal.run/v1"
```
Then read both environment variables explicitly and create a typed credential after constructing `catalog` from those settings:
```rust
use fabro_auth::ApiCredential;
use fabro_llm::client::Client;
use std::collections::HashMap;
let credential = ApiCredential::with_extra_headers(
"modal",
HashMap::from([
("Modal-Key".to_string(), std::env::var("MODAL_TOKEN_ID")?),
(
"Modal-Secret".to_string(),
std::env::var("MODAL_TOKEN_SECRET")?,
),
]),
);
let client = Client::from_credentials(vec![credential], catalog).await?;
enabled = true
```
## Costs
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`.
Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports the cost source as `catalog`.
Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill.
## Troubleshooting
**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`.
**Modal requests fail with 404** — Add the Modal endpoint URL as `base_url` under `[llm.providers.modal]`. Include `/v1`.
**Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient.

View file

@ -44,7 +44,7 @@ export OPENROUTER_API_KEY=sk-or-v1-...
## Included models
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_id` values:
The built-in catalog gives OpenRouter offerings the same human-facing model slugs used by direct providers. Vendor-namespaced OpenRouter IDs remain opaque `api_model` values:
| Fabro model slug | OpenRouter API ID / notes |
| --- | --- |
@ -60,21 +60,14 @@ The built-in catalog gives OpenRouter offerings the same human-facing model slug
| `minimax-m2.7`, `mimo-v2.5-pro` | Vendor-prefixed API IDs |
| `nemotron-3-super-120b-a12b`, `devstral-2512` | Vendor-prefixed API IDs |
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_id`:
Any other OpenRouter model can be added under the provider. Choose a stable Fabro model slug as the table key and put OpenRouter's exact vendor/model string in `api_model`:
```toml title="settings.toml"
[llm.providers.openrouter.models."llama-4-maverick"]
api_id = "meta-llama/llama-4-maverick"
display_name = "Llama 4 Maverick"
family = "llama-4"
[llm.providers.openrouter.models."llama-4-maverick".limits]
context_window = 1000000
[llm.providers.openrouter.models."llama-4-maverick".features]
tools = true
vision = false
reasoning = false
api_model = "meta-llama/llama-4-maverick"
limits = { context_tokens = 1000000, max_output_tokens = 16384 }
capabilities = { text = true, tools = true }
```
## Use OpenRouter models
@ -112,7 +105,7 @@ digraph Example {
## Cost telemetry
Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as `cost_usd` with `cost_source = "authoritative"` on completion responses. Other providers populate the same fields from catalog price estimates with `cost_source = "estimated"`.
Every OpenRouter response includes an inline `usage.cost` with authoritative USD billing. Fabro surfaces it as the response `cost` with source `provider`. Other providers populate the same field from catalog price estimates with source `catalog`.
The catalog prices on OpenRouter model rows are best-effort estimates used only before the authoritative figure arrives (for example, mid-stream rollups).
@ -134,10 +127,10 @@ OpenRouter's [provider routing preferences](https://openrouter.ai/docs/guides/ro
## Attribution headers
Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: <run-id>` for request grouping; an explicit provider `extra_headers` value for that header takes precedence. To opt in to attribution:
Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `X-Title`) by default, so self-hosted installations stay anonymous on OpenRouter's public app leaderboard. Workflow runs do send `x-session-id: <run-id>` for request grouping; an explicit provider `default_headers` value for that header takes precedence. To opt in to attribution:
```toml title="settings.toml"
[llm.providers.openrouter.extra_headers]
[llm.providers.openrouter.default_headers]
"HTTP-Referer" = "https://your-site.example"
"X-Title" = "Your App"
```
@ -150,7 +143,7 @@ Fabro does not send OpenRouter's optional attribution headers (`HTTP-Referer`, `
**402 / insufficient credits** — Paid OpenRouter models require prepaid credit; check your balance at [openrouter.ai/credits](https://openrouter.ai/credits).
**Unknown model** — Confirm the model's `api_id` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model <fabro-model-id>`.
**Unknown model** — Confirm the model's `api_model` matches an OpenRouter slug exactly (including the vendor prefix), then run `fabro model test --model <fabro-model-id>`.
## Further reading

View file

@ -70,7 +70,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro attach` | Attach to a running or finished workflow run |
| `fabro auth` | Manage CLI authentication state |
| `fabro completion` | Generate shell completions |
| `fabro create` | Register a local workflow version and create a submitted run |
| `fabro create` | Register a workflow version and create a submitted run |
| `fabro deny` | Deny pending workflow runs |
| `fabro discord` | Open the Discord community in the browser |
| `fabro docs` | Open the docs website in the browser |
@ -92,7 +92,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro resume` | Resume an interrupted workflow run |
| `fabro rewind` | Rewind a workflow run to an earlier checkpoint |
| `fabro rm` | Remove one or more workflow runs |
| `fabro run` | Register a local workflow version, create a run, and start it |
| `fabro run` | Register a workflow version, create a run, and start it |
| `fabro sandbox` | Sandbox operations (cp, ssh, preview) |
| `fabro secret` | Manage server-owned secrets |
| `fabro server` | Server operations |
@ -332,7 +332,7 @@ fabro completion [OPTIONS] <SHELL>
### `fabro create`
Register a local workflow version and create a submitted run
Register a workflow version and create a submitted run
```bash
fabro create [OPTIONS] <WORKFLOW>
@ -342,7 +342,7 @@ fabro create [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
#### Options
@ -350,7 +350,7 @@ fabro create [OPTIONS] <WORKFLOW>
| --- | --- |
| `--auto-approve` | Auto-approve all human gates |
| `-d, --detach` | Run the workflow in the background and print the run ID |
| `--dry-run` | Execute with simulated LLM backend |
| `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
@ -360,8 +360,14 @@ fabro create [OPTIONS] <WORKFLOW>
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
### `fabro deny`
@ -698,7 +704,7 @@ fabro model test [OPTIONS]
| `-j, --jobs <jobs>` | Number of model tests to run concurrently in bulk mode<br />Default: `4` |
| `-m, --model <model>` | Test a specific model |
| `-p, --provider <provider>` | Filter by provider |
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level<br />Values: `low`, `medium`, `high`, `xhigh`, `max` |
| `--reasoning-effort <reasoning_effort>` | Request a reasoning-effort level (minimal, low, medium, high, xhigh, max) |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--tools` | Run a multi-turn tool-use test |
@ -1061,7 +1067,7 @@ fabro rm [OPTIONS] <RUNS>...
### `fabro run`
Register a local workflow version, create a run, and start it
Register a workflow version, create a run, and start it
```bash
fabro run [OPTIONS] <WORKFLOW>
@ -1071,7 +1077,7 @@ fabro run [OPTIONS] <WORKFLOW>
| Name | Description |
| --- | --- |
| `WORKFLOW` | Local workflow name, checkout path, .fabro file, or workflow TOML |
| `WORKFLOW` | Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW |
#### Options
@ -1079,7 +1085,7 @@ fabro run [OPTIONS] <WORKFLOW>
| --- | --- |
| `--auto-approve` | Auto-approve all human gates |
| `-d, --detach` | Run the workflow in the background and print the run ID |
| `--dry-run` | Execute with simulated LLM backend |
| `--dry-run` | Simulate execution; workflow source may still be fetched and uploaded |
| `--environment <environment>` | Named environment for agent tools |
| `--goal <goal>` | Override the workflow goal (available as {{ goal }} in prompts) |
| `--goal-file <goal_file>` | Read a per-run goal value from a local file |
@ -1089,8 +1095,14 @@ fabro run [OPTIONS] <WORKFLOW>
| `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) |
| `--provider <provider>` | Override default LLM provider |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--target-branch <branch>` | Target working branch (default: remote default branch), pinned to its observed commit |
| `--target-from <path>` | Observe this target directory instead of cwd; Folder targets require server filesystem access |
| `--target-repo <owner/repo>` | Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials |
| `--target <owner/repo[@branch]>` | Target GitHub repository and optional working branch |
| `-I, --input <key=value>` | Override a workflow input value (repeatable, format: KEY=VALUE) |
| `-v, --verbose` | Enable verbose output |
| `--workflow-ref <ref>` | Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names |
| `--workflow-repo <owner/repo>` | Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials |
### `fabro sandbox`

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.<id>]`, `[llm.providers.<id>.models.<id>]`, and the agent harness under `metadata.agent` |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.
@ -89,36 +89,28 @@ level = "info"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
default = true
aliases = ["team-code"]
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000 }
```
All fields are optional. Include only the sections and keys you want to override. A single file can still include both CLI and server sections when you run both processes on one machine, but explicit remote targets do not read remote server state from the local machine.
@ -147,146 +139,113 @@ url = "https://fabro.example.com/api/v1"
| `url` | string | None | Required for `type = "http"`; the API base URL. |
| `path` | string | None | Required for `type = "unix"`; the absolute Unix socket path. |
## `[llm.providers.<id>]`
## `[llm]`
Define or override an LLM provider. Provider IDs are strings, so custom
providers can be added when they use an adapter Fabro already supports.
The `[llm]` table is a [lithos-llm](https://docs.rs/lithos-llm) catalog
overlay. Fabro builds its model catalog from two layers: the lithos built-in
providers and models, and this table. Later layers win; tables merge key by
key and every other value replaces. Fabro does not interpret the table itself.
lithos validates it when the catalog is built, and rejects unknown provider or
model fields.
Several built-in providers ship with `enabled = false`. Turn one on by setting
`enabled = true` on its provider table.
```toml title="settings.toml"
[llm.providers.proxy]
display_name = "Acme Gateway"
adapter = "openai_compatible"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://llm-gateway.example.com/v1"
auth = { type = "bearer" }
priority = 50
enabled = true
aliases = ["gateway"]
default_model = "team-code-large"
[llm.providers.proxy.auth]
credentials = ["env:ACME_GATEWAY_API_KEY", "vault:ACME_GATEWAY_API_KEY"]
[llm.providers.proxy.extra_headers]
x-portkey-api-key = "{{ secrets.portkey_api_key }}"
[llm.providers.proxy.default_headers]
x-portkey-api-key = "{{ secrets.PORTKEY_API_KEY }}"
x-portkey-config = "@bedrock-prod"
x-team-secret = "{{ secrets.gateway_team_secret }}"
[llm.providers.proxy.metadata.agent]
profile = "anthropic"
[llm.providers.proxy.models."team-code-large"]
display_name = "Team Code Large"
aliases = ["team-code"]
api_model = "provider-wire-model-name"
limits = { context_tokens = 200000, max_output_tokens = 32000 }
capabilities = { text = true, tools = true, reasoning = true, caching = true, reasoning_effort = { low = true, medium = true, high = true } }
protocol_options = { reasoning_effort_levels = true }
pricing = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 8000000, cached_input_usd_micros_per_million = 300000 }
family = "team-code"
small_default = true
estimated_output_tps = 80
```
A provider's API key is the secret lithos names for it: `OPENAI_API_KEY` for
`openai`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` for `modal`, and
`<PROVIDER>_API_KEY` (upper case, `-` and `.` as `_`) for a provider you
define, so the gateway above reads `PROXY_API_KEY`. Store it in the server
vault with `fabro secret set`, or export it for `fabro exec` and SDK use.
## `[llm.providers.<id>]`
Define or override an LLM provider. The keys are the lithos provider record.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `display_name` | string | provider ID | Human-readable provider name. |
| `adapter` | string | built-in value | Adapter registry key, such as `"anthropic"`, `"openai"`, `"gemini"`, or `"openai_compatible"`. Required for new providers. |
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | derived from `adapter` | Agent profile used for project memory, CLI/ACP command selection, and native session routing. Override only when a provider needs profile behavior different from its adapter. |
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values are literal text or `{{ secrets.NAME }}` interpolation strings. Put credentials in a secret and reference them with a token, not a bare literal. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection; ties use canonical provider ID. |
| `enabled` | boolean | `true` | Set `false` to disable a provider after lower-precedence layers define it. |
| `display_name` | string | required for new providers | Human-readable provider name. |
| `adapter` | string | required for new providers | lithos adapter id: `anthropic`, `openai`, `gemini`, `openai-compatible`, or `bedrock`. |
| `codec` | string | required for new providers | Wire codec: `anthropic-messages`, `openai-responses`, `openai-chat`, `gemini-generate`, or `bedrock-converse`. |
| `base_url` | string | required for new providers | Provider API base URL. The `openai-compatible` adapter appends `/v1/chat/completions` unless the URL already ends in a version segment. |
| `auth` | table | required for new providers | Auth scheme: `{ type = "bearer" }`, `{ type = "header", name = "x-api-key" }`, `{ type = "headers" }`, `{ type = "none" }`, or `{ type = "aws" }`. |
| `enabled` | boolean | `true` | Set `false` to hide a provider from Fabro. `bedrock`, `bedrock-openai`, `fireworks`, `litellm`, `modal`, `ollama`, and `openrouter` ship disabled. |
| `priority` | integer | `0` | Higher-priority ready providers win unqualified model and default selection. |
| `aliases` | array<string> | `[]` | Additional provider names accepted by model routing and fallback config. |
| `default_model` | string | None | The provider's default model id. |
| `allow_passthrough` | boolean | `false` | Whether `provider/model` selectors may name models the catalog does not list. |
| `api_key_url` | string | None | Where an operator obtains an API key. Shown by `fabro provider login` and the install flow. |
| `stands_in_for` | string | None | Another provider this one answers for when that provider has no credentials. `openai-codex` stands in for `openai`. |
| `default_headers` | table | `{}` | Headers attached to every request. A value may be literal text or a `{{ secrets.NAME }}` token resolved against the vault. |
## `[llm.providers.<provider>.models.<model-slug>]`
## `[llm.providers.<id>.metadata.agent]`
Which coding harness the provider's models expect. Pebble reads the same
namespace. Every key is optional; a model row overrides the provider.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `profile` | `"anthropic"` \| `"claude-5"` \| `"openai"` \| `"gemini"` \| `"kimi"` \| `"gpt56"` \| `"gpt6"` | derived from `adapter` | Agent profile for models on this provider. |
| `reasoning_by_default` | boolean | reasoning models with effort levels: `true` | Whether requests reason when no `reasoning_effort` is supplied. |
## `[llm.providers.<provider>.models.<model-id>]`
Define or override one provider's offering of a model. The table key is the
canonical model slug Fabro users reference. An offering's identity is the
pair `(provider, model slug)`, so different providers may use the same slug
and aliases. `api_id` is the opaque model string sent to this provider's API
and defaults to the exact model slug.
```toml title="settings.toml"
[llm.providers.proxy.models."team-code-large"]
api_id = "provider-wire-model-name"
agent_profile = "anthropic"
display_name = "Team Code Large"
family = "team-code"
default = true
probe = true
enabled = true
aliases = ["team-code"]
estimated_output_tps = 80
[llm.providers.proxy.models."team-code-large".limits]
context_window = 200000
max_output = 32000
[llm.providers.proxy.models."team-code-large".features]
tools = true
vision = false
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
[llm.providers.proxy.models."team-code-large".controls]
reasoning_effort = ["low", "medium", "high"]
speed = ["fast"]
[llm.providers.proxy.models."team-code-large".costs]
input_cost_per_mtok = 1.50
output_cost_per_mtok = 8.00
cache_input_cost_per_mtok = 0.30
[llm.providers.proxy.models."team-code-large".costs.speed.fast]
input_cost_per_mtok = 3.00
output_cost_per_mtok = 16.00
cache_input_cost_per_mtok = 0.60
```
model id Fabro users reference. An offering's identity is the pair
`(provider, model id)`, so different providers may use the same id and
aliases. `api_model` is the string sent to the provider and defaults to the id.
| Key | Type / values | Default | Description |
|---|---|---|---|
| `api_id` | string | model slug | Opaque identifier sent to this provider's API. An explicitly empty value is invalid. |
| `agent_profile` | `"anthropic"` \| `"openai"` \| `"gemini"` | provider profile | Agent profile override for this model. Model overrides take precedence over provider overrides. |
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | provider policy | Billing algorithm override for this model — for models whose billing family differs from their provider's (e.g. Claude served through OpenRouter bills Anthropic-style cache reads/writes). |
| `display_name` | string | model ID | Human-readable model name. |
| `family` | string | model ID | Family label used for catalog display and matching. |
| `training` | string | None | Training data cutoff label. |
| `knowledge_cutoff` | string or TOML date | None | Public knowledge cutoff label; TOML dates normalize to `YYYY-MM-DD`. |
| `default` | boolean | `false` | Whether this is the provider default model. |
| `probe` | boolean | `false` | Whether this model should be preferred for provider connectivity probes. Set `false` in a higher-precedence layer to clear an inherited probe marker. |
| `enabled` | boolean | `true` | Set `false` to disable a model after lower-precedence layers define it. |
| `aliases` | array<string> | `[]` | Additional model selectors accepted by routing and fallback config. Aliases may repeat across providers, but one selector cannot identify two models within the same provider. |
| `estimated_output_tps` | number | None | Estimated output tokens per second for catalog display and planning. |
| `display_name` | string | required for new models | Human-readable model name. |
| `aliases` | array<string> | `[]` | Additional selectors. Aliases may repeat across providers. |
| `api_model` | string | model id | Wire model identifier sent to this provider. |
| `limits` | `{ context_tokens, max_output_tokens }` | None | Token limits. |
| `capabilities` | table | unknown | Per-capability `true`, `false`, or `"unknown"`: `text`, `images`, `audio`, `documents`, `tools`, `reasoning`, `caching`, `cache_routing`, `sampling`, plus `tool_choice = { required, named }`, `response_format = { json_object, json_schema }`, `reasoning_effort = { minimal, low, medium, high, xhigh, max }`, and `speed = { fast, balanced, economical }`. |
| `protocol_options` | table | `{}` | Encoding flags: `reasoning_effort_levels`, `cache_breakpoints`, `system_turns`. |
| `pricing` | table | None | USD micros per million tokens: `input_usd_micros_per_million`, `output_usd_micros_per_million`, `cached_input_usd_micros_per_million`, `cache_write_usd_micros_per_million`, plus optional `long_context` and `speed` tiers. |
| `family` | string | model id | Family label for display and grouping. |
| `training_cutoff` | string | None | Training data cutoff, as the provider states it. |
| `knowledge_cutoff` | string | None | Public knowledge cutoff label, as a person would write it. |
| `estimated_output_tps` | number | None | Estimated output tokens per second. |
| `small_default` | boolean | `false` | Preferred for small utility calls such as generated run titles. |
| `probe` | boolean | `false` | Preferred for provider connectivity probes. |
## `[llm.providers.<provider>.models.<model-slug>.limits]`
## `[llm.providers.<provider>.models.<model-id>.metadata.agent]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `context_window` | integer | None | Maximum context window size in tokens. |
| `max_output` | integer | None | Maximum output tokens, if known. |
## `[llm.providers.<provider>.models.<model-slug>.features]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `tools` | boolean | `false` | Whether the model supports tool calls. |
| `vision` | boolean | `false` | Whether the model accepts image inputs. |
| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. |
| `reasoning_by_default` | boolean | effort-capable models: `true`; other models: `false` | Whether requests reason when no `reasoning_effort` is supplied. Set this explicitly for always-reasoning routes that do not expose an effort control, or for effort-capable routes whose provider defaults reasoning off. |
| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
## `[llm.providers.<provider>.models.<model-slug>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.providers.<provider>.models.<model-slug>.costs]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `input_cost_per_mtok` | number | None | Input cost in USD per million tokens. |
| `output_cost_per_mtok` | number | None | Output cost in USD per million tokens. |
| `cache_input_cost_per_mtok` | number | None | Cached input/read cost in USD per million tokens. |
## `[llm.providers.<provider>.models.<model-slug>.costs.speed.<speed>]`
Per-speed cost overrides use the same keys as
`[llm.providers.<provider>.models.<model-slug>.costs]`. Each `<speed>` key
must be declared in
`[llm.providers.<provider>.models.<model-slug>.controls].speed`.
The `standard` speed is implicit and always uses the base cost table.
The same keys as the provider-level `metadata.agent` table, applied to one
model. `profile` here is how a Kimi or GPT-5.6 model keeps its own harness on
a gateway whose other models use the provider default.
## `[cli.updates]`
@ -396,8 +355,8 @@ email = "fabro-bot@company.com"
| Key | Type / values | Default | Description |
|---|---|---|---|
| `email` | string | "fabro@local" | Git author email for checkpoint commits. |
| `name` | string | "fabro" | Git author name for checkpoint commits. |
| `email` | string | resolved from the run's GitHub credential | Git author and committer email for every commit the run creates. When<br />unset, the run uses the credential's noreply address, else<br />`noreply@fabro.sh`. |
| `name` | string | resolved from the run's GitHub credential | Git author and committer name for every commit the run creates. When<br />unset, the run uses its GitHub App bot or PAT user, else `Fabro`. |
## `[run.pull_request]`

View file

@ -22,10 +22,12 @@ fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-environment = { path = "../../components/fabro-environment" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-model = { path = "../../foundation/fabro-model", features = ["clap"] }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
pebble-agent.workspace = true
pebble-coding-agent.workspace = true
pebble-cli-core.workspace = true
sandbox-driver.workspace = true
fabro-dump = { path = "../../components/fabro-dump" }
fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-install = { path = "../../components/fabro-install" }
@ -34,7 +36,7 @@ fabro-mcp = { path = "../../components/fabro-mcp" }
fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
fabro-checkpoint = { path = "../../components/fabro-checkpoint" }
fabro-graphviz = { path = "../../components/fabro-graphviz" }
fabro-validate = { path = "../../components/fabro-validate" }
@ -46,6 +48,7 @@ fabro-telemetry = { path = "../../foundation/fabro-telemetry" }
fabro-store = { path = "../../components/fabro-store" }
fabro-vault = { path = "../../foundation/fabro-vault" }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] }
lithos-llm = { workspace = true, features = ["runtime"] }
fabro-redact.workspace = true
fabro-util = { path = "../../foundation/fabro-util" }
fabro-http.workspace = true
@ -57,7 +60,6 @@ clap_complete.workspace = true
cli-table.workspace = true
console.workspace = true
indicatif.workspace = true
daytona-sdk.workspace = true
anyhow.workspace = true
miette.workspace = true
dotenvy.workspace = true
@ -94,17 +96,16 @@ serde_yaml = "0.9"
tempfile = "3"
sha2.workspace = true
shlex = "1"
walkdir.workspace = true
object_store.workspace = true
bytes.workspace = true
tokio-util.workspace = true
libc = "0.2"
nix = { version = "0.30", features = ["fs"] }
nix = { version = "0.30", features = ["fs", "signal"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "0.9", optional = true }
# Vendor openssl only for musl targets. daytona-sdk transitively pulls
# Vendor openssl only for musl targets. Transitive dependencies pull
# native-tls via reqwest, which needs libssl. On glibc runners the system
# libssl is used; on musl runners we compile openssl from source.
[target.'cfg(target_env = "musl")'.dependencies]
@ -116,8 +117,11 @@ chrono = { workspace = true }
[dev-dependencies]
assert_cmd = "2"
walkdir.workspace = true
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
fabro-mcp = { path = "../../components/fabro-mcp", features = ["test-support"] }
fabro-build-support = { path = "../../foundation/build-support" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] }

View file

@ -3,14 +3,15 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_model::ReasoningEffort;
use fabro_server::serve::DEFAULT_TCP_PORT;
use fabro_static::EnvVars;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::run::MergeStrategy;
use fabro_types::{GitHubRepositorySlug, PermissionLevel};
use fabro_util::printer::Printer;
use lithos_llm::catalog::ProviderId;
use lithos_llm::types::ReasoningEffort;
pub(crate) const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
@ -232,11 +233,40 @@ pub(crate) struct RunArgs {
#[command(flatten)]
pub(crate) inputs: InputOverrideArgs,
/// Local workflow name, checkout path, .fabro file, or workflow TOML
/// Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
/// Execute with simulated LLM backend
/// Acquire workflow source locally from a GitHub OWNER/REPO using native
/// Git credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) workflow_repo: Option<GitHubRepositorySlug>,
/// Workflow branch, tag, HEAD (default), or full commit SHA; qualify
/// ambiguous names
#[arg(long, requires = "workflow_repo", value_name = "REF")]
pub(crate) workflow_ref: Option<String>,
/// Observe this target directory instead of cwd; Folder targets require
/// server filesystem access
#[arg(long, conflicts_with_all = ["target_repo", "target_repo_selector"], value_name = "PATH")]
pub(crate) target_from: Option<PathBuf>,
/// Target GitHub repository and optional working branch
#[arg(long = "target", conflicts_with_all = ["target_repo", "target_branch"], value_name = "OWNER/REPO[@BRANCH]")]
pub(crate) target_repo_selector: Option<String>,
/// Target GitHub OWNER/REPO; the execution sandbox still needs its own
/// clone credentials
#[arg(long, value_name = "OWNER/REPO")]
pub(crate) target_repo: Option<GitHubRepositorySlug>,
/// Target working branch (default: remote default branch), pinned to its
/// observed commit
#[arg(long, requires = "target_repo", value_name = "BRANCH")]
pub(crate) target_branch: Option<String>,
/// Simulate execution; workflow source may still be fetched and uploaded
#[arg(long)]
pub(crate) dry_run: bool,
@ -836,7 +866,7 @@ pub(crate) struct ProviderLoginArgs {
/// LLM provider to authenticate with
#[arg(long)]
pub(crate) provider: fabro_model::ProviderId,
pub(crate) provider: ProviderId,
/// Read an API key from stdin instead of prompting
#[arg(long)]
@ -1101,8 +1131,9 @@ pub(crate) struct ModelTestArgs {
#[arg(long, alias = "deep")]
pub(crate) tools: bool,
/// Request a reasoning-effort level
#[arg(long, value_enum)]
/// Request a reasoning-effort level (minimal, low, medium, high, xhigh,
/// max)
#[arg(long, value_parser = parse_reasoning_effort_arg)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
@ -1115,6 +1146,111 @@ pub(crate) struct ExecArgs {
pub(crate) agent: AgentArgs,
}
/// Agent tool permission level, as the `--permissions` flag spells it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum PermissionsArg {
ReadOnly,
ReadWrite,
Full,
}
impl From<PermissionsArg> for PermissionLevel {
fn from(value: PermissionsArg) -> Self {
match value {
PermissionsArg::ReadOnly => Self::ReadOnly,
PermissionsArg::ReadWrite => Self::ReadWrite,
PermissionsArg::Full => Self::Full,
}
}
}
/// Output format for `fabro exec`: human-readable assistant output on stdout
/// with progress on stderr, or one coding agent event per line as JSON.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum ExecOutputFormat {
Text,
Json,
}
/// Arguments for the agentic `fabro exec` session.
#[derive(Args)]
pub(crate) struct AgentArgs {
/// Task prompt
pub(crate) prompt: String,
/// LLM provider (built-in or configured provider ID)
#[arg(long)]
pub(crate) provider: Option<String>,
/// Model name (defaults per provider)
#[arg(long)]
pub(crate) model: Option<String>,
/// Permission level for tool execution
#[arg(long, value_enum)]
pub(crate) permissions: Option<PermissionsArg>,
/// Skip interactive prompts; deny tools outside permission level
#[arg(long)]
pub(crate) auto_approve: bool,
/// Print LLM request/response debug info to stderr
#[arg(long)]
pub(crate) debug: bool,
/// Print full LLM request/response JSON to stderr
#[arg(long)]
pub(crate) verbose: bool,
/// Directory containing skill files (overrides default discovery)
#[arg(long)]
pub(crate) skills_dir: Option<String>,
/// Output format (text for human-readable, json for NDJSON event stream)
#[arg(long, value_enum)]
pub(crate) output_format: Option<ExecOutputFormat>,
}
impl AgentArgs {
/// Fill `None` fields from settings.toml values, then hardcoded defaults.
pub(crate) fn apply_cli_defaults(
&mut self,
provider: Option<&str>,
model: Option<&str>,
permissions: Option<PermissionLevel>,
output_format: Option<ExecOutputFormat>,
) {
self.provider = self
.provider
.take()
.or_else(|| provider.map(String::from))
.or_else(|| Some("anthropic".to_string()));
self.model = self.model.take().or_else(|| model.map(String::from));
self.permissions = self
.permissions
.or_else(|| permissions.map(permissions_arg))
.or(Some(PermissionsArg::ReadWrite));
self.output_format = self
.output_format
.or(output_format)
.or(Some(ExecOutputFormat::Text));
}
/// The permission level after defaults are applied.
pub(crate) fn permission_level(&self) -> PermissionLevel {
self.permissions
.map_or(PermissionLevel::ReadWrite, PermissionLevel::from)
}
}
fn permissions_arg(level: PermissionLevel) -> PermissionsArg {
match level {
PermissionLevel::ReadOnly => PermissionsArg::ReadOnly,
PermissionLevel::ReadWrite => PermissionsArg::ReadWrite,
PermissionLevel::Full => PermissionsArg::Full,
}
}
#[derive(Args)]
pub(crate) struct UpgradeArgs {
/// Target version (e.g. "0.5.0", "v0.5.0", or "v0.177.0-alpha.1")
@ -1136,10 +1272,12 @@ pub(crate) struct UpgradeArgs {
#[derive(Subcommand)]
pub(crate) enum RunCommands {
/// Register a local workflow version, create a run, and start it
Run(RunArgs),
/// Register a local workflow version and create a submitted run
Create(RunArgs),
// Boxed so `RunArgs` does not dominate the size of the flattened
// `Commands` enum (clippy `large_enum_variant`).
/// Register a workflow version, create a run, and start it
Run(Box<RunArgs>),
/// Register a workflow version and create a submitted run
Create(Box<RunArgs>),
/// Start a created workflow run on the server
Start(StartArgs),
/// Attach to a running or finished workflow run
@ -1727,7 +1865,7 @@ pub(crate) struct InstallGithubArgs {
#[derive(Args, Debug, Clone, Default)]
pub(crate) struct InstallNonInteractiveArgs {
#[arg(long, hide = true)]
pub(crate) llm_provider: Option<fabro_model::ProviderId>,
pub(crate) llm_provider: Option<ProviderId>,
#[arg(long, hide = true)]
pub(crate) llm_api_key_stdin: bool,
@ -1854,3 +1992,53 @@ pub(crate) struct CompletionArgs {
/// Shell to generate completions for
pub shell: clap_complete::Shell,
}
fn parse_reasoning_effort_arg(value: &str) -> Result<ReasoningEffort, String> {
value.parse().map_err(|_| {
format!(
"unknown reasoning effort '{value}'; expected one of: {}",
ReasoningEffort::ALL
.into_iter()
.map(ReasoningEffort::as_str)
.collect::<Vec<_>>()
.join(", ")
)
})
}
#[cfg(test)]
mod run_selection_grammar_tests {
use crate::commands::run::test_support::parse_run_args;
#[test]
fn run_selection_accepts_independent_resource_flags() {
for flags in [
vec![
"review",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"refs/tags/v1",
"--target-repo",
"acme/app",
"--target-branch",
"release/topic",
],
vec!["./review.toml", "--target-from", "../app"],
] {
assert!(parse_run_args(flags).is_ok());
}
}
#[test]
fn run_selection_requires_modifier_owners_and_exclusive_targets() {
for flags in [
vec!["review", "--workflow-ref", "v1"],
vec!["review", "--target-branch", "release"],
vec!["review", "--target-from", ".", "--target-repo", "acme/app"],
vec!["--workflow-repo", "acme/workflows"],
] {
assert!(parse_run_args(flags).is_err());
}
}
}

View file

@ -2,9 +2,10 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use anyhow::{Context as _, Result, bail};
use fabro_auth::{CredentialSource, SqlVaultCredentialSource};
use fabro_config::{CliLayer, Storage, load_llm_catalog_settings};
use fabro_model::Catalog;
use fabro_auth::SqlVaultCredentialSource;
use fabro_config::{CliLayer, Storage, load_llm_overlay};
use fabro_llm::credentials::CredentialProvider;
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::UserSettings;
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
@ -44,7 +45,7 @@ pub(crate) struct CommandContext {
run_settings_key_presence: RunSettingsKeyPresence,
server_mode: ServerMode,
server: OnceCell<Arc<Client>>,
llm_source: OnceCell<Arc<dyn CredentialSource>>,
llm_source: OnceCell<Arc<dyn CredentialProvider>>,
catalog: OnceLock<Arc<Catalog>>,
}
@ -163,7 +164,7 @@ impl CommandContext {
Ok(Arc::clone(client))
}
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialSource>> {
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialProvider>> {
let storage_dir = self.storage_dir.clone();
let source = self
@ -173,9 +174,9 @@ impl CommandContext {
let store = SecretStore::open(storage.sqlite_path(), storage.secrets_path())
.await
.context("opening the Fabro secret store")?;
let source: Arc<dyn CredentialSource> =
let source: Arc<dyn CredentialProvider> =
Arc::new(SqlVaultCredentialSource::new(Arc::new(store)));
Ok::<Arc<dyn CredentialSource>, anyhow::Error>(source)
Ok::<Arc<dyn CredentialProvider>, anyhow::Error>(source)
})
.await?;
@ -187,12 +188,7 @@ impl CommandContext {
return Ok(Arc::clone(catalog));
}
let llm_catalog_settings =
load_llm_catalog_settings(None).context("loading LLM catalog")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("building LLM catalog")?,
);
let catalog = Arc::new(load_cli_catalog().context("building LLM catalog")?);
if self.catalog.set(Arc::clone(&catalog)).is_ok() {
return Ok(catalog);
}
@ -244,6 +240,18 @@ fn load_merged_settings(cli_layer: &CliLayer, server_mode: &ServerMode) -> Resul
}
}
/// The catalog CLI commands run against: lithos built-ins, Fabro policy, and
/// the operator `[llm]` overlay from the active settings file.
#[expect(
clippy::disallowed_methods,
reason = "The CLI honors OPENAI_BASE_URL from the process environment."
)]
pub(crate) fn load_cli_catalog() -> Result<Catalog> {
let overlay = load_llm_overlay(None).context("loading the LLM settings overlay")?;
fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())
.context("building the LLM catalog")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;

View file

@ -1,150 +1,116 @@
//! `fabro exec`: one agentic coding session in the current directory.
//!
//! The session is pebble's coding agent over a local sandbox, run through
//! pebble's own command-line session: its event renderer, closing summary,
//! and terminal approval prompt. What is fabro's here is the client (model
//! calls go either straight to the provider with the CLI's credentials or
//! through a Fabro server's completions endpoint when a server target is
//! set), the sandbox, the MCP servers, skills, search, and redaction.
use std::collections::HashMap;
use std::io::IsTerminal as _;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context as _, Result as AnyResult};
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,
};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::providers::common::{LineReader, parse_retry_after};
use fabro_llm::types::{
CostSource, FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts,
};
use async_trait::async_trait;
use fabro_llm::credentials::CredentialProvider;
use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::middleware::{Call, Middleware, Next, Output};
use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind};
use fabro_mcp::config::McpServerSettings;
use fabro_model::ProviderId;
use fabro_mcp::pebble::pebble_servers;
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::ResolvedMcpEntry;
use fabro_util::exit::{self, ErrorExt, ExitClass};
use futures::stream;
use serde::Deserialize;
use fabro_util::home::Home;
use fabro_util::terminal::Styles;
use fabro_workflow::web_search::{self, SearchSecrets};
use lithos_llm::catalog::ProviderId;
use pebble_cli_core::approval::TerminalApproval;
use pebble_cli_core::render::{self, JsonStream, Style};
use pebble_cli_core::session::{SessionOptions, run_prompt};
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery};
use tokio::signal;
use tokio_util::sync::CancellationToken;
use crate::args::ExecArgs;
use crate::args::{AgentArgs, ExecArgs, ExecOutputFormat};
use crate::command_context::CommandContext;
#[cfg(feature = "sleep_inhibitor")]
use crate::sleep_inhibitor;
use crate::{server_client, user_config};
struct AuthenticatedFabroServerAdapter {
client: server_client::Client,
base_url: String,
provider_name: String,
/// Posts completions to a Fabro server through the authenticated CLI client.
struct ServerCompletionTransport {
client: server_client::Client,
base_url: String,
}
impl AuthenticatedFabroServerAdapter {
fn new(client: server_client::Client, provider_name: impl Into<String>) -> Self {
let base_url = client.base_url().clone();
Self {
client,
base_url,
provider_name: provider_name.into(),
impl ServerCompletionTransport {
fn new(client: server_client::Client) -> Self {
let base_url = client.base_url();
Self { client, base_url }
}
}
#[async_trait]
impl GatewayTransport for ServerCompletionTransport {
async fn post_completion(
&self,
body: serde_json::Value,
) -> Result<fabro_http::Response, GatewayError> {
let url = format!("{}/api/v1/completions", self.base_url);
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| GatewayError::Transport {
auth: exit::exit_class_for(&err) == Some(ExitClass::AuthRequired),
message: err.to_string(),
})?;
response.map_err(|failure| GatewayError::Status {
status: failure.status.as_u16(),
headers: failure.headers,
body: failure.body,
})
}
}
/// How a failed session is reported: a model failure by what the provider
/// said, everything else by the agent's own description.
#[derive(Debug, thiserror::Error)]
enum SessionError {
#[error("LLM error: {0}")]
Llm(fabro_llm::ErrorData),
#[error(transparent)]
Agent(pebble_coding_agent::Error),
}
impl From<pebble_coding_agent::Error> for SessionError {
fn from(error: pebble_coding_agent::Error) -> Self {
match error.llm_source() {
Some(llm) => Self::Llm(llm.data()),
None => Self::Agent(error),
}
}
}
#[derive(Deserialize)]
struct ServerCompletionResponse {
id: String,
model: String,
message: Message,
stop_reason: String,
usage: ServerUsage,
cost_usd: Option<f64>,
cost_source: Option<CostSource>,
}
#[derive(Deserialize)]
struct ServerUsage {
input_tokens: i64,
output_tokens: i64,
}
fn map_stop_reason(reason: &str) -> FinishReason {
match reason {
"end_turn" | "stop" => FinishReason::Stop,
"max_tokens" | "length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
other => FinishReason::Other(other.to_string()),
}
}
fn build_body(request: &Request, stream: bool) -> std::result::Result<serde_json::Value, LlmError> {
let mut body = serde_json::to_value(request).map_err(|err| {
LlmError::configuration_error(format!("failed to serialize request: {err}"), err)
})?;
body["stream"] = serde_json::Value::Bool(stream);
Ok(body)
}
fn parse_server_error_body(body: &str) -> (String, Option<String>, Option<serde_json::Value>) {
serde_json::from_str::<serde_json::Value>(body).map_or_else(
|_| (body.to_string(), None, None),
|value| {
let first = value
.get("errors")
.and_then(serde_json::Value::as_array)
.and_then(|errors| errors.first());
let detail = first
.and_then(|entry| entry.get("detail"))
.and_then(serde_json::Value::as_str)
.or_else(|| value.get("detail").and_then(serde_json::Value::as_str))
.or_else(|| {
value
.get("error")
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
})
.unwrap_or("Unknown error")
.to_string();
let code = first
.and_then(|entry| entry.get("code"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
value
.get("error")
.and_then(|error| error.get("type"))
.and_then(serde_json::Value::as_str)
})
.map(ToOwned::to_owned);
(detail, code, Some(value))
},
)
}
fn transport_error(provider: &str, err: &anyhow::Error) -> LlmError {
let message = err.to_string();
if exit::exit_class_for(err) == Some(ExitClass::AuthRequired) {
return LlmError::Provider {
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail {
message,
provider: provider.to_string(),
status_code: Some(401),
error_code: None,
retry_after: None,
raw: None,
}),
};
}
LlmError::Configuration {
message,
source: None,
}
}
fn classify_server_agent_auth(err: anyhow::Error) -> anyhow::Error {
let is_auth = err.chain().any(|cause| {
cause
.downcast_ref::<fabro_agent::Error>()
.downcast_ref::<SessionError>()
.is_some_and(|error| {
matches!(
error,
fabro_agent::Error::Llm(llm)
if llm.provider_kind() == Some(ProviderErrorKind::Authentication)
)
matches!(error, SessionError::Llm(data) if data.kind() == ErrorKind::Authentication)
})
});
if is_auth {
@ -154,134 +120,6 @@ fn classify_server_agent_auth(err: anyhow::Error) -> anyhow::Error {
}
}
fn map_response_failure(provider: &str, failure: &fabro_client::ApiError) -> LlmError {
let retry_after = parse_retry_after(&failure.headers);
let (message, code, raw) = parse_server_error_body(&failure.body);
error_from_status_code(
failure.status.as_u16(),
message,
provider.to_string(),
code,
raw,
retry_after,
)
}
fn parse_sse_block(block: &str) -> Option<(String, String)> {
let mut event_type = None;
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(value) = line.strip_prefix("event:") {
event_type = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim());
}
}
let event_type = event_type?;
if data_lines.is_empty() {
return None;
}
Some((event_type, data_lines.join("\n")))
}
#[async_trait::async_trait]
impl ProviderAdapter for AuthenticatedFabroServerAdapter {
fn name(&self) -> &str {
&self.provider_name
}
async fn complete(&self, request: &Request) -> std::result::Result<LlmResponse, LlmError> {
let url = format!("{}/api/v1/completions", self.base_url);
let body = build_body(request, false)?;
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| transport_error(&self.provider_name, &err))?;
let response =
response.map_err(|failure| map_response_failure(&self.provider_name, &failure))?;
let response_body = response
.text()
.await
.map_err(|err| LlmError::network(err.to_string(), err))?;
let server_response: ServerCompletionResponse = serde_json::from_str(&response_body)
.map_err(|err| {
LlmError::stream_error(format!("failed to parse completion response: {err}"), err)
})?;
Ok(LlmResponse {
id: server_response.id,
model: server_response.model,
provider: self.provider_name.clone(),
message: server_response.message,
finish_reason: map_stop_reason(&server_response.stop_reason),
usage: TokenCounts {
input_tokens: server_response.usage.input_tokens,
output_tokens: server_response.usage.output_tokens,
..Default::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
// Carry the server's cost through; the local client's stamping
// never overwrites an already-set cost.
cost_usd: server_response.cost_usd,
cost_source: server_response.cost_source,
})
}
async fn stream(&self, request: &Request) -> std::result::Result<StreamEventStream, LlmError> {
let url = format!("{}/api/v1/completions", self.base_url);
let body = build_body(request, true)?;
let response = self
.client
.send_http_response(|http_client| {
let body = body.clone();
let url = url.clone();
async move { http_client.post(url).json(&body).send().await }
})
.await
.map_err(|err| transport_error(&self.provider_name, &err))?;
let response =
response.map_err(|failure| map_response_failure(&self.provider_name, &failure))?;
let stream = stream::unfold(LineReader::new(response, None), |mut reader| async move {
loop {
match reader.read_next_chunk("\n\n").await {
Ok(Some(block)) => {
if let Some((event_type, data)) = parse_sse_block(&block) {
if event_type == "stream_event" {
match serde_json::from_str::<StreamEvent>(&data) {
Ok(event) => return Some((Ok(event), reader)),
Err(err) => {
return Some((
Err(LlmError::stream_error(
format!("failed to parse stream event: {err}"),
err,
)),
reader,
));
}
}
}
}
}
Ok(None) => return None,
Err(err) => return Some((Err(err), reader)),
}
}
});
Ok(Box::pin(stream))
}
}
fn run_mcp_servers_for_exec(
mcps: &HashMap<String, ResolvedMcpEntry>,
) -> AnyResult<Vec<McpServerSettings>> {
@ -308,8 +146,8 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
let model_str = cli.exec.model.name.as_deref();
let permissions = cli.exec.agent.permissions;
let output_format = Some(match cli.output.format {
SettingsOutputFormat::Text => OutputFormat::Text,
SettingsOutputFormat::Json => OutputFormat::Json,
SettingsOutputFormat::Text => ExecOutputFormat::Text,
SettingsOutputFormat::Json => ExecOutputFormat::Json,
});
args.agent
.apply_cli_defaults(provider_str, model_str, permissions, output_format);
@ -337,6 +175,9 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
.with_context(|| format!("failed to resolve MCP server {:?}", settings.name))
})
.collect::<AnyResult<Vec<_>>>()?;
// Resolve color support once, leak to get 'static lifetime for use across
// threads.
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
if let Some(target) = server_target {
tracing::info!(transport = "server", "Agent session starting");
let provider_name = args
@ -345,40 +186,348 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
.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 provider_id = catalog.enabled_provider(&provider_name).map_or_else(
|| ProviderId::new(provider_name.as_str()),
|provider| provider.id().clone(),
);
let server_client = server_client::connect_server_target(&target).await?;
let adapter = Arc::new(AuthenticatedFabroServerAdapter::new(
server_client,
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_and_catalog(args.agent, client, mcp_servers, catalog)
let adapter = Arc::new(GatewayAdapter::new(Box::new(
ServerCompletionTransport::new(server_client),
)));
// The server inlines attachments and is the billing authority, so the
// local client only routes and reports diagnostics.
let mut options = cli_client_options(&args.agent, styles);
options.inline_attachments = false;
let client = fabro_llm::build_offline_client(
Catalog::clone(&catalog),
options.with_adapter(provider_id, adapter),
)
.context("Failed to register fabro server adapter")?
.client;
run_session(args.agent, client, mcp_servers, catalog, styles)
.await
.map_err(classify_server_agent_auth)?;
} else {
tracing::info!(transport = "direct", "Agent session starting");
let llm_source = ctx.llm_source().await?;
let catalog = ctx.catalog()?;
run_with_args_and_source_and_catalog(args.agent, llm_source, mcp_servers, catalog).await?;
let client = build_direct_client(&args.agent, llm_source, &catalog, styles).await?;
run_session(args.agent, client, mcp_servers, catalog, styles).await?;
}
Ok(())
}
#[allow(
clippy::print_stderr,
reason = "Provider build issues are diagnostics for the person running the CLI."
)]
async fn build_direct_client(
args: &AgentArgs,
llm_source: Arc<dyn CredentialProvider>,
catalog: &Arc<Catalog>,
styles: &'static Styles,
) -> AnyResult<Client> {
let built = fabro_llm::build_client(
Catalog::clone(catalog),
llm_source,
cli_client_options(args, styles),
)
.await
.context("Failed to create LLM client")?;
for issue in &built.build_issues {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"[llm] provider '{}' is unavailable: {}",
issue.provider, issue.cause
))
);
}
Ok(built.client)
}
/// Client options for the session: standard retries plus the requested
/// diagnostic middleware.
fn cli_client_options(args: &AgentArgs, styles: &'static Styles) -> ClientOptions {
let options = ClientOptions::standard();
if args.verbose {
options.with_middleware(Arc::new(VerboseMiddleware { styles }))
} else if args.debug {
options.with_middleware(Arc::new(DebugMiddleware { styles }))
} else {
options
}
}
#[expect(
clippy::disallowed_methods,
reason = "fabro exec passes search process-env credentials into the agent's search tool."
)]
fn cli_search_secrets() -> SearchSecrets {
SearchSecrets {
brave_search_api_key: std::env::var(EnvVars::BRAVE_SEARCH_API_KEY).ok(),
venice_api_key: std::env::var(EnvVars::VENICE_API_KEY).ok(),
}
}
/// The provider the session runs on: the `--provider` flag, else the
/// highest-priority available provider offering `--model`, else the default.
fn resolve_provider_id(
catalog: &Catalog,
args: &AgentArgs,
available: &std::collections::HashSet<ProviderId>,
) -> ProviderId {
let requested = ProviderId::new(args.provider.as_deref().unwrap_or("anthropic"));
if args.provider.is_some() {
return canonical_provider_id(catalog, &requested);
}
if let Some(model_id) = args.model.as_deref() {
let matches = catalog.offerings_matching(model_id);
if let Some(entry) = matches
.iter()
.find(|entry| available.contains(entry.provider.id()))
.or_else(|| matches.first())
{
return entry.provider.id().clone();
}
}
canonical_provider_id(catalog, &requested)
}
/// The catalog id for `requested`, resolving aliases; the request itself when
/// the catalog does not know it, so the error names what the caller typed.
fn canonical_provider_id(catalog: &Catalog, requested: &ProviderId) -> ProviderId {
catalog
.enabled_provider(requested.as_str())
.map_or_else(|| requested.clone(), |provider| provider.id().clone())
}
/// The model that summarizes fetched web pages: the provider's small default,
/// else its default model, else the session's own model.
fn summarizer_model(catalog: &Catalog, provider_id: &ProviderId, selected_model: &str) -> String {
let model = catalog
.small_default_for([provider_id])
.filter(|entry| entry.provider.id() == provider_id)
.or_else(|| {
catalog
.enabled_provider(provider_id.as_str())?
.default_offering()
})
.map_or_else(
|| selected_model.to_string(),
|entry| entry.model.id().to_string(),
);
format!("{provider_id}/{model}")
}
/// Middleware that logs LLM request/response summaries to stderr.
struct DebugMiddleware {
styles: &'static Styles,
}
#[async_trait]
impl Middleware for DebugMiddleware {
#[allow(
clippy::print_stderr,
reason = "Debug middleware logs request and response summaries to stderr."
)]
async fn handle(&self, call: Call, next: Next) -> Result<Output, LlmError> {
let s = self.styles;
eprintln!(
"{}",
s.dim.apply_to(format!(
"[debug] request: model={} messages={} tools={}",
call.route().handle(),
call.request().messages().len(),
call.request().tools().len(),
)),
);
let output = next.run(call).await?;
if let Output::Complete(response) = &output {
eprintln!(
"{}",
s.dim.apply_to(format!(
"[debug] response: model={} finish={:?} usage=({}/{}/{})",
response.model,
response.finish_reason,
response.usage.input,
response.usage.output,
response.usage.total(),
)),
);
}
Ok(output)
}
}
/// Middleware that logs full LLM request/response JSON to stderr.
struct VerboseMiddleware {
styles: &'static Styles,
}
#[async_trait]
impl Middleware for VerboseMiddleware {
#[allow(
clippy::print_stderr,
reason = "Verbose middleware dumps full request and response JSON to stderr."
)]
async fn handle(&self, call: Call, next: Next) -> Result<Output, LlmError> {
let s = self.styles;
eprintln!(
"{}\n{}",
s.dim.apply_to("[verbose] request:"),
serde_json::to_string_pretty(call.request())
.unwrap_or_else(|e| format!("<serialize error: {e}>"))
);
let output = next.run(call).await?;
if let Output::Complete(response) = &output {
eprintln!(
"{}\n{}",
s.dim.apply_to("[verbose] response:"),
serde_json::to_string_pretty(response)
.unwrap_or_else(|e| format!("<serialize error: {e}>"))
);
}
Ok(output)
}
}
#[allow(
clippy::print_stderr,
reason = "The model line is a diagnostic for the person running the CLI."
)]
async fn run_session(
args: AgentArgs,
client: Client,
mcp_servers: Vec<McpServerSettings>,
catalog: Arc<Catalog>,
styles: &'static Styles,
) -> AnyResult<()> {
let available: std::collections::HashSet<ProviderId> =
client.available_providers().iter().cloned().collect();
let provider_id = resolve_provider_id(&catalog, &args, &available);
if !available.contains(&provider_id) {
anyhow::bail!("LLM credentials not configured for provider '{provider_id}'");
}
let model = if let Some(model) = args.model.clone() {
model
} else {
catalog
.enabled_provider(provider_id.as_str())
.and_then(CatalogProvider::default_offering)
.map(|entry| entry.model.id().to_string())
.ok_or_else(|| {
anyhow::anyhow!(
"provider '{provider_id}' has no default model in the catalog; pass --model explicitly"
)
})?
};
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(cwd)
.await
.context("failed to create the local sandbox")?,
);
let permissions = args.permission_level();
#[expect(
clippy::disallowed_methods,
reason = "is_terminal() on stdin is a non-blocking fstat; no actual I/O performed"
)]
let is_interactive = std::io::stdin().is_terminal() && !args.auto_approve;
let permission_middleware =
PermissionMiddleware::new(Arc::new(PermissionLevelPolicy::new(permissions)))
.with_approval(Arc::new(TerminalApproval::new(permissions, is_interactive)));
// The profile's own instruction files from the repository root down, and
// fabro's skill directories: pebble knows the files and does the walk.
let mut options = CodingAgentOptions::default()
.with_memory_discovery(MemoryDiscovery::from_git_root())
.with_recorded_permission_level(permissions);
options = match &args.skills_dir {
Some(skills_dir) => options.with_skill_dirs([skills_dir.clone()]),
None => options.with_skill_discovery(
SkillDiscovery::new()
.search(Home::from_env().skills_dir().to_string_lossy().into_owned())
.search_under_git_root(".fabro/skills")
.search_under_git_root("skills"),
),
};
let environment: Arc<dyn Environment> = Arc::clone(&sandbox) as Arc<dyn Environment>;
let mut builder = CodingAgent::builder(client, environment)
.model(format!("{provider_id}/{model}"))
.options(options)
.tool_middleware(Arc::new(permission_middleware))
.redactor(Arc::new(SecretRedactor))
.web_fetch_summarizer(summarizer_model(&catalog, &provider_id, &model))
.mcp_servers(pebble_servers(&mcp_servers))
.subagents(SubagentOptions::enabled());
if let Some(routes) = sandbox.port_routes() {
builder = builder.port_routes(routes);
}
if let Some(search) = web_search::search_provider(&cli_search_secrets()) {
builder = builder.search_provider(search);
}
let agent = builder
.build()
.await
.context("failed to start the agent session")?;
// Text puts progress on stderr and the answer on stdout; JSON puts the
// event stream itself on stdout, as scripts that read it expect.
let session = match args.output_format.unwrap_or(ExecOutputFormat::Text) {
ExecOutputFormat::Text => SessionOptions::default(),
ExecOutputFormat::Json => SessionOptions {
style: Style::Json,
json_to: JsonStream::Stdout,
write_answer: false,
},
};
render::report_mcp_servers(&agent, session.style);
// SIGINT ends the prompt; the session shuts down as cancelled.
let cancel_token = CancellationToken::new();
let sigint_token = cancel_token.clone();
tokio::spawn(async move {
signal::ctrl_c().await.ok();
sigint_token.cancel();
});
let report = run_prompt(agent, args.prompt.as_str(), &cancel_token, session).await?;
report
.result
.map(|_| ())
.map_err(|error| anyhow::Error::new(SessionError::from(error)))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay};
use fabro_types::settings::run::{McpServerRef, McpServerSettings, ResolvedMcpEntry};
use lithos_llm::catalog::builtin;
use super::run_mcp_servers_for_exec;
use super::{AgentArgs, resolve_provider_id, run_mcp_servers_for_exec, summarizer_model};
use crate::args::{ExecOutputFormat, PermissionsArg};
fn args(provider: Option<&str>, model: Option<&str>) -> AgentArgs {
AgentArgs {
prompt: "task".to_string(),
provider: provider.map(str::to_string),
model: model.map(str::to_string),
permissions: Some(PermissionsArg::Full),
auto_approve: true,
debug: false,
verbose: false,
skills_dir: None,
output_format: Some(ExecOutputFormat::Text),
}
}
#[test]
fn run_mcp_servers_for_exec_rejects_catalog_references() {
@ -412,4 +561,34 @@ mod tests {
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "inline");
}
#[test]
fn explicit_provider_wins_over_model_matching() {
let catalog = test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n");
let available = [builtin::openai()].into_iter().collect();
let provider = resolve_provider_id(&catalog, &args(Some("openrouter"), None), &available);
assert_eq!(provider.as_str(), "openrouter");
}
#[test]
fn a_bare_model_picks_an_available_provider_offering_it() {
let catalog = test_catalog();
let available = [builtin::openai()].into_iter().collect();
let provider = resolve_provider_id(&catalog, &args(None, Some("gpt-5.4")), &available);
assert_eq!(provider, builtin::openai());
}
#[test]
fn summarizer_uses_the_providers_small_default() {
let catalog = test_catalog();
let selector = summarizer_model(&catalog, &builtin::anthropic(), "claude-opus-4-6");
assert!(selector.starts_with("anthropic/"), "{selector}");
assert_ne!(selector, "anthropic/claude-opus-4-6");
}
}

View file

@ -34,8 +34,7 @@ use fabro_install::{
restore_optional_file, rollback_dev_token_write, seed_environments_in_storage,
write_github_app_settings, write_token_settings,
};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, CredentialRef, ProviderId};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
@ -47,6 +46,7 @@ use fabro_util::version::FABRO_VERSION;
use fabro_util::{browser, dev_token, path, session_secret};
use fabro_vault::SecretType as VaultSecretType;
use futures::future::BoxFuture;
use lithos_llm::catalog::{ProviderId, builtin};
use rand::Rng;
use tokio::net::TcpListener;
use tokio::process::Command as TokioCommand;
@ -75,46 +75,34 @@ const GITHUB_APP_PRIVATE_KEY_KEY: &str = fabro_static::EnvVars::GITHUB_APP_PRIVA
const GITHUB_APP_CLIENT_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_CLIENT_SECRET;
const GITHUB_APP_WEBHOOK_SECRET_KEY: &str = fabro_static::EnvVars::GITHUB_APP_WEBHOOK_SECRET;
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(|| {
Catalog::from_builtin().expect("embedded install model catalog should be valid")
});
static INSTALL_CATALOG: LazyLock<Catalog> = LazyLock::new(fabro_llm::default_catalog);
fn supports_install_api_key(provider: &CatalogProvider) -> bool {
provider.auth.is_some()
fabro_auth::accepts_api_key(provider)
}
fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
catalog
.providers()
.iter()
.listed_providers()
.into_iter()
.filter(|provider| supports_install_api_key(provider))
.map(|provider| provider.id.clone())
.map(|provider| provider.id().clone())
.collect()
}
fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
catalog
.provider(provider)
.and_then(|provider| provider.auth.as_ref())
.map(|auth| {
auth.credentials
.iter()
.filter_map(|credential| match credential {
CredentialRef::Env(name) => Some(name.as_str()),
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
})
.collect::<Vec<_>>()
.join(" / ")
})
.enabled_provider(provider.as_str())
.map(|provider| fabro_auth::secret_names(provider).join(" / "))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| "API_KEY".to_string())
}
fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String {
catalog.provider_vault_secret_name(provider).map_or_else(
|| format!("{}_API_KEY", provider.to_string().to_uppercase()),
str::to_string,
)
catalog
.enabled_provider(provider.as_str())
.and_then(fabro_auth::expected_secret_name)
.unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase()))
}
// ---------------------------------------------------------------------------
@ -442,14 +430,14 @@ impl InstallInputSource for InteractiveInstallInputSource {
if use_device_auth {
let credential = authenticate_provider_with_method(
ProviderId::openai(),
builtin::openai(),
AuthMethod::CodexDevice(codex_oauth_config()),
s,
printer,
)
.await?;
credentials.push(credential);
configured_providers.push(ProviderId::openai());
configured_providers.push(builtin::openai());
openai_configured = true;
}
}
@ -2711,7 +2699,7 @@ client_id = "client-id"
description: None,
},
credential_secret_request(&LoginResult::ApiKey {
provider: ProviderId::anthropic(),
provider: lithos_llm::catalog::builtin::anthropic(),
key: "anthropic-key".to_string(),
})
.unwrap(),
@ -3515,11 +3503,11 @@ root = "{}"
#[test]
fn install_llm_providers_come_from_catalog_api_key_providers() {
let ids = install_llm_provider_ids(Catalog::builtin());
let ids = install_llm_provider_ids(&INSTALL_CATALOG);
assert!(ids.contains(&ProviderId::anthropic()));
assert!(ids.contains(&ProviderId::openai()));
assert!(ids.contains(&ProviderId::gemini()));
assert!(ids.contains(&lithos_llm::catalog::builtin::anthropic()));
assert!(ids.contains(&lithos_llm::catalog::builtin::openai()));
assert!(ids.contains(&lithos_llm::catalog::builtin::gemini()));
assert!(ids.contains(&ProviderId::new("moonshot")));
assert!(ids.contains(&ProviderId::new("zai")));
assert!(ids.contains(&ProviderId::new("minimax")));
@ -3527,7 +3515,6 @@ root = "{}"
assert!(ids.contains(&ProviderId::new("venice")));
assert!(ids.contains(&ProviderId::new("poolside")));
assert!(ids.contains(&ProviderId::new("deepseek")));
assert!(!ids.contains(&ProviderId::new("fireworks")));
assert!(!ids.contains(&ProviderId::new("ollama")));
assert!(!ids.contains(&ProviderId::new("litellm")));
}
@ -3545,7 +3532,7 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_hidden_args_without_switch() {
let args = install_args(false, InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
..InstallNonInteractiveArgs::default()
});
let err = NonInteractiveInstallInputSource::new(&args).unwrap_err();
@ -3558,7 +3545,7 @@ root = "{}"
#[test]
fn non_interactive_source_rejects_conflicting_api_key_inputs() {
let args = install_args(true, InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_stdin: true,
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
@ -3654,7 +3641,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_strategy() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_username: Some("brynary".to_string()),
..InstallNonInteractiveArgs::default()
@ -3672,7 +3659,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_username_for_new_config() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
..InstallNonInteractiveArgs::default()
@ -3689,7 +3676,7 @@ root = "{}"
fn non_interactive_source_allows_keep_existing_settings_without_username() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
keep_existing_settings: true,
@ -3704,7 +3691,7 @@ root = "{}"
fn non_interactive_source_rejects_missing_github_owner_for_app() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
..InstallNonInteractiveArgs::default()
@ -3723,7 +3710,7 @@ root = "{}"
fn non_interactive_source_rejects_github_owner_for_token() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_owner: Some("personal".to_string()),
@ -3743,7 +3730,7 @@ root = "{}"
fn non_interactive_source_rejects_github_username_for_app() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
github_owner: Some("personal".to_string()),
@ -3763,7 +3750,7 @@ root = "{}"
fn non_interactive_source_allows_github_app_setup() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::App),
github_owner: Some("personal".to_string()),
@ -3778,7 +3765,7 @@ root = "{}"
async fn non_interactive_source_requires_config_choice_when_settings_exist() {
let source = NonInteractiveInstallInputSource {
args: InstallNonInteractiveArgs {
llm_provider: Some(ProviderId::anthropic()),
llm_provider: Some(lithos_llm::catalog::builtin::anthropic()),
llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
github_strategy: Some(InstallGitHubStrategyArg::Token),
github_username: Some("brynary".to_string()),

View file

@ -34,7 +34,6 @@ fn server_settings(
let user_settings = connection_ctx.user_settings().clone();
let storage_dir = connection_ctx.storage_dir().to_path_buf();
let base_config_path = connection_ctx.base_config_path().to_path_buf();
let config_path = base_config_path.clone();
let client_factory: fabro_mcp_server::FabroClientFactory = std::sync::Arc::new(move || {
let target = target.clone();
let user_settings = user_settings.clone();
@ -51,11 +50,7 @@ fn server_settings(
});
future
});
Ok(fabro_mcp_server::FabroMcpServerSettings {
client_factory,
config_path,
cwd: base_ctx.cwd().to_path_buf(),
})
Ok(fabro_mcp_server::FabroMcpServerSettings { client_factory })
}
fn init_settings(args: &McpInitArgs) -> Result<fabro_mcp_server::McpInitSettings> {

View file

@ -2,9 +2,10 @@ 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, ProviderId};
use fabro_types::{Model, ModelTestMode};
use fabro_util::terminal::Styles;
use futures::{StreamExt, stream};
use lithos_llm::catalog::ProviderId;
use serde::Serialize;
use crate::args::{ModelListArgs, ModelTestArgs, ModelsCommand};
@ -46,7 +47,7 @@ struct CompletedModelTest {
}
fn model_matches_selector(model: &Model, selector: &str) -> bool {
model.id == selector || model.aliases.iter().any(|alias| alias == selector)
model.id.as_str() == selector || model.aliases.iter().any(|alias| alias == selector)
}
fn find_model_by_id_or_alias(
@ -363,7 +364,7 @@ async fn test_models_via_server(
for info in &unconfigured {
skipped += 1;
let provider_name = info.provider.display_name();
let provider_name = info.provider.to_string();
if !skipped_providers.contains(&provider_name) {
skipped_providers.push(provider_name);
}
@ -513,10 +514,9 @@ impl Default for ModelsCommand {
#[cfg(test)]
mod tests {
use fabro_model::{
ModelControls, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffort,
ReasoningEffortFeature,
};
use fabro_types::{ModelControls, ModelCosts, ModelFeatures, ModelLimits};
use lithos_llm::catalog::builtin;
use lithos_llm::types::ReasoningEffort;
use super::*;
@ -537,13 +537,11 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
prompt_cache: false,
sampling: true,
},
controls: ModelControls::default(),
costs: ModelCosts {
@ -573,13 +571,11 @@ mod tests {
training: None,
knowledge_cutoff: None,
features: ModelFeatures {
tools: true,
vision: false,
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
cache_control_breakpoints: false,
sampling_params: true,
tools: true,
vision: false,
reasoning: false,
prompt_cache: false,
sampling: true,
},
controls: ModelControls::default(),
costs: ModelCosts {
@ -907,7 +903,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("test-model", ProviderId::anthropic())],
"data": [test_model_json("test-model", builtin::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
@ -920,8 +916,8 @@ mod tests {
mock.assert_async().await;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "test-model");
assert_eq!(models[0].provider, ProviderId::anthropic());
assert_eq!(models[0].id.as_str(), "test-model");
assert_eq!(models[0].provider, builtin::anthropic());
}
#[tokio::test]
@ -938,7 +934,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-a", ProviderId::anthropic())],
"data": [test_model_json("model-a", builtin::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
@ -950,7 +946,7 @@ mod tests {
let models = client.list_models(Some("anthropic"), None).await.unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "model-a");
assert_eq!(models[0].id.as_str(), "model-a");
}
#[tokio::test]
@ -967,7 +963,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("claude-sonnet-4-5", ProviderId::anthropic())],
"data": [test_model_json("claude-sonnet-4-5", builtin::anthropic())],
"meta": { "has_more": false }
})
.to_string(),
@ -980,7 +976,7 @@ mod tests {
mock.assert_async().await;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "claude-sonnet-4-5");
assert_eq!(models[0].id.as_str(), "claude-sonnet-4-5");
}
#[tokio::test]
@ -996,7 +992,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-a", ProviderId::anthropic())],
"data": [test_model_json("model-a", builtin::anthropic())],
"meta": { "has_more": true }
})
.to_string(),
@ -1013,7 +1009,7 @@ mod tests {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [test_model_json("model-b", ProviderId::openai())],
"data": [test_model_json("model-b", builtin::openai())],
"meta": { "has_more": false }
})
.to_string(),
@ -1027,8 +1023,8 @@ mod tests {
first_page.assert_async().await;
second_page.assert_async().await;
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "model-a");
assert_eq!(models[1].id, "model-b");
assert_eq!(models[0].id.as_str(), "model-a");
assert_eq!(models[1].id.as_str(), "model-b");
}
#[tokio::test]

View file

@ -1,9 +1,9 @@
use anyhow::{Context, Result};
use fabro_api::types;
use fabro_auth::{AuthContextRequest, AuthMethod, LoginResult, OPENAI_CODEX_VAULT_SECRET_NAME};
use fabro_model::ProviderId;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use lithos_llm::catalog::ProviderId;
use tokio::task::spawn_blocking;
use crate::args::ProviderLoginArgs;

View file

@ -1,6 +1,7 @@
use anyhow::Result;
use fabro_util::terminal::Styles;
use super::remote_workflow::Interruption;
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
@ -15,16 +16,32 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res
let quiet = args.detach;
let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep;
let created_run = Box::pin(super::create::create_run(&ctx, &args, styles)).await?;
// Ctrl-C stays owned here through start; `attach` installs its own listener.
let interruption = Interruption::for_run_args(&args);
let (created_run, client) = interruption
.guard(async {
let created_run = Box::pin(super::create::create_run(
&ctx,
&args,
styles,
&interruption,
))
.await?;
if !quiet {
fabro_util::printerr!(
printer,
" {} {}",
styles.dim.apply_to("Run:"),
styles.dim.apply_to(&created_run.run_id),
);
}
if !quiet {
fabro_util::printerr!(
printer,
" {} {}",
styles.dim.apply_to("Run:"),
styles.dim.apply_to(&created_run.run_id),
);
}
let client = ctx.server().await?;
super::start::start_run_with_client(&client, &created_run.run_id, false).await?;
Ok((created_run, client))
})
.await?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = sleep_inhibitor::guard(prevent_idle_sleep);
@ -32,9 +49,6 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res
#[cfg(not(feature = "sleep_inhibitor"))]
let _ = prevent_idle_sleep;
let client = ctx.server().await?;
super::start::start_run_with_client(&client, &created_run.run_id, false).await?;
let json = ctx.json_output();
if args.detach {
if json {

View file

@ -1,13 +1,16 @@
use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use anyhow::{Context as _, anyhow};
use fabro_config::project;
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, Environment};
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget};
use fabro_types::{RunId, RunIntent};
use fabro_util::terminal::Styles;
use super::overrides::prepare_intent_overrides;
use super::remote_workflow::Interruption;
use super::resolution::ResolvedWorkflow;
use super::selection::WorkflowSelection;
use super::{resolution, selection};
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::commands::resolve_run_id;
@ -17,19 +20,20 @@ pub(crate) struct CreatedRun {
pub(crate) run_id: RunId,
}
/// Register the local workflow version closure with the server and create a
/// Register the workflow version closure with the server and create a
/// run from an immutable workflow intent, leaving it in the submitted state.
///
/// This does NOT start the workflow — starting is a separate request.
///
/// Native Git acquisition runs under `interruption`; the caller guards this
/// call (and any later phase before `attach`) with the same handle.
pub(crate) async fn create_run(
ctx: &CommandContext,
args: &RunArgs,
styles: &Styles,
interruption: &Interruption,
) -> anyhow::Result<CreatedRun> {
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let (workflow_selection, target_selection) = selection::parse(args)?;
let canonical_cwd = ctx.cwd().canonicalize().with_context(|| {
format!(
"failed to canonicalize caller working directory {}",
@ -37,11 +41,20 @@ pub(crate) async fn create_run(
)
})?;
let user_workflows_root = fabro_util::Home::from_env().workflows_dir();
let package = fabro_manifest::resolve_local_workflow_package(
workflow_path,
&canonical_cwd,
Some(&user_workflows_root),
)?;
let resolve_workflow = || {
resolution::workflow(
&workflow_selection,
&canonical_cwd,
Some(&user_workflows_root),
interruption,
)
};
// Preserve local lookup diagnostics before contacting the server. Remote
// acquisition waits until the parent and environment are validated.
let local_package = match &workflow_selection {
WorkflowSelection::Local(_) => Some(resolve_workflow().await?),
WorkflowSelection::Git { .. } => None,
};
let prepared = prepare_intent_overrides(args, &canonical_cwd).await?;
warn_untransmitted_settings(
@ -50,7 +63,12 @@ pub(crate) async fn create_run(
ctx.base_config_path(),
*ctx.run_settings_key_presence(),
);
let project_config = project::discover_project_config(&package.workflow_location().dir)?;
let project_config = match &local_package {
Some(ResolvedWorkflow::Local(package)) => {
project::discover_project_config(&package.workflow_location().dir)?
}
_ => project::discover_project_config(&canonical_cwd)?,
};
if let Some(path) = project_config.as_deref() {
warn_untransmitted_settings(
ctx,
@ -72,12 +90,36 @@ pub(crate) async fn create_run(
},
resolve_run_environment(client.as_ref(), args.environment.as_deref()),
)?;
let (target, dirty_worktree) =
run_target_for_environment(environment.settings.provider, &canonical_cwd)?;
// Observing a local Git target may push its branch. Acquire the remote
// workflow first so a bad --workflow-ref never causes that side effect.
let package = match local_package {
Some(package) => package,
None => resolve_workflow().await?,
};
// Preserve configured repository inference for the existing local workflow
// path. Explicit targets select their own repository independently.
let configured_repo_origin_url = match &package {
ResolvedWorkflow::Local(package)
if args.target_from.is_none()
&& args.target_repo.is_none()
&& args.target_repo_selector.is_none() =>
{
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
}
_ => None,
};
let (target, dirty_worktree) = resolution::target(
&target_selection,
&environment.settings.provider,
&canonical_cwd,
configured_repo_origin_url.as_deref(),
interruption,
)
.await?;
if dirty_worktree {
fabro_util::printerr!(
ctx.printer(),
"{} the caller Git working tree is dirty; uncommitted changes are not included in the run target.",
"{} the selected target Git working tree is dirty; uncommitted changes are not included in the run target.",
styles.yellow.apply_to("Warning:"),
);
}
@ -164,86 +206,3 @@ fn warn_untransmitted_settings(
keys.join(", "),
);
}
/// Derives the run target from the caller directory for the environment's
/// provider. Returns the target plus whether a clone-based observation found a
/// dirty Git worktree, so the caller can warn about it.
fn run_target_for_environment(
provider: EnvironmentProvider,
canonical_cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
if !provider.is_clone_based() {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"caller working directory is not valid UTF-8: {}",
canonical_cwd.display()
)
})?;
return Ok((
RunTarget::Folder {
path: path.to_string(),
},
false,
));
}
let Some(observation) = fabro_manifest::observe_git_run_target(canonical_cwd, None) else {
return Ok((none_target_for_unversioned_directory(canonical_cwd)?, false));
};
let dirty = observation.legacy_git_context.dirty == DirtyStatus::Dirty;
let target = observation.run_target.ok_or_else(|| {
anyhow!("the caller Git checkout cannot be represented as a canonical GitHub run target")
})?;
if target.sha.is_none() {
bail!(
"the exact local Git commit could not be made available from the canonical GitHub origin; push the commit and try again"
);
}
Ok((RunTarget::Git(target), dirty))
}
fn none_target_for_unversioned_directory(canonical_cwd: &Path) -> anyhow::Result<RunTarget> {
let repository = match git2::Repository::discover(canonical_cwd) {
Ok(repository) => repository,
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(RunTarget::None {}),
Err(source) => {
return Err(anyhow::Error::new(source)).with_context(|| {
format!(
"failed to inspect caller working directory {} for Git metadata",
canonical_cwd.display()
)
});
}
};
if repository.is_bare() {
bail!(
"the caller directory resolves to a bare Git repository; clone-based runs require a non-bare checkout with an attached branch"
);
}
match repository.head() {
Err(source)
if matches!(
source.code(),
git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
) =>
{
bail!(
"the caller Git checkout has no commits; create a commit before using a clone-based environment"
);
}
Err(source) => {
return Err(anyhow::Error::new(source))
.context("failed to inspect the caller Git checkout HEAD");
}
Ok(head) if !head.is_branch() => {
bail!(
"the caller Git checkout has a detached HEAD; check out a branch before using a clone-based environment"
);
}
Ok(_) => {}
}
bail!(
"the caller Git checkout does not have a usable attached branch for a clone-based run target"
)
}

View file

@ -654,25 +654,47 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
styles.dim.apply_to(&duration),
))
}
"sandbox.snapshot.pulling" => {
"git.identity.resolved" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
let email = prop_str_field(envelope, "email").unwrap_or("?");
let source = prop_str_field(envelope, "source").unwrap_or("?");
Some(format!(
"{} Git identity: {} <{}> {}",
styles.dim.apply_to(&ts),
name,
email,
styles.dim.apply_to(source),
))
}
"sandbox.create.progress" => {
let code = envelope
.pointer("/properties/progress/code")
.and_then(serde_json::Value::as_str)?;
if code != "image.pull" {
return None;
}
let message = envelope
.pointer("/properties/progress/message")
.and_then(serde_json::Value::as_str)
.unwrap_or("image");
let name = message.strip_prefix("pulling image ").unwrap_or(message);
Some(format!(
"{} Sandbox: pulling {}",
styles.dim.apply_to(&ts),
name,
))
}
"sandbox.snapshot.creating" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
"snapshot.create.started" => {
let name = driver_subject_name(envelope);
Some(format!(
"{} Sandbox: building {}",
styles.dim.apply_to(&ts),
name,
))
}
"sandbox.snapshot.ready" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
let duration = format_duration_ms(prop_field(envelope, "duration_ms"));
"snapshot.create.completed" => {
let name = driver_subject_name(envelope);
let duration = format_duration_ms(driver_duration_ms(envelope).as_ref());
Some(format!(
"{} Sandbox snapshot: {} {}",
styles.dim.apply_to(&ts),
@ -680,9 +702,12 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
styles.dim.apply_to(&duration),
))
}
"sandbox.snapshot.failed" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
let error = prop_str_field(envelope, "error").unwrap_or("unknown error");
"snapshot.create.failed" => {
let name = driver_subject_name(envelope);
let error = envelope
.pointer("/properties/error/message")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
Some(format!(
"{} {} Sandbox snapshot {} failed: {}",
styles.dim.apply_to(&ts),
@ -809,6 +834,30 @@ fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
value.get(key)?.as_str()
}
/// The name of the resource a sandbox driver event is about, falling back
/// to its id.
fn driver_subject_name(envelope: &serde_json::Value) -> &str {
envelope
.pointer("/properties/subject/name")
.or_else(|| envelope.pointer("/properties/subject/id"))
.and_then(serde_json::Value::as_str)
.unwrap_or("?")
}
/// A sandbox driver operation's duration, in milliseconds, as the number
/// [`format_duration_ms`] reads.
fn driver_duration_ms(envelope: &serde_json::Value) -> Option<serde_json::Value> {
let duration = envelope.pointer("/properties/duration")?;
let secs = duration.get("secs").and_then(serde_json::Value::as_u64)?;
let nanos = duration
.get("nanos")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
Some(serde_json::Value::from(
secs.saturating_mul(1000).saturating_add(nanos / 1_000_000),
))
}
fn prop_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
value.get("properties")?.get(key)
}
@ -1261,7 +1310,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_pulling() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.pulling","properties":{"name":"buildpack-deps:noble"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.create.progress","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"sandbox"},"type":"operation_progress","action":"create","progress":{"code":"image.pull","message":"pulling image buildpack-deps:noble"}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox: pulling"), "got: {result}");
assert!(result.contains("buildpack-deps:noble"), "got: {result}");
@ -1270,7 +1319,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_creating() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.creating","properties":{"name":"fabro-v9-test"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.started","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"fabro-v9-test"},"type":"operation_started","action":"create"}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox: building"), "got: {result}");
assert!(result.contains("fabro-v9-test"), "got: {result}");
@ -1279,7 +1328,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_ready() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.ready","properties":{"name":"buildpack-deps:noble","duration_ms":8200}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.completed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_completed","action":"create","duration":{"secs":8,"nanos":200000000}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox snapshot:"), "got: {result}");
assert!(result.contains("buildpack-deps:noble"), "got: {result}");
@ -1289,7 +1338,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_failed() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.failed","properties":{"name":"buildpack-deps:noble","error":"pull failed"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.failed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_failed","action":"create","duration":{"secs":1,"nanos":0},"error":{"kind":"provider","message":"pull failed","retryable":false,"causes":[]}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(
result.contains("Sandbox snapshot buildpack-deps:noble failed: pull failed"),

View file

@ -21,13 +21,18 @@ pub(crate) mod logs;
pub(crate) mod output;
pub(crate) mod overrides;
pub(crate) mod preview;
mod remote_workflow;
mod resolution;
pub(crate) mod resume;
pub(crate) mod rewind;
pub(crate) mod run_progress;
pub(crate) mod runner;
mod selection;
pub(crate) mod ssh;
pub(crate) mod start;
pub(crate) mod steer;
#[cfg(test)]
pub(crate) mod test_support;
pub(crate) mod wait;
pub(crate) async fn dispatch(
@ -38,11 +43,19 @@ pub(crate) async fn dispatch(
let printer = base_ctx.printer();
match cmd {
RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await,
RunCommands::Run(args) => Box::pin(command::execute(*args, base_ctx)).await,
RunCommands::Create(args) => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let ctx = base_ctx.with_target(&args.target)?;
let created_run = Box::pin(create::create_run(&ctx, &args, styles)).await?;
let interruption = remote_workflow::Interruption::for_run_args(&args);
let created_run = interruption
.guard(Box::pin(create::create_run(
&ctx,
&args,
styles,
&interruption,
)))
.await?;
if ctx.json_output() {
print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?;
} else {

View file

@ -165,21 +165,27 @@ mod tests {
fn run_args() -> RunArgs {
RunArgs {
target: ServerTargetArgs::default(),
inputs: InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
dry_run: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
environment: None,
label: Vec::new(),
parent: None,
preserve_sandbox: false,
detach: false,
target: ServerTargetArgs::default(),
inputs: InputOverrideArgs::default(),
workflow: Some(PathBuf::from("workflow.fabro")),
workflow_repo: None,
workflow_ref: None,
target_from: None,
target_repo_selector: None,
target_repo: None,
target_branch: None,
dry_run: false,
auto_approve: false,
goal: None,
goal_file: None,
model: None,
provider: None,
verbose: false,
environment: None,
label: Vec::new(),
parent: None,
preserve_sandbox: false,
detach: false,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,247 @@
use std::path::Path;
use anyhow::{Context as _, bail};
use fabro_manifest::{CollectedWorkflowClosure, ResolvedLocalWorkflowPackage};
use fabro_types::{RunTarget, SandboxProviderKind};
use tokio::task;
use super::remote_workflow::{Interruption, NativeGit};
use super::selection::{TargetSelection, WorkflowSelection};
/// Owns the canonical collector result without copying its contents. Local
/// location metadata remains available for settings warnings and target
/// inference.
pub(super) enum ResolvedWorkflow {
Local(ResolvedLocalWorkflowPackage),
Git(CollectedWorkflowClosure),
}
impl ResolvedWorkflow {
pub(super) fn closure(&self) -> &CollectedWorkflowClosure {
match self {
Self::Local(package) => package.closure(),
Self::Git(closure) => closure,
}
}
}
pub(super) async fn workflow(
selection: &WorkflowSelection,
cwd: &Path,
user_workflows: Option<&Path>,
interruption: &Interruption,
) -> anyhow::Result<ResolvedWorkflow> {
match selection {
WorkflowSelection::Local(path) => {
let (path, cwd, user_workflows) = (
path.clone(),
cwd.to_path_buf(),
user_workflows.map(Path::to_path_buf),
);
let package = task::spawn_blocking(move || {
fabro_manifest::resolve_local_workflow_package(
&path,
&cwd,
user_workflows.as_deref(),
)
.map_err(anyhow::Error::new)
})
.await
.context("local workflow collection task failed")??;
Ok(ResolvedWorkflow::Local(package))
}
WorkflowSelection::Git {
repository,
selector,
revision,
} => {
let git = NativeGit::new();
let (repository, selector, revision) =
(repository.clone(), selector.clone(), revision.clone());
let closure = interruption
.owned(move |cancel| async move {
git.collect(repository, selector, revision, cancel).await
})
.await?;
Ok(ResolvedWorkflow::Git(closure))
}
}
}
pub(super) async fn target(
selection: &TargetSelection,
provider: &SandboxProviderKind,
cwd: &Path,
configured_repo_origin_url: Option<&str>,
interruption: &Interruption,
) -> anyhow::Result<(RunTarget, bool)> {
let path = match selection {
TargetSelection::Path(path) => cwd
.join(path)
.canonicalize()
.context("failed to canonicalize target directory")?,
TargetSelection::Git { repository, branch } => {
if !provider.clones_workspace() {
bail!("Git targets require a clone-enabled environment");
}
let git = NativeGit::new();
let (repository, branch) = (repository.clone(), branch.clone());
let target = interruption
.owned(move |cancel| async move {
git.resolve_target(repository, branch, &cancel).await
})
.await?;
// Canonical admission retains ownership of provider capabilities.
return Ok((RunTarget::Git(target), false));
}
};
if !path.is_dir() {
bail!("target path must be a directory");
}
// The existing observer can push/query Git synchronously. Preserve its
// behavior without blocking a Tokio worker or promising a new timeout.
let provider = provider.clone();
let configured_repo_origin_url = configured_repo_origin_url.map(str::to_owned);
let derived = task::spawn_blocking(move || {
fabro_manifest::derive_run_target_for_provider(
&provider,
&path,
configured_repo_origin_url.as_deref(),
)
})
.await
.context("target observation task failed")??;
Ok((derived.target, derived.dirty_worktree))
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "resolver tests construct small local workflow fixtures"
)]
mod tests {
use super::super::test_support::write_workflow;
use super::*;
#[tokio::test]
async fn run_selection_target_resolution_by_provider() {
let caller = tempfile::tempdir().unwrap();
let root = caller.path().canonicalize().unwrap();
write_workflow(&root, ".fabro/workflows/review");
std::fs::create_dir(root.join("target")).unwrap();
let selected = TargetSelection::Path("target".into());
let interruption = Interruption::new(false);
assert_eq!(
target(
&selected,
&SandboxProviderKind::LOCAL,
&root,
None,
&interruption
)
.await
.unwrap()
.0,
RunTarget::Folder {
path: root.join("target").to_str().unwrap().into(),
}
);
for provider in [
SandboxProviderKind::DOCKER,
SandboxProviderKind::DAYTONA,
SandboxProviderKind::try_new("host").unwrap(),
] {
assert_eq!(
target(&selected, &provider, &root, None, &interruption)
.await
.unwrap()
.0,
RunTarget::None {}
);
}
assert_eq!(
target(
&TargetSelection::Path(".".into()),
&SandboxProviderKind::LOCAL,
&root,
None,
&interruption
)
.await
.unwrap()
.0,
RunTarget::Folder {
path: root.to_str().unwrap().into(),
}
);
assert!(
target(
&TargetSelection::Git {
repository: "acme/app".parse().unwrap(),
branch: None,
},
&SandboxProviderKind::LOCAL,
&root,
None,
&interruption
)
.await
.unwrap_err()
.to_string()
.contains("clone-enabled environment")
);
for path in ["missing", ".fabro/workflows/review/workflow.toml"] {
assert!(
target(
&TargetSelection::Path(path.into()),
&SandboxProviderKind::LOCAL,
&root,
None,
&interruption
)
.await
.is_err(),
"{path}"
);
}
}
#[tokio::test]
async fn run_selection_local_lookup_preserves_precedence_and_explicit_failure() {
let root = tempfile::tempdir().unwrap();
let user = root.path().join("user");
let project = root.path().join("project");
let checkout = root.path().join("project/checkout");
write_workflow(&user, "review");
write_workflow(&project, ".fabro/workflows/review");
write_workflow(&checkout, ".fabro/workflows/review");
std::fs::write(project.join(".fabro/project.toml"), "_version = 1\n").unwrap();
git2::Repository::init(&checkout).unwrap();
let selected = WorkflowSelection::Local("review".into());
let interruption = Interruption::new(false);
for (cwd, expected_root) in [
(checkout.as_path(), checkout.as_path()),
(project.as_path(), project.as_path()),
(root.path(), user.as_path()),
] {
let ResolvedWorkflow::Local(package) =
workflow(&selected, cwd, Some(&user), &interruption)
.await
.unwrap()
else {
panic!("local package");
};
assert_eq!(package.source_root(), expected_root.canonicalize().unwrap());
}
assert!(
workflow(
&WorkflowSelection::Local("missing.toml".into()),
&checkout,
Some(&user),
&interruption
)
.await
.is_err()
);
}
}

View file

@ -1,10 +1,8 @@
use std::convert::TryFrom;
use chrono::{DateTime, Utc};
use fabro_agent::Error as AgentError;
use fabro_types::{BilledModelUsage, EventBody, LlmOutputKind, RunEvent};
use fabro_types::{BilledModelUsage, EventBody, RunEvent};
use fabro_util::{error, text};
use fabro_workflow::event::RunNoticeLevel;
use pebble_coding_agent::events::{CodingEvent, ErrorKind as AgentErrorKind, LlmOutputKind};
use serde_json::Value;
#[derive(Debug, Clone)]
@ -15,13 +13,13 @@ pub(super) struct ProgressUsage {
}
impl ProgressUsage {
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option<Self> {
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Self {
let tokens = usage.tokens();
Some(Self {
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?,
Self {
input_tokens: tokens.input,
output_tokens: tokens.billable_output(),
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
})
}
}
pub(super) fn total_tokens(&self) -> u64 {
@ -53,8 +51,6 @@ pub(super) enum ProgressEvent {
provider: String,
duration_ms: u64,
name: Option<String>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<String>,
},
SandboxFailed {
@ -255,28 +251,13 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
provider: props.provider.clone(),
duration_ms: props.duration_ms,
name: props.name.clone(),
cpu: props.cpu,
memory: props.memory,
url: props.url.clone(),
}),
EventBody::SandboxFailed(props) => Some(ProgressEvent::SandboxFailed {
provider: props.provider.clone(),
error: props.error.clone(),
}),
EventBody::SnapshotPulling(props) => Some(ProgressEvent::SnapshotPulling {
name: props.name.clone(),
}),
EventBody::SnapshotCreating(props) => Some(ProgressEvent::SnapshotCreating {
name: props.name.clone(),
}),
EventBody::SnapshotReady(props) => Some(ProgressEvent::SnapshotReady {
name: props.name.clone(),
duration_ms: props.duration_ms,
}),
EventBody::SnapshotFailed(props) => Some(ProgressEvent::SnapshotFailed {
name: props.name.clone(),
error: props.error.clone(),
}),
EventBody::SandboxDriver { event, .. } => driver_progress_event(event),
EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady {
ssh_command: props.ssh_command.clone(),
}),
@ -313,10 +294,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
name: node_label,
timing: props.timing,
status: props.status.to_string(),
usage: props
.billing
.as_ref()
.and_then(ProgressUsage::from_stage_usage),
usage: props.billing.as_ref().map(ProgressUsage::from_stage_usage),
}),
EventBody::StageFailed(props) => Some(ProgressEvent::StageFailed {
node_id,
@ -346,107 +324,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
status: props.status,
}),
EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted),
EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage {
stage_node_id: node_id,
model: props.model.model_id.to_string(),
root_session: stored.parent_session_id.is_none(),
}),
EventBody::AgentToolStarted(props) => Some(ProgressEvent::ToolCallStarted {
stage_node_id: node_id,
tool_name: props.tool_name.clone(),
tool_call_id: props.tool_call_id.clone(),
arguments: props.arguments.clone(),
timestamp: Some(stored.ts),
}),
EventBody::AgentToolCompleted(props) => Some(ProgressEvent::ToolCallCompleted {
stage_node_id: node_id,
tool_call_id: props.tool_call_id.clone(),
is_error: props.is_error,
duration_ms: None,
timestamp: Some(stored.ts),
}),
EventBody::AgentWarning(props) if props.kind == "context_window" => {
let usage_percent = props
.details
.as_object()
.and_then(|details| details.get("usage_percent"))
.and_then(Value::as_u64)
.unwrap_or(0);
Some(ProgressEvent::ContextWindowWarning {
stage_node_id: node_id,
usage_percent,
})
}
EventBody::AgentCompactionStarted(_) => Some(ProgressEvent::CompactionStarted {
stage_node_id: node_id,
}),
EventBody::AgentCompactionCompleted(props) => Some(ProgressEvent::CompactionCompleted {
stage_node_id: node_id,
original_turn_count: props.original_turn_count as u64,
preserved_turn_count: props.preserved_turn_count as u64,
tracked_file_count: props.tracked_file_count as u64,
}),
EventBody::AgentError(props) => match display_compaction_error(&props.error) {
Some(error) => Some(ProgressEvent::CompactionFailed {
stage_node_id: node_id,
error,
root_session: stored.parent_session_id.is_none(),
}),
None if stored.parent_session_id.is_none() => Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
}),
None => None,
},
EventBody::AgentLlmStarted(props) if stored.parent_session_id.is_none() => {
Some(ProgressEvent::LlmRequestStarted {
stage_node_id: node_id,
model: props.requested_model.model_id.to_string(),
})
}
EventBody::AgentLlmFirstOutput(props) if stored.parent_session_id.is_none() => {
Some(ProgressEvent::LlmFirstOutput {
stage_node_id: node_id,
kind: props.kind,
})
}
EventBody::AgentLlmRetry(props) if stored.parent_session_id.is_none() => {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "Retry delays are represented as small non-negative millisecond values."
)]
let delay_ms = (props.delay_secs * 1000.0) as u64;
Some(ProgressEvent::LlmRetry {
stage_node_id: node_id,
model: props.model.clone(),
attempt: props.attempt as u64,
delay_ms,
error: display_value(&props.error).unwrap_or_else(|| "unknown error".to_string()),
})
}
EventBody::AgentRoundInterrupted(_) if stored.parent_session_id.is_none() => {
Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
})
}
EventBody::AgentSubSpawned(props) => Some(ProgressEvent::SubagentStarted {
stage_node_id: node_id,
agent_id: props.agent_id.clone(),
task: props.task.clone(),
generation: props.generation,
}),
EventBody::AgentSubTurnStarted(props) => Some(ProgressEvent::SubagentStarted {
stage_node_id: node_id,
agent_id: props.agent_id.clone(),
task: props.task.clone(),
generation: props.generation,
}),
EventBody::AgentSubCompleted(props) => Some(ProgressEvent::SubagentCompleted {
stage_node_id: node_id,
agent_id: props.agent_id.clone(),
success: props.success,
turns_used: props.turns_used as u64,
}),
EventBody::Agent(props) => agent_progress_event(node_id, stored, props.coding_event()),
EventBody::EdgeSelected(props) => Some(ProgressEvent::EdgeSelected {
from_node: props.from_node.clone(),
to_node: props.to_node.clone(),
@ -498,47 +376,218 @@ pub(super) fn from_json_line(line: &str) -> Option<ProgressEvent> {
from_run_event(&stored)
}
fn display_compaction_error(value: &Value) -> Option<String> {
let error = serde_json::from_value::<AgentError>(value.clone()).ok()?;
match error {
AgentError::Compaction(error) => Some(error.to_string()),
/// The progress line for one coding agent event, if the terminal shows it.
///
/// Inference brackets and interrupts are tracked for the root session only:
/// a subagent's rounds must not move the stage's live line.
fn agent_progress_event(
node_id: String,
stored: &RunEvent,
event: &CodingEvent,
) -> Option<ProgressEvent> {
let root_session = stored.parent_session_id.is_none();
match event {
CodingEvent::AssistantMessage { model, .. } => Some(ProgressEvent::AssistantMessage {
stage_node_id: node_id,
model: model.clone(),
root_session,
}),
CodingEvent::ToolCallStarted {
tool_name,
tool_call_id,
arguments,
} => Some(ProgressEvent::ToolCallStarted {
stage_node_id: node_id,
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: arguments.clone(),
timestamp: Some(stored.ts),
}),
CodingEvent::ToolCallCompleted {
tool_call_id,
is_error,
..
} => Some(ProgressEvent::ToolCallCompleted {
stage_node_id: node_id,
tool_call_id: tool_call_id.clone(),
is_error: *is_error,
duration_ms: None,
timestamp: Some(stored.ts),
}),
CodingEvent::Warning { kind, details, .. } if kind == "context_window" => {
let usage_percent = details
.as_object()
.and_then(|details| details.get("usage_percent"))
.and_then(Value::as_u64)
.unwrap_or(0);
Some(ProgressEvent::ContextWindowWarning {
stage_node_id: node_id,
usage_percent,
})
}
CodingEvent::CompactionStarted { .. } => Some(ProgressEvent::CompactionStarted {
stage_node_id: node_id,
}),
CodingEvent::CompactionCompleted {
original_turn_count,
preserved_turn_count,
tracked_file_count,
..
} => Some(ProgressEvent::CompactionCompleted {
stage_node_id: node_id,
original_turn_count: *original_turn_count as u64,
preserved_turn_count: *preserved_turn_count as u64,
tracked_file_count: *tracked_file_count as u64,
}),
CodingEvent::CompactionFailed { error, .. } => Some(ProgressEvent::CompactionFailed {
stage_node_id: node_id,
error: error.message.clone(),
root_session,
}),
CodingEvent::Error { error } if error.kind == AgentErrorKind::Compaction => {
Some(ProgressEvent::CompactionFailed {
stage_node_id: node_id,
error: error.message.clone(),
root_session,
})
}
CodingEvent::Error { .. } if root_session => Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
}),
CodingEvent::LlmRequestStarted { requested_model } if root_session => {
Some(ProgressEvent::LlmRequestStarted {
stage_node_id: node_id,
model: requested_model.clone(),
})
}
CodingEvent::LlmFirstOutput { kind } if root_session => {
Some(ProgressEvent::LlmFirstOutput {
stage_node_id: node_id,
kind: *kind,
})
}
CodingEvent::LlmRetry {
model,
attempt,
delay_secs,
error,
..
} if root_session => {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "Retry delays are represented as small non-negative millisecond values."
)]
let delay_ms = (delay_secs * 1000.0) as u64;
Some(ProgressEvent::LlmRetry {
stage_node_id: node_id,
model: model.clone(),
attempt: *attempt as u64,
delay_ms,
error: error.message.clone(),
})
}
CodingEvent::RoundInterrupted { .. } if root_session => {
Some(ProgressEvent::LlmRequestFinished {
stage_node_id: node_id,
})
}
CodingEvent::SubAgentSpawned {
agent_id,
task,
generation,
..
}
| CodingEvent::SubAgentTurnStarted {
agent_id,
task,
generation,
..
} => Some(ProgressEvent::SubagentStarted {
stage_node_id: node_id,
agent_id: agent_id.clone(),
task: task.clone(),
generation: *generation,
}),
CodingEvent::SubAgentCompleted {
agent_id,
success,
turns_used,
..
} => Some(ProgressEvent::SubagentCompleted {
stage_node_id: node_id,
agent_id: agent_id.clone(),
success: *success,
turns_used: *turns_used as u64,
}),
_ => None,
}
}
fn display_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(value) => Some(value.clone()),
Value::Object(map) => map
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| {
map.get("detail")
.and_then(Value::as_object)
.and_then(|detail| detail.get("message"))
.and_then(Value::as_str)
.map(str::to_owned)
/// The setup progress a sandbox driver event stands for: the image pull
/// inside the sandbox's create, or a snapshot build. Every other driver
/// event is stored on the run but renders nothing here.
fn driver_progress_event(event: &sandbox_driver::Event) -> Option<ProgressEvent> {
use sandbox_driver::{Action, EventBody as Body, EventSubject, ProgressCode};
match (&event.subject, &event.body) {
(
EventSubject::Sandbox { .. },
Body::OperationProgress {
action: Action::Create,
progress,
},
) if progress.code.as_str() == ProgressCode::IMAGE_PULL => {
Some(ProgressEvent::SnapshotPulling {
name: pulled_image_name(progress.message.as_deref()),
})
.or_else(|| {
map.get("data")
.and_then(Value::as_object)
.and_then(|detail| detail.get("message"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.or_else(|| map.get("data").and_then(Value::as_str).map(str::to_owned))
.or_else(|| Some(value.to_string())),
_ => Some(value.to_string()),
}
(EventSubject::Snapshot { id, name }, body) => {
let name = name
.clone()
.or_else(|| id.as_ref().map(ToString::to_string))
.unwrap_or_default();
match body {
Body::OperationStarted {
action: Action::Create,
} => Some(ProgressEvent::SnapshotCreating { name }),
Body::OperationCompleted {
action: Action::Create,
duration,
} => Some(ProgressEvent::SnapshotReady {
name,
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
}),
Body::OperationFailed {
action: Action::Create,
error,
..
} => Some(ProgressEvent::SnapshotFailed {
name,
error: error.message.clone(),
}),
_ => None,
}
}
_ => None,
}
}
/// The image an image pull progress report names. The Docker provider
/// says `pulling image <reference>`; the reference alone reads better.
fn pulled_image_name(message: Option<&str>) -> String {
let message = message.unwrap_or("image");
message
.strip_prefix("pulling image ")
.unwrap_or(message)
.to_owned()
}
#[cfg(test)]
mod tests {
use fabro_agent::AgentEvent;
use fabro_types::{MetadataSnapshotFailureKind, MetadataSnapshotPhase, fixtures};
use fabro_workflow::event::{Event, RunNoticeCode, to_run_event};
use fabro_workflow::event::{Event, RunNoticeCode, SandboxLifecycle, to_run_event};
use pebble_coding_agent::events::CodingAgentEvent;
use super::*;
@ -633,16 +682,17 @@ mod tests {
#[test]
fn round_trip_agent_tool_call() {
let event = Event::Agent {
stage: "code".into(),
visit: 1,
event: AgentEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
},
session_id: None,
parent_session_id: None,
tool_call_id: None,
stage: "code".into(),
visit: 1,
event: CodingAgentEvent::new(
"ses_root",
CodingEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
},
std::time::SystemTime::UNIX_EPOCH,
),
};
let stored = to_run_event(&fixtures::RUN_1, &event);
@ -695,10 +745,17 @@ mod tests {
"node_id": "code",
"node_label": "code",
"properties": {
"tool_name": "read_file",
"tool_call_id": "tc1",
"arguments": {"path": "src/main.rs"},
"visit": 1
"stage": "code",
"visit": 1,
"session_id": "ses_root",
"timestamp": "2026-03-30T12:00:00.000Z",
"event": {
"ToolCallStarted": {
"tool_name": "read_file",
"tool_call_id": "tc1",
"arguments": {"path": "src/main.rs"}
}
}
}
})
.to_string(),
@ -713,11 +770,21 @@ mod tests {
"node_id": "code",
"node_label": "code",
"properties": {
"tool_name": "read_file",
"tool_call_id": "tc1",
"output": {"ok": true},
"is_error": false,
"visit": 1
"stage": "code",
"visit": 1,
"session_id": "ses_root",
"timestamp": "2026-03-30T12:00:00.500Z",
"event": {
"ToolCallCompleted": {
"tool_name": "read_file",
"tool_call_id": "tc1",
"output": {"ok": true},
"is_error": false,
"output_bytes_observed": 11,
"output_bytes_retained": 11,
"output_bytes_omitted": 0
}
}
}
})
.to_string(),
@ -748,12 +815,10 @@ mod tests {
#[test]
fn round_trip_sandbox_ready() {
let event = Event::Sandbox {
event: fabro_agent::SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 2500,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: Some("https://example.test".into()),
},
};
@ -774,7 +839,7 @@ mod tests {
#[test]
fn round_trip_sandbox_failed() {
let event = Event::Sandbox {
event: fabro_agent::SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -791,32 +856,76 @@ mod tests {
));
}
#[test]
fn round_trip_snapshot_lifecycle_events() {
let pulling = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotPulling {
name: "buildpack-deps:noble".into(),
},
});
let creating = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotCreating {
name: "fabro-v9".into(),
},
});
let ready = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 1200,
},
});
let failed = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotFailed {
name: "fabro-v9".into(),
error: "build failed".into(),
causes: Vec::new(),
},
});
fn driver_event(value: serde_json::Value) -> Event {
Event::SandboxDriver {
event: serde_json::from_value(value).expect("a driver event"),
}
}
#[test]
fn round_trip_driver_events_that_render_setup_progress() {
let pulling = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox"},
"type": "operation_progress",
"action": "create",
"progress": {"code": "image.pull", "message": "pulling image buildpack-deps:noble"}
})),
);
let creating = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 2},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_started",
"action": "create"
})),
);
let ready = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 3},
"occurred_at": "2026-01-01T00:00:01Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_completed",
"action": "create",
"duration": {"secs": 1, "nanos": 200_000_000}
})),
);
let failed = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 4},
"occurred_at": "2026-01-01T00:00:02Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_failed",
"action": "create",
"duration": {"secs": 2, "nanos": 0},
"error": {"kind": "provider", "message": "build failed", "retryable": false, "causes": []}
})),
);
let stopped = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 5},
"occurred_at": "2026-01-01T00:00:03Z",
"provider": "docker",
"subject": {"type": "sandbox", "id": "c1"},
"type": "operation_completed",
"action": "stop",
"duration": {"secs": 0, "nanos": 0}
})),
);
assert_eq!(pulling.event_name(), "sandbox.create.progress");
assert!(matches!(
from_run_event(&pulling).unwrap(),
ProgressEvent::SnapshotPulling { name } if name == "buildpack-deps:noble"
@ -828,13 +937,18 @@ mod tests {
assert!(matches!(
from_run_event(&ready).unwrap(),
ProgressEvent::SnapshotReady { name, duration_ms }
if name == "buildpack-deps:noble" && duration_ms == 1200
if name == "fabro-v9" && duration_ms == 1200
));
assert!(matches!(
from_run_event(&failed).unwrap(),
ProgressEvent::SnapshotFailed { name, error }
if name == "fabro-v9" && error == "build failed"
));
assert_eq!(stopped.event_name(), "sandbox.stop.completed");
assert!(
from_run_event(&stopped).is_none(),
"a stop is stored on the run but renders no setup progress"
);
}
#[test]

View file

@ -140,8 +140,6 @@ impl ProgressUI {
provider,
duration_ms,
name,
cpu,
memory,
url,
} => {
self.setup.on_sandbox_ready(
@ -149,8 +147,6 @@ impl ProgressUI {
&provider,
duration_ms,
name.as_deref(),
cpu,
memory,
url.as_deref(),
);
}
@ -457,16 +453,21 @@ mod tests {
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_llm::types::TokenCounts;
use fabro_model::{Catalog, ModelRef, ProviderId};
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind,
StageId, fixtures,
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelRef, ParallelBranchId,
SandboxProviderKind, StageId, fixtures,
};
use fabro_workflow::event::{
Event, RunNoticeLevel, SandboxLifecycle, to_run_event, to_run_event_at,
};
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
use fabro_workflow::outcome::billed_model_usage_from_llm;
use lithos_llm::catalog::{ModelId, builtin};
use lithos_llm::types::TokenCounts;
use pebble_coding_agent::events::{
CodingAgentEvent, CodingEvent, CompactionReason, ErrorData as AgentErrorData,
ErrorKind as AgentErrorKind, TokenUsage,
};
use super::*;
use crate::commands::run::run_progress::stage_display::ToolCallStatus;
@ -506,6 +507,55 @@ mod tests {
.expect("valid utf-8")
}
fn driver_event(value: serde_json::Value) -> Event {
Event::SandboxDriver {
event: serde_json::from_value(value).expect("a driver event"),
}
}
/// A snapshot build reported by the driver: started, or completed after
/// `secs`.
fn snapshot_build_event(name: &str, kind: &str, secs: Option<u64>) -> Event {
let mut value = serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": name},
"type": kind,
"action": "create"
});
if let Some(secs) = secs {
value["duration"] = serde_json::json!({"secs": secs, "nanos": 0});
}
driver_event(value)
}
fn snapshot_build_failed_event(name: &str, error: &str) -> Event {
driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "snapshot", "name": name},
"type": "operation_failed",
"action": "create",
"duration": {"secs": 1, "nanos": 0},
"error": {"kind": "provider", "message": error, "retryable": false, "causes": []}
}))
}
/// The Docker provider pulling the sandbox's image inside its create.
fn image_pull_event(image: &str) -> Event {
driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox"},
"type": "operation_progress",
"action": "create",
"progress": {"code": "image.pull", "message": format!("pulling image {image}")}
}))
}
fn emit(ui: &mut ProgressUI, event: Event) {
let stored = to_run_event(&fixtures::RUN_1, &event);
ui.handle_event(&stored);
@ -534,25 +584,20 @@ mod tests {
});
}
fn agent_event(stage: &str, event: AgentEvent) -> Event {
fn agent_event(stage: &str, event: CodingEvent) -> Event {
Event::Agent {
stage: stage.into(),
visit: 1,
event,
session_id: None,
parent_session_id: None,
tool_call_id: None,
event: CodingAgentEvent::new("ses_root", event, std::time::SystemTime::UNIX_EPOCH),
}
}
fn child_agent_event(stage: &str, event: AgentEvent) -> Event {
fn child_agent_event(stage: &str, event: CodingEvent) -> Event {
Event::Agent {
stage: stage.into(),
visit: 1,
event,
session_id: Some("ses_child".into()),
parent_session_id: Some("ses_root".into()),
tool_call_id: None,
event: CodingAgentEvent::new("ses_child", event, std::time::SystemTime::UNIX_EPOCH)
.with_parent_session_id("ses_root"),
}
}
@ -569,16 +614,12 @@ mod tests {
}
}
fn assistant_event(model: &str, text: &str) -> AgentEvent {
AgentEvent::AssistantMessage {
fn assistant_event(model: &str, text: &str) -> CodingEvent {
CodingEvent::AssistantMessage {
text: text.into(),
model: ModelRef {
provider: ProviderId::openai(),
model_id: model.into(),
speed: None,
},
usage: TokenCounts::default(),
cost_usd: None,
model: model.into(),
usage: TokenUsage::default(),
cost_usd_micros: None,
cost_source: None,
tool_call_count: 0,
context_window: None,
@ -595,12 +636,8 @@ mod tests {
}
fn llm_request_started(stage: &str, model: &str) -> Event {
agent_event(stage, AgentEvent::LlmRequestStarted {
requested_model: ModelRef {
provider: ProviderId::anthropic(),
model_id: model.into(),
speed: None,
},
agent_event(stage, CodingEvent::LlmRequestStarted {
requested_model: model.into(),
})
}
@ -615,15 +652,11 @@ mod tests {
suggested_next_ids: Vec::new(),
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,
&fabro_llm::test_support::test_catalog(),
&ModelRef::new(builtin::openai(), ModelId::new("gpt-5.4")),
TokenCounts {
input: 1200,
output: 300,
..TokenCounts::default()
},
)
@ -729,9 +762,10 @@ mod tests {
emit(
&mut ui,
agent_event("s1", AgentEvent::CompactionStarted {
agent_event("s1", CodingEvent::CompactionStarted {
estimated_tokens: 5000,
context_window_size: 8000,
reason: CompactionReason::Threshold,
}),
);
assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());
@ -740,11 +774,12 @@ mod tests {
emit(
&mut ui,
agent_event("s1", AgentEvent::CompactionCompleted {
agent_event("s1", CodingEvent::CompactionCompleted {
original_turn_count: 20,
preserved_turn_count: 6,
summary_token_estimate: 500,
tracked_file_count: 3,
reason: CompactionReason::Threshold,
}),
);
assert!(ui.stage.active_stages["s1"].compaction_bar.is_none());
@ -757,19 +792,22 @@ mod tests {
emit(&mut ui, stage_started("s1", "Build"));
emit(
&mut ui,
agent_event("s1", AgentEvent::CompactionStarted {
agent_event("s1", CodingEvent::CompactionStarted {
estimated_tokens: 5000,
context_window_size: 8000,
reason: CompactionReason::Threshold,
}),
);
assert!(ui.stage.active_stages["s1"].compaction_bar.is_some());
emit(
&mut ui,
agent_event("s1", AgentEvent::Error {
error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary {
summarized_turn_count: 14,
}),
agent_event("s1", CodingEvent::Error {
error: AgentErrorData::new(
AgentErrorKind::Compaction,
"generated summary was empty after trimming; refused to replace 14 turns and \
left history intact",
),
}),
);
@ -783,10 +821,12 @@ mod tests {
emit(
&mut ui,
agent_event("s1", AgentEvent::Error {
error: fabro_agent::Error::Compaction(fabro_agent::CompactionError::EmptySummary {
summarized_turn_count: 14,
}),
agent_event("s1", CodingEvent::Error {
error: AgentErrorData::new(
AgentErrorKind::Compaction,
"generated summary was empty after trimming; refused to replace 14 turns and \
left history intact",
),
}),
);
@ -813,7 +853,7 @@ mod tests {
emit(
&mut ui,
agent_event("s1", AgentEvent::LlmFirstOutput {
agent_event("s1", CodingEvent::LlmFirstOutput {
kind: fabro_types::LlmOutputKind::ToolCall,
}),
);
@ -839,22 +879,19 @@ mod tests {
emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
emit(
&mut ui,
agent_event("s1", AgentEvent::LlmFirstOutput {
agent_event("s1", CodingEvent::LlmFirstOutput {
kind: fabro_types::LlmOutputKind::Text,
}),
);
emit(
&mut ui,
agent_event("s1", AgentEvent::LlmRetry {
agent_event("s1", CodingEvent::LlmRetry {
provider: "anthropic".into(),
model: "claude-fable-5".into(),
attempt: 1,
delay_secs: 0.1,
phase: fabro_types::LlmRetryPhase::Consume,
error: fabro_llm::Error::Configuration {
message: "retry".into(),
source: None,
},
error: AgentErrorData::new(AgentErrorKind::Llm, "retry"),
}),
);
@ -874,7 +911,7 @@ mod tests {
emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
emit(
&mut ui,
agent_event("s1", AgentEvent::RoundInterrupted { generation: 1 }),
agent_event("s1", CodingEvent::RoundInterrupted { generation: 1 }),
);
assert!(ui.stage.active_stages["s1"].inference_bar.is_none());
@ -888,7 +925,7 @@ mod tests {
emit(&mut ui, llm_request_started("s1", "claude-fable-5"));
emit(
&mut ui,
child_agent_event("s1", AgentEvent::LlmFirstOutput {
child_agent_event("s1", CodingEvent::LlmFirstOutput {
kind: fabro_types::LlmOutputKind::ToolCall,
}),
);
@ -920,7 +957,7 @@ mod tests {
stage_started("code", "Code"),
Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProviderKind::Daytona,
provider: SandboxProviderKind::DAYTONA,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
@ -932,7 +969,7 @@ mod tests {
image: None,
snapshot: None,
},
agent_event("code", AgentEvent::ToolCallStarted {
agent_event("code", CodingEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({
@ -959,29 +996,26 @@ mod tests {
max_attempts: 3,
delay_ms: 1500,
},
agent_event("code", AgentEvent::Warning {
agent_event("code", CodingEvent::Warning {
kind: "context_window".into(),
message: "high usage".into(),
details: serde_json::json!({"usage_percent": 92}),
}),
agent_event("code", AgentEvent::LlmRetry {
agent_event("code", CodingEvent::LlmRetry {
provider: "openai".into(),
model: "gpt-5-mini".into(),
attempt: 2,
delay_secs: 1.5,
phase: fabro_types::LlmRetryPhase::Open,
error: fabro_llm::Error::Configuration {
message: "busy".into(),
source: None,
},
error: AgentErrorData::new(AgentErrorKind::Llm, "busy"),
}),
agent_event("code", AgentEvent::SubAgentSpawned {
agent_event("code", CodingEvent::SubAgentSpawned {
agent_id: "a1".into(),
depth: 1,
task: "review recent changes".into(),
generation: 1,
}),
agent_event("code", AgentEvent::SubAgentCompleted {
agent_event("code", CodingEvent::SubAgentCompleted {
agent_id: "a1".into(),
depth: 1,
generation: 1,
@ -1020,7 +1054,7 @@ mod tests {
emit(&mut ui, assistant_message("plan", "gpt-5-mini"));
emit(
&mut ui,
agent_event("plan", AgentEvent::ToolCallStarted {
agent_event("plan", CodingEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
@ -1028,10 +1062,12 @@ mod tests {
);
emit(
&mut ui,
agent_event("plan", AgentEvent::ToolCallCompleted {
agent_event("plan", CodingEvent::ToolCallCompleted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
metadata: pebble_agent::ToolOutputMetadata::default(),
error_kind: None,
is_error: false,
output_bytes_observed: 11,
output_bytes_retained: 11,
@ -1040,7 +1076,7 @@ mod tests {
);
emit(&mut ui, stage_completed("plan", "Plan"));
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan 5s");
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.01 5s");
}
#[test]
@ -1048,17 +1084,15 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "daytona".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 2500,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: None,
},
});
@ -1077,12 +1111,12 @@ mod tests {
duration_ms: 600,
}),
);
insta::assert_snapshot!(rendered(&buffer), @r"
Sandbox: daytona (ready in 2s)
sandbox-1 (4 cpu, 8 GB)
ssh daytona@example
Setup: 2 commands (8s)
CLI: gh (installed, 600ms)
insta::assert_snapshot!(rendered(&buffer), @"
Sandbox: daytona (ready in 2s)
sandbox-1
ssh daytona@example
Setup: 2 commands (8s)
CLI: gh (installed, 600ms)
");
}
@ -1091,36 +1125,31 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "daytona".into(),
},
});
emit(
&mut ui,
snapshot_build_event("fabro-v9-test", "operation_started", None),
);
emit(
&mut ui,
snapshot_build_event("fabro-v9-test", "operation_completed", Some(210)),
);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotCreating {
name: "fabro-v9-test".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "fabro-v9-test".into(),
duration_ms: 210_000,
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 212_000,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: None,
},
});
insta::assert_snapshot!(rendered(&buffer), @r"
Sandbox: building fabro-v9-test...
Sandbox: daytona (ready in 3m32s)
sandbox-1 (4 cpu, 8 GB)
insta::assert_snapshot!(rendered(&buffer), @"
Sandbox: building fabro-v9-test...
Sandbox: daytona (ready in 3m32s)
sandbox-1
");
}
@ -1129,28 +1158,16 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(&mut ui, image_pull_event("buildpack-deps:noble"));
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotPulling {
name: "buildpack-deps:noble".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 8_200,
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 9_000,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1166,17 +1183,15 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 20,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1189,19 +1204,16 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(
&mut ui,
snapshot_build_failed_event("buildpack-deps:noble", "pull failed"),
);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotFailed {
name: "buildpack-deps:noble".into(),
error: "pull failed".into(),
causes: Vec::new(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -1220,27 +1232,23 @@ mod tests {
let mut ui = ProgressUI::new(true, false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 10,
},
});
emit(
&mut ui,
snapshot_build_event("buildpack-deps:noble", "operation_completed", Some(0)),
);
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 20,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1252,14 +1260,14 @@ mod tests {
let mut ui = ProgressUI::new(true, false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -1276,7 +1284,7 @@ mod tests {
emit(&mut ui, stage_started("code", "Code"));
emit(&mut ui, Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProviderKind::Daytona,
provider: SandboxProviderKind::DAYTONA,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
@ -1290,7 +1298,7 @@ mod tests {
});
emit(
&mut ui,
agent_event("code", AgentEvent::ToolCallStarted {
agent_event("code", CodingEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({
@ -1320,7 +1328,7 @@ mod tests {
});
emit(
&mut ui,
agent_event("code", AgentEvent::Warning {
agent_event("code", CodingEvent::Warning {
kind: "context_window".into(),
message: "high usage".into(),
details: serde_json::json!({"usage_percent": 92}),
@ -1328,21 +1336,18 @@ mod tests {
);
emit(
&mut ui,
agent_event("code", AgentEvent::LlmRetry {
agent_event("code", CodingEvent::LlmRetry {
provider: "openai".into(),
model: "gpt-5-mini".into(),
attempt: 2,
delay_secs: 1.5,
phase: fabro_types::LlmRetryPhase::Open,
error: fabro_llm::Error::Configuration {
message: "busy".into(),
source: None,
},
error: AgentErrorData::new(AgentErrorKind::Llm, "busy"),
}),
);
emit(
&mut ui,
agent_event("code", AgentEvent::SubAgentSpawned {
agent_event("code", CodingEvent::SubAgentSpawned {
agent_id: "a1".into(),
depth: 1,
task: "review recent changes".into(),
@ -1351,7 +1356,7 @@ mod tests {
);
emit(
&mut ui,
agent_event("code", AgentEvent::SubAgentCompleted {
agent_event("code", CodingEvent::SubAgentCompleted {
agent_id: "a1".into(),
depth: 1,
generation: 1,
@ -1361,7 +1366,7 @@ mod tests {
);
emit(
&mut ui,
agent_event("code", AgentEvent::SubAgentTurnStarted {
agent_event("code", CodingEvent::SubAgentTurnStarted {
agent_id: "a1".into(),
depth: 1,
task: "fix the review findings".into(),
@ -1370,7 +1375,7 @@ mod tests {
);
emit(
&mut ui,
agent_event("code", AgentEvent::SubAgentCompleted {
agent_event("code", CodingEvent::SubAgentCompleted {
agent_id: "a1".into(),
depth: 1,
generation: 2,
@ -1399,7 +1404,7 @@ mod tests {
subagent[a1] (2 turns)
[1/1] bun install 2s
Setup: 1 command (2s)
Code 5s (1 turns, 0 tools, 1.5k toks)
Code $0.01 5s (1 turns, 0 tools, 1.5k toks)
"#);
}
@ -1561,7 +1566,7 @@ mod tests {
.unwrap();
let tool_started = serde_json::to_string(&to_run_event_at(
&fixtures::RUN_1,
&agent_event("code", AgentEvent::ToolCallStarted {
&agent_event("code", CodingEvent::ToolCallStarted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
@ -1572,10 +1577,12 @@ mod tests {
.unwrap();
let tool_completed = serde_json::to_string(&to_run_event_at(
&fixtures::RUN_1,
&agent_event("code", AgentEvent::ToolCallCompleted {
&agent_event("code", CodingEvent::ToolCallCompleted {
tool_name: "read_file".into(),
tool_call_id: "tc1".into(),
output: serde_json::json!({"ok": true}),
metadata: pebble_agent::ToolOutputMetadata::default(),
error_kind: None,
is_error: false,
output_bytes_observed: 11,
output_bytes_retained: 11,

View file

@ -56,20 +56,10 @@ impl SetupDisplay {
provider: &str,
duration_ms: u64,
name: Option<&str>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<&str>,
) {
let dur = format_duration_ms(duration_ms);
let detail = match (name, cpu, memory) {
(Some(name), Some(cpu), Some(memory)) => Some(format!(
"{name} ({} cpu, {} GB)",
styles::format_number(cpu),
styles::format_number(memory)
)),
(Some(name), _, _) => Some(name.to_string()),
_ => None,
};
let detail = name.map(str::to_string);
if renderer.is_tty() {
let display_provider = match url {

View file

@ -557,6 +557,9 @@ impl StageDisplay {
LlmOutputKind::Reasoning => "reasoning",
LlmOutputKind::Text => "writing",
LlmOutputKind::ToolCall => "calling tools",
// `LlmOutputKind` is non-exhaustive; a kind this build does not
// know is still output.
_ => "responding",
};
bar.set_message(format!("\u{27f3} model request: {activity}\u{2026}"));
}

View file

@ -68,19 +68,6 @@ pub(super) fn terminal_hyperlink(url: &str, text: &str) -> String {
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
}
pub(super) fn format_number(n: f64) -> String {
if (n - n.round()).abs() < f64::EPSILON {
#[allow(
clippy::cast_possible_truncation,
reason = "Whole-number display intentionally narrows to i64 for formatting."
)]
let i = n as i64;
format!("{i}")
} else {
format!("{n:.1}")
}
}
pub(super) fn truncate(s: &str, max: usize) -> String {
let single_line = s.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.len() > max {

View file

@ -5,25 +5,19 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_client::ServerTarget;
use fabro_config::user::active_settings_path;
use fabro_config::{ServerSettingsBuilder, Storage, load_llm_catalog_settings};
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{
AnswerSubmission, ControlInterviewer, WORKER_CONTROL_INVALID_CURSOR_REASON,
WORKER_CONTROL_PONG_TIMEOUT_REASON, WORKER_CONTROL_WS_LIVENESS_TIMEOUT,
WORKER_CONTROL_WS_PING_INTERVAL, WorkerControlDeliveryFrame, WorkerControlEnvelope,
WorkerControlMessage,
};
use fabro_model::Catalog;
use fabro_server::run_tool_manifest;
use fabro_manifest::SuppliedWorkflowVersionPackager;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
WorkflowSettings,
};
use fabro_types::{ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId};
use fabro_vault::{SecretStore, Vault};
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@ -51,8 +45,8 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungsten
use tokio_util::sync::CancellationToken;
use crate::args::RunWorkerMode;
use crate::server_client;
use crate::shared::github::build_github_credentials;
use crate::{command_context, server_client};
const RUN_STORE_RETRY_DELAYS: [Duration; 3] = [
Duration::from_millis(50),
@ -92,11 +86,8 @@ pub(crate) async fn execute(
.await
.with_context(|| format!("failed to load run state for {run_id}"))?;
let run_spec = &run_state.spec;
let llm_catalog_settings =
load_llm_catalog_settings(None).context("failed to load worker LLM catalog settings")?;
let catalog = Arc::new(
Catalog::from_builtin_with_overrides(&llm_catalog_settings)
.context("failed to build worker LLM catalog")?,
command_context::load_cli_catalog().context("failed to build worker LLM catalog")?,
);
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
run_id,
@ -104,13 +95,7 @@ pub(crate) async fn execute(
worker_token.to_owned(),
)));
let fabro_run_tools = if fabro_run_tools_enabled_from_worker_token(worker_token) {
build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
)
build_fabro_run_tool_services(worker_token, client.clone_for_reuse(), run_id)
} else {
None
};
@ -139,8 +124,11 @@ pub(crate) async fn execute(
let vault = load_worker_vault(&storage_dir).await?;
let github_app = {
let vault_guard = vault.read().await;
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
maybe_build_github_credentials(run_spec, &vault_guard)?
};
let sandbox_providers = ServerSettingsBuilder::load_default()
.map(|settings| settings.server.sandbox.providers)
.unwrap_or_default();
let services = StartServices {
run_id,
cancel_token: cancel_token.clone(),
@ -169,6 +157,7 @@ pub(crate) async fn execute(
.resolve_integration()
.context("failed to resolve github integration")?,
vault,
sandbox_providers,
catalog,
on_node: None,
registry_override: None,
@ -232,35 +221,18 @@ fn build_fabro_run_tool_services(
worker_token: &str,
client: fabro_client::Client,
current_run_id: RunId,
source_directory: Option<&str>,
run_dir: &Path,
) -> Option<FabroRunToolServices> {
if worker_token.trim().is_empty() {
return None;
}
let backend = ClientBackend::new(Arc::new(client))
.with_manifest_builder(Arc::new(WorkerRunManifestBuilder));
.with_workflow_version_packager(Arc::new(SuppliedWorkflowVersionPackager));
Some(FabroRunToolServices {
backend: Arc::new(backend),
current_run_id,
base_cwd: source_directory.map_or_else(|| run_dir.to_path_buf(), PathBuf::from),
user_settings_path: active_settings_path(None),
})
}
struct WorkerRunManifestBuilder;
impl fabro_tool::RunManifestBuilder for WorkerRunManifestBuilder {
fn build_run_manifest(
&self,
spec: &fabro_tool::ValidatedCreateRunSpec,
cwd: &Path,
user_settings_path: &Path,
) -> fabro_tool::ToolResult<RunManifest> {
run_tool_manifest::build_run_tool_manifest(spec, cwd, user_settings_path)
}
}
/// Load the worker's secret vault from the run's storage root.
///
/// A worker always receives the server storage root so it can load the same
@ -1005,7 +977,9 @@ impl RunStoreBackend for HttpRunStore {
async move { client.append_run_event(&run_id, &event).await }
}))
.await?;
self.apply_acknowledged_event(seq, event).await
// Both the sandbox lifecycle and the lithos event shapes grew this
// future past clippy's stack budget; box it once at the call.
Box::pin(self.apply_acknowledged_event(seq, event)).await
}
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
@ -1100,10 +1074,13 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
}
fn maybe_build_github_credentials(
settings: &WorkflowSettings,
run_spec: &fabro_types::RunSpec,
vault: &fabro_vault::Vault,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = &settings.run;
let resolved_run = &run_spec.settings.run;
let has_repo_origin = run_spec
.repo_origin_url()
.is_some_and(|origin| !origin.trim().is_empty());
let resolved_server = ServerSettingsBuilder::load_default().ok();
let server_ns = resolved_server.as_ref().map(|s| &s.server);
let strategy = server_ns
@ -1112,7 +1089,7 @@ fn maybe_build_github_credentials(
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
if requires_github_credentials(resolved_run) {
if requires_github_credentials(resolved_run, has_repo_origin) {
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
}
@ -1133,14 +1110,17 @@ fn maybe_build_github_credentials(
}
/// Hard-gate for the CLI worker path: a run-level token is requested, or
/// a clone-based sandbox in non-dry-run mode will need credentials to
/// pull the repository. Pull-request-driven credential acquisition is
/// handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace) -> bool {
/// a clone-based sandbox in non-dry-run mode will clone a repository and
/// needs credentials to pull it. A run without a repository origin creates
/// an empty workspace and needs none. Pull-request-driven credential
/// acquisition is handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace, has_repo_origin: bool) -> bool {
if run.integrations.github.is_token_requested() {
return true;
}
run.execution.mode != RunMode::DryRun && run.environment.provider.is_clone_based()
run.execution.mode != RunMode::DryRun
&& run.environment.provider.clones_workspace()
&& has_repo_origin
}
fn install_signal_handlers(
@ -1230,10 +1210,10 @@ mod tests {
#[test]
fn clone_sandbox_credentials_are_required_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;
assert!(EnvironmentProvider::Docker.is_clone_based());
assert!(EnvironmentProvider::Daytona.is_clone_based());
assert!(!EnvironmentProvider::Local.is_clone_based());
use fabro_types::SandboxProviderKind;
assert!(SandboxProviderKind::DOCKER.clones_workspace());
assert!(SandboxProviderKind::DAYTONA.clones_workspace());
assert!(!SandboxProviderKind::LOCAL.clones_workspace());
}
#[test]
@ -1743,10 +1723,10 @@ mod tests {
use std::collections::HashMap;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
EnvironmentProvider, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode,
RunNamespace,
RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode, RunNamespace,
};
use super::super::requires_github_credentials;
@ -1759,7 +1739,7 @@ mod tests {
let mut run = RunNamespace::default();
run.execution.mode = mode;
run.environment.provider = provider
.parse::<EnvironmentProvider>()
.parse::<SandboxProviderKind>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings {
@ -1776,28 +1756,38 @@ mod tests {
// Even with local sandbox + dry-run, non-empty permissions
// force credential acquisition.
let run = run_with(permissions, "local", RunMode::DryRun);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, false));
}
#[test]
fn requires_github_credentials_for_clone_based_provider() {
fn requires_github_credentials_for_clone_based_provider_with_an_origin() {
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, true));
let daytona = run_with(HashMap::new(), "daytona", RunMode::Normal);
assert!(requires_github_credentials(&daytona));
assert!(requires_github_credentials(&daytona, true));
let plugin = run_with(HashMap::new(), "host", RunMode::Normal);
assert!(requires_github_credentials(&plugin, true));
}
#[test]
fn does_not_require_github_credentials_without_a_repository_origin() {
// A `none` target creates an empty workspace; nothing is cloned.
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(!requires_github_credentials(&run, false));
}
#[test]
fn does_not_require_github_credentials_for_local_clean_run() {
let run = run_with(HashMap::new(), "local", RunMode::Normal);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
#[test]
fn does_not_require_github_credentials_for_clone_provider_in_dry_run() {
let run = run_with(HashMap::new(), "docker", RunMode::DryRun);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
}
}

View file

@ -0,0 +1,444 @@
//! CLI syntax ends here. Resolvers receive selections and explicit caller
//! context.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, bail};
use fabro_types::{GitHubRepositorySlug, WorkflowPath, repository};
use crate::args::RunArgs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum WorkflowSelection {
Local(PathBuf),
Git {
repository: GitHubRepositorySlug,
selector: PathBuf,
revision: RemoteWorkflowRevision,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum TargetSelection {
/// Directory relative to the caller; the default is the caller directory
/// itself.
Path(PathBuf),
Git {
repository: GitHubRepositorySlug,
branch: Option<String>,
},
}
/// A validated `--workflow-ref`, classified once so resolution never re-derives
/// which ref namespaces a value may name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RemoteWorkflowRevision {
DefaultBranch,
/// A fully qualified `refs/heads/...` branch.
Branch(String),
/// A fully qualified `refs/tags/...` tag.
Tag(String),
/// A bare name that may be a branch or a tag.
Name(String),
Commit(String),
}
impl RemoteWorkflowRevision {
pub(super) fn parse(value: Option<&str>) -> anyhow::Result<Self> {
match value {
None | Some("HEAD") => Ok(Self::DefaultBranch),
Some(value) => {
if let Some(sha) = repository::normalize_git_commit_sha(value) {
return Ok(Self::Commit(sha));
}
if !repository::is_valid_github_ref_selector(value) {
bail!("workflow ref must be a branch, tag, HEAD, or full 40-hex commit SHA");
}
let reference = value.to_owned();
if value.starts_with("refs/heads/") {
Ok(Self::Branch(reference))
} else if value.starts_with("refs/tags/") {
Ok(Self::Tag(reference))
} else if value.starts_with("refs/") {
bail!("workflow ref must be a branch, tag, HEAD, or full 40-hex commit SHA");
} else {
Ok(Self::Name(reference))
}
}
}
}
}
pub(super) fn validate_remote_selector(path: &Path) -> anyhow::Result<()> {
let value = path
.to_str()
.context("remote workflow selector must be valid UTF-8")?;
let value = value.strip_prefix("./").unwrap_or(value);
if WorkflowPath::new(value).is_err() {
bail!(
"remote workflow must be a name or repository-relative .fabro/.toml file without traversal"
);
}
let is_bare_name = !value.contains('/') && !value.starts_with('-');
match Path::new(value).extension().and_then(|ext| ext.to_str()) {
Some("toml" | "fabro") => Ok(()),
None if is_bare_name => Ok(()),
_ => bail!(
"remote workflow must be a name or explicit .fabro/.toml file; directories are ambiguous"
),
}
}
/// Explicit local paths escape the shorthand grammar, including colons in
/// file names. A repository without `:WORKFLOW` retains local lookup behavior.
pub(super) fn workflow_shorthand(path: &Path) -> Option<(&str, &str)> {
let value = path.to_str()?;
if path.is_absolute() || value.starts_with("./") || value.starts_with("../") {
return None;
}
value.split_once(':')
}
fn repository_revision(value: &str) -> anyhow::Result<(GitHubRepositorySlug, Option<&str>)> {
let (repository, revision) = value
.split_once('@')
.map_or((value, None), |(repository, revision)| {
(repository, Some(revision))
});
let repository = repository
.parse()
.context("repository must be a GitHub OWNER/REPO")?;
if revision == Some("") {
bail!("a revision or branch is required after '@'");
}
Ok((repository, revision))
}
pub(super) fn parse(args: &RunArgs) -> anyhow::Result<(WorkflowSelection, TargetSelection)> {
// Flag co-occurrence rules (`requires`/`conflicts_with`) are enforced by clap.
let workflow = args.workflow.as_ref().context("workflow is required")?;
let workflow = match (&args.workflow_repo, workflow_shorthand(workflow)) {
(_, Some(_)) if args.workflow_repo.is_some() || args.workflow_ref.is_some() => {
bail!("workflow shorthand cannot be combined with --workflow-repo or --workflow-ref");
}
(None, Some((source, selector))) => {
let (repository, revision) = repository_revision(source)?;
let selector = PathBuf::from(selector);
validate_remote_selector(&selector)?;
WorkflowSelection::Git {
repository,
selector,
revision: RemoteWorkflowRevision::parse(revision)?,
}
}
(None, None) => WorkflowSelection::Local(workflow.clone()),
(Some(repository), _) => {
validate_remote_selector(workflow)?;
WorkflowSelection::Git {
repository: repository.clone(),
selector: workflow.clone(),
revision: RemoteWorkflowRevision::parse(args.workflow_ref.as_deref())?,
}
}
};
let target = if let Some(value) = &args.target_repo_selector {
let (repository, branch) = repository_revision(value)?;
git_target(repository, branch)?
} else {
match (&args.target_from, &args.target_repo) {
(Some(path), _) => TargetSelection::Path(path.clone()),
(_, Some(repository)) => git_target(repository.clone(), args.target_branch.as_deref())?,
_ => TargetSelection::Path(PathBuf::from(".")),
}
};
Ok((workflow, target))
}
fn git_target(
repository: GitHubRepositorySlug,
branch: Option<&str>,
) -> anyhow::Result<TargetSelection> {
if branch.is_some_and(|branch| !repository::is_valid_git_branch_name(branch)) {
bail!("target branch must be a working branch name, not a tag, SHA, or qualified ref");
}
Ok(TargetSelection::Git {
repository,
branch: branch.map(str::to_owned),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_selection_remote_grammar_is_pure_and_rejects_unsafe_selectors() {
for path in [
"review",
"./review.toml",
".fabro/workflows/review/workflow.toml",
"dir/graph.fabro",
] {
validate_remote_selector(Path::new(path)).unwrap();
}
for path in [
"",
".",
"..",
"/tmp/workflow.toml",
"../review.toml",
"a/../review.toml",
"dir/review",
"dir/",
"a\\review.toml",
] {
assert!(validate_remote_selector(Path::new(path)).is_err(), "{path}");
}
for (value, expected) in [
(
"topic/slash",
RemoteWorkflowRevision::Name("topic/slash".into()),
),
(
"refs/heads/release",
RemoteWorkflowRevision::Branch("refs/heads/release".into()),
),
(
"refs/tags/v1",
RemoteWorkflowRevision::Tag("refs/tags/v1".into()),
),
("HEAD", RemoteWorkflowRevision::DefaultBranch),
(
"abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
RemoteWorkflowRevision::Commit("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd".into()),
),
] {
assert_eq!(
RemoteWorkflowRevision::parse(Some(value)).unwrap(),
expected
);
}
assert_eq!(
RemoteWorkflowRevision::parse(None).unwrap(),
RemoteWorkflowRevision::DefaultBranch
);
for value in [
"--upload-pack=x",
"topic*",
"HEAD~1",
"main..next",
"refs/pull/1/head",
"a.lock",
"main@{1}",
] {
assert!(
RemoteWorkflowRevision::parse(Some(value)).is_err(),
"{value}"
);
}
}
}
#[cfg(test)]
mod adapter_tests {
use super::super::test_support::parse_run_args;
use super::*;
use crate::args::{Cli, Commands, RunCommands};
#[test]
fn shorthand_matches_explicit_selections_for_both_commands() {
for command in ["run", "create"] {
for (suffix, reference) in [
("", None),
("@v1.2", Some("v1.2")),
("@release/v2", Some("release/v2")),
("@refs/tags/v1", Some("refs/tags/v1")),
(
"@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
Some("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd"),
),
] {
for selector in ["review", "./reviews/security.toml"] {
for branch in [None, Some("release/v2")] {
let workflow = format!("acme/workflows{suffix}:{selector}");
let target = branch.map_or_else(
|| "acme/app".to_owned(),
|branch| format!("acme/app@{branch}"),
);
let short = ["fabro", command, &workflow, "--target", &target];
let mut explicit = vec![
"fabro",
command,
selector,
"--workflow-repo",
"acme/workflows",
"--target-repo",
"acme/app",
];
if let Some(reference) = reference {
explicit.extend(["--workflow-ref", reference]);
}
if let Some(branch) = branch {
explicit.extend(["--target-branch", branch]);
}
let selections = |argv: &[&str]| {
let cli = Cli::try_parse_from(argv).unwrap();
let Commands::RunCmd(
RunCommands::Run(args) | RunCommands::Create(args),
) = *cli.command.unwrap()
else {
panic!("expected run args")
};
parse(&args).unwrap()
};
assert_eq!(selections(&short), selections(&explicit));
}
}
}
}
}
#[test]
fn shorthand_preserves_local_paths_and_requires_remote_workflow_selector() {
for value in [
"review",
"dir/review.toml",
"acme/workflows",
"acme/workflows@v1",
"./acme/workflows:review",
"../acme/workflows:review",
"/tmp/workflows:review",
] {
let args = parse_run_args([value]).unwrap();
assert_eq!(
parse(&args).unwrap(),
(
WorkflowSelection::Local(value.into()),
TargetSelection::Path(".".into())
)
);
}
}
#[test]
fn shorthand_rejects_malformed_or_conflicting_selections_before_acquisition() {
for flags in [
vec!["acme/workflows:"],
vec!["acme/workflows@:review"],
vec!["acme/workflows@HEAD~1:review"],
vec!["acme/workflows:../review.toml"],
vec!["acme/workflows:/review.toml"],
vec!["acme/workflows/extra:review"],
vec!["https://github.com/acme/workflows:review"],
vec!["acme/workflows:review", "--workflow-repo", "acme/other"],
vec!["acme/workflows:review", "--workflow-ref", "v1"],
vec!["review", "--target", "acme/app@"],
vec!["review", "--target", "acme/app@refs/tags/v1"],
vec![
"review",
"--target",
"acme/app@abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
],
vec!["review", "--target", "acme/app@main..next"],
vec!["review", "--target", "acme/app/extra"],
vec!["review", "--target", "acme/app", "--target-from", "."],
vec![
"review",
"--target",
"acme/app",
"--target-repo",
"acme/app",
],
vec!["review", "--target", "acme/app", "--target-branch", "main"],
] {
if let Ok(args) = parse_run_args(flags.iter().copied()) {
assert!(parse(&args).is_err(), "{flags:?}");
}
}
}
#[test]
fn run_selection_both_commands_share_the_adapter() {
for command in ["run", "create"] {
let cli = Cli::try_parse_from([
"fabro",
command,
"review",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"v1",
"--target-repo",
"acme/app",
"--target-branch",
"release",
])
.unwrap();
let Commands::RunCmd(RunCommands::Run(args) | RunCommands::Create(args)) =
*cli.command.unwrap()
else {
panic!("expected shared run arguments");
};
assert_eq!(
parse(&args).unwrap(),
(
WorkflowSelection::Git {
repository: "acme/workflows".parse().unwrap(),
selector: "review".into(),
revision: RemoteWorkflowRevision::Name("v1".into()),
},
TargetSelection::Git {
repository: "acme/app".parse().unwrap(),
branch: Some("release".into()),
},
)
);
}
let cli = Cli::try_parse_from(["fabro", "run", "create"]).unwrap();
assert!(
matches!(*cli.command.unwrap(), Commands::RunCmd(RunCommands::Run(args)) if args.workflow.as_deref() == Some(Path::new("create")))
);
}
#[test]
fn run_selection_adapter_rejects_invalid_inputs_without_acquisition() {
// Malformed repository slugs never reach the adapter.
for flags in [
[
"review",
"--workflow-repo",
"https://github.com/acme/workflows",
],
["review", "--target-repo", "acme/app/extra"],
] {
assert!(parse_run_args(flags).is_err());
}
for flags in [
vec!["../review.toml", "--workflow-repo", "acme/workflows"],
vec!["/tmp/review.toml", "--workflow-repo", "acme/workflows"],
vec![
"review",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"HEAD~1",
],
vec![
"review",
"--target-repo",
"acme/app",
"--target-branch",
"refs/tags/v1",
],
vec![
"review",
"--target-repo",
"acme/app",
"--target-branch",
"1234567890123456789012345678901234567890",
],
] {
let args = parse_run_args(flags.iter().copied()).unwrap();
assert!(parse(&args).is_err(), "{flags:?}");
}
}
}

View file

@ -0,0 +1,62 @@
//! Fixtures shared by the run selection, resolution, and remote workflow
//! unit tests.
#![expect(
clippy::disallowed_methods,
reason = "test fixtures write small files synchronously"
)]
use std::path::Path;
use clap::Parser as _;
use crate::args::RunArgs;
#[derive(clap::Parser)]
struct Command {
#[command(flatten)]
args: RunArgs,
}
/// Parse `fabro run`/`fabro create` arguments exactly as clap would.
pub(crate) fn parse_run_args<'a>(
args: impl IntoIterator<Item = &'a str>,
) -> Result<RunArgs, clap::Error> {
Command::try_parse_from(std::iter::once("cmd").chain(args)).map(|command| command.args)
}
/// Write a minimal two-file workflow package under `root/name`.
pub(super) fn write_workflow(root: &Path, name: &str) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("workflow.toml"),
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.unwrap();
std::fs::write(
dir.join("workflow.fabro"),
"digraph Test { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
)
.unwrap();
}
/// Stage every file in the worktree and commit it on HEAD, returning the SHA.
pub(super) fn commit_all(repo: &git2::Repository, message: &str) -> String {
let mut index = repo.index().unwrap();
index
.add_all(["."], git2::IndexAddOption::DEFAULT, None)
.unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let parent = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
let parents: Vec<_> = parent.iter().collect();
let signature = git2::Signature::now("Fixture", "fixture@example.test").unwrap();
repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)
.unwrap()
.to_string()
}

View file

@ -21,7 +21,7 @@ pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Re
list::list_command(&args, &styles, base_ctx).await
}
RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await,
RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await,
RunsCommands::Inspect(args) => Box::pin(inspect::run(&args, base_ctx)).await,
RunsCommands::Approve(args) => approval::approve_command(&args, base_ctx).await,
RunsCommands::Deny(args) => approval::deny_command(&args, base_ctx).await,
RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await,

View file

@ -13,9 +13,7 @@ use fabro_config::user::default_settings_path;
use fabro_config::{RuntimeDirectory, Storage};
use fabro_server::jwt_auth::auth_method_name;
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start};
use fabro_server::{
migrate_startup_vault, process_env_snapshot, validate_startup, validate_startup_configuration,
};
use fabro_server::{process_env_snapshot, validate_startup, validate_startup_configuration};
use fabro_static::EnvVars;
use fabro_types::settings::{LogDestination, ServerAuthMethod};
use fabro_util::printer::Printer;
@ -291,7 +289,6 @@ async fn execute_daemon(
}
validate_startup_configuration(&resolved_settings)?;
let storage = Storage::new(storage_dir);
migrate_startup_vault(storage.secrets_path());
let startup_vault = SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
.await
.context("loading secrets for startup validation")?;

View file

@ -586,6 +586,7 @@ mod tests {
ProviderCommand, ProviderNamespace,
};
use clap::error::ErrorKind;
use lithos_llm::catalog::{ProviderId, builtin};
use temp_env::with_var;
use tokio::runtime::Runtime;
@ -657,7 +658,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::openai());
assert_eq!(args.provider, builtin::openai());
}
_ => panic!("unexpected command variant"),
}
@ -671,7 +672,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::anthropic());
assert_eq!(args.provider, builtin::anthropic());
}
_ => panic!("unexpected command variant"),
}
@ -692,7 +693,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::anthropic());
assert_eq!(args.provider, builtin::anthropic());
assert!(args.api_key_stdin);
}
_ => panic!("unexpected command variant"),
@ -1201,7 +1202,7 @@ destination = "{destination}"
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::ProviderId::new("bogus"));
assert_eq!(args.provider, ProviderId::new("bogus"));
}
_ => panic!("expected provider login command"),
}

View file

@ -15,17 +15,15 @@ use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Password};
use fabro_auth::{
ApiCredential, AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult,
codex_oauth_config, strategy_for,
AuthContextRequest, AuthContextResponse, AuthMethod, LoginResult, codex_oauth_config,
strategy_for,
};
use fabro_llm::client::Client as LlmClient;
use fabro_llm::generate::{GenerateParams, generate};
use fabro_model::catalog::CatalogProvider;
use fabro_model::{Catalog, ProviderId};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use lithos_llm::catalog::{ProviderId, builtin};
use tokio::task::spawn_blocking;
use tokio::time::timeout;
// ---------------------------------------------------------------------------
// Interactive prompts
@ -55,16 +53,14 @@ pub(crate) enum ApiKeySource {
// API key validation
// ---------------------------------------------------------------------------
fn default_catalog_for_provider_auth() -> Result<Arc<Catalog>> {
Ok(Arc::new(
Catalog::from_builtin().context("failed to build provider auth catalog")?,
))
fn default_catalog_for_provider_auth() -> Arc<Catalog> {
Arc::new(fabro_llm::default_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(),
catalog.enabled_provider(provider.as_str()).map_or_else(
|| provider.to_string(),
|provider| provider.display_name().to_string(),
)
}
@ -72,15 +68,15 @@ fn api_key_catalog_provider<'a>(
provider: &ProviderId,
catalog: &'a Catalog,
) -> Result<&'a CatalogProvider> {
let catalog_provider = catalog
.provider(provider)
let provider = catalog
.enabled_provider(provider.as_str())
.with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?;
anyhow::ensure!(
catalog_provider.auth.is_some(),
fabro_auth::accepts_api_key(provider),
"provider '{}' does not define an API-key credential path",
catalog_provider.id
provider.id()
);
Ok(catalog_provider)
Ok(provider)
}
pub(crate) async fn validate_api_key(
@ -89,33 +85,28 @@ pub(crate) async fn validate_api_key(
catalog: Arc<Catalog>,
) -> Result<()> {
api_key_catalog_provider(provider, catalog.as_ref())?;
let client = LlmClient::from_credentials(
vec![ApiCredential::from_api_key(
provider.clone(),
api_key.to_string(),
catalog.as_ref(),
)?],
Arc::clone(&catalog),
let outcome = probe::probe_provider_with_api_key(
Catalog::clone(&catalog),
provider,
api_key.to_string(),
std::time::Duration::from_secs(30),
)
.await
.context("failed to create LLM client")?;
let probe_model = catalog.probe_for_provider(provider).map_or_else(
|| format!("unknown-{provider}"),
|model| model.id.to_string(),
);
let params = GenerateParams::new(probe_model, Arc::new(client))
.provider(provider.to_string())
.prompt("Say OK")
.max_tokens(16);
let response = timeout(std::time::Duration::from_secs(30), generate(params))
.await
.context("API key validation timed out")?;
response
.map(|_| ())
.context("API key validation request failed")
.map_err(|err| match err {
ApiKeyProbeError::Setup(err) => {
anyhow::Error::new(err).context("failed to create LLM client")
}
other => anyhow::Error::msg(other.to_string()),
})?;
match outcome.status {
ModelTestStatus::Ok => Ok(()),
ModelTestStatus::Error => Err(anyhow::anyhow!(
"API key validation request failed: {}",
outcome
.error_message
.unwrap_or_else(|| "unknown error".to_string())
)),
}
}
fn normalize_api_key_input(raw: &str) -> Result<String> {
@ -193,7 +184,7 @@ async fn read_and_validate_api_key(
}
pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result<AuthMethod> {
if provider != &ProviderId::openai() {
if provider != &builtin::openai() {
return Ok(AuthMethod::ApiKey);
}
@ -212,7 +203,7 @@ pub(crate) async fn authenticate_provider(
s: &Styles,
printer: Printer,
) -> Result<LoginResult> {
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth()?)
authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth())
.await
}
@ -238,7 +229,7 @@ pub(crate) async fn authenticate_provider_with_api_key_source(
source,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -269,7 +260,7 @@ pub(crate) async fn authenticate_provider_with_method(
method,
s,
printer,
default_catalog_for_provider_auth()?,
default_catalog_for_provider_auth(),
)
.await
}
@ -381,29 +372,29 @@ mod tests {
#[test]
fn builtin_api_key_providers_have_key_urls() {
let catalog = Catalog::builtin();
let catalog = fabro_llm::default_catalog();
for provider in [
ProviderId::anthropic(),
ProviderId::openai(),
ProviderId::gemini(),
builtin::anthropic(),
builtin::openai(),
builtin::gemini(),
ProviderId::new("moonshot"),
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);
let provider = api_key_catalog_provider(&provider, &catalog).unwrap();
let url = provider.api_key_url().unwrap_or_default();
assert!(!url.is_empty(), "{} has empty URL", provider.id());
assert!(url.starts_with("https://"), "{} URL: {url}", provider.id());
}
}
#[test]
fn api_key_catalog_provider_rejects_unconfigured_provider() {
let catalog = Catalog::builtin();
let catalog = fabro_llm::default_catalog();
let provider = ProviderId::new("bogus");
let err = api_key_catalog_provider(&provider, catalog).unwrap_err();
let err = api_key_catalog_provider(&provider, &catalog).unwrap_err();
assert!(
err.to_string()
@ -417,9 +408,9 @@ mod tests {
#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))]
async fn validate_api_key_rejects_invalid_key() {
let result = validate_api_key(
&ProviderId::anthropic(),
&builtin::anthropic(),
"sk-invalid-key-12345",
default_catalog_for_provider_auth().unwrap(),
default_catalog_for_provider_auth(),
)
.await;
assert!(result.is_err(), "expected invalid key to be rejected");

View file

@ -1,5 +1,29 @@
use anyhow::{Result, bail};
use fabro_sandbox::daytona::detect_repo_info;
use std::path::Path;
use anyhow::{Context as _, Result, bail};
/// Detect the git remote URL and current branch from a local repository.
///
/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL
/// and the HEAD branch name.
pub(crate) fn detect_repo_info(path: &Path) -> Result<(String, Option<String>)> {
let repo = git2::Repository::discover(path)
.with_context(|| format!("Failed to discover git repo at {}", path.display()))?;
let url = repo
.find_remote("origin")
.context("Failed to find 'origin' remote")?
.url()
.context("origin remote URL is not valid UTF-8")?
.to_string();
let branch = repo
.head()
.ok()
.and_then(|head| head.shorthand().map(String::from));
Ok((url, branch))
}
pub(crate) fn ensure_matching_repo_origin(
expected_origin_url: Option<&str>,
@ -28,10 +52,41 @@ pub(crate) fn ensure_matching_repo_origin(
#[cfg(test)]
mod tests {
use super::ensure_matching_repo_origin;
use super::{detect_repo_info, ensure_matching_repo_origin};
#[test]
fn missing_expected_origin_skips_guard() {
ensure_matching_repo_origin(None, "fork").unwrap();
}
#[test]
fn detect_git_remote_from_repo() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let (url, _branch) = detect_repo_info(dir.path()).unwrap();
assert_eq!(url, "https://github.com/org/repo.git");
}
#[test]
fn detect_repo_info_returns_worktree_branch() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
repo.branch("fabro/run/ABC", &commit_obj, false).unwrap();
repo.set_head("refs/heads/fabro/run/ABC").unwrap();
let (_, branch) = detect_repo_info(dir.path()).unwrap();
assert_eq!(branch, Some("fabro/run/ABC".into()));
}
}

View file

@ -1093,6 +1093,91 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.started",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"id": {
"sequence": 1,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_started"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.progress",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"id": {
"sequence": 2,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"progress": {
"code": "sandbox.provision"
},
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_progress"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.completed",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"duration": {
"nanos": "[NANOS]",
"secs": 0
},
"id": {
"sequence": 3,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_completed"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
@ -1115,9 +1200,27 @@ fn attach_json_errors_without_prompting_for_human_input() {
"event": "sandbox.initialized",
"id": "[EVENT_ID]",
"properties": {
"id": "local:[ULID]",
"id": "host-dir-[HEX]",
"provider": "local",
"working_directory": "[TEMP_DIR]"
"repo_cloned": false,
"repos_root": "[TEMP_DIR]/.repos",
"working_directory": "[TEMP_DIR]",
"workspace_root": "[TEMP_DIR]"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "git.identity.resolved",
"id": "[EVENT_ID]",
"properties": {
"email": "noreply@fabro.sh",
"name": "Fabro",
"source": "default"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"

View file

@ -12,10 +12,10 @@ use insta::assert_snapshot;
use serde_json::json;
use super::support::{
created_run_id, environment_json, fixture, mock_environment,
created_run_id, environment_json, fixture, init_remote_fixture, mock_environment,
mock_workflow_version_registrations, mock_workflow_version_registrations_recording,
output_stderr, output_stdout, remote_run_summary_json, resolve_run, run_count_for_test_case,
run_git, run_state,
run_git, run_state, write_workflow,
};
use crate::support::unique_run_id;
@ -60,24 +60,6 @@ fn mock_intent_create<'a>(
})
}
fn write_workflow(root: &std::path::Path, directory: &str, graph_name: &str) -> std::path::PathBuf {
let directory = root.join(directory);
std::fs::create_dir_all(&directory).expect("workflow fixture directory should be created");
std::fs::write(
directory.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.expect("workflow fixture manifest should be written");
std::fs::write(
directory.join("workflow.fabro"),
format!(
"digraph {graph_name} {{ start [shape=Mdiamond] exit [shape=Msquare] start -> exit }}"
),
)
.expect("workflow fixture graph should be written");
directory.join("workflow.toml")
}
#[test]
fn help() {
let context = test_context!();
@ -87,33 +69,39 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Register a local workflow version and create a submitted run
Register a workflow version and create a submitted run
Usage: fabro create [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <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=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--dry-run Execute with simulated LLM backend
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--auto-approve Auto-approve all human gates
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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
--json Output as JSON [env: FABRO_JSON=]
--server <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=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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
----- stderr -----
");
}
@ -688,6 +676,112 @@ fn create_preserves_named_user_other_checkout_and_loose_file_selection() {
}
}
#[test]
fn create_preserves_configured_repository_inference_but_explicit_target_path_wins() {
let context = test_context!();
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let versions = mock_workflow_version_registrations(&server);
let requests = Arc::new(Mutex::new(Vec::new()));
let create = mock_intent_create(&server, &unique_run_id(), Arc::clone(&requests));
let root = tempfile::tempdir().unwrap();
let checkout = root.path().join("checkout");
let workflow = write_workflow(&checkout, "workflow", "ConfiguredRepository");
std::fs::write(&workflow, "_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.scm]\nowner = \"acme\"\nrepository = \"configured\"\n").unwrap();
let sha = init_remote_fixture(&checkout, "topic");
let origin = root.path().join("origin.git");
let bare = git2::Repository::init_bare(&origin).unwrap();
run_git(&checkout, &[
"remote",
"add",
"origin",
"https://github.com/acme/actual.git",
]);
run_git(&checkout, &[
"remote",
"set-url",
"--push",
"origin",
&format!("file://{}", origin.display()),
]);
let server_url = format!("{}/api/v1", server.base_url());
let inferred = context
.create_cmd()
.current_dir(&checkout)
.args(["--server", &server_url, workflow.to_str().unwrap()])
.output()
.unwrap();
assert!(!inferred.status.success());
assert!(
output_stderr(&inferred)
.contains("run.scm repository that is not the local checkout's origin")
);
assert!(
bare.find_reference("refs/heads/topic").is_err(),
"rejected inference must not publish the branch"
);
versions.assert_calls(0);
create.assert_calls(0);
let explicit = context
.create_cmd()
.current_dir(&checkout)
.args([
"--server",
&server_url,
workflow.to_str().unwrap(),
"--target-from",
".",
])
.output()
.unwrap();
assert!(explicit.status.success(), "{}", output_stderr(&explicit));
assert_eq!(
requests.lock().unwrap()[0]["target"],
json!({
"kind": "git", "repo": "acme/actual", "branch": "topic", "sha": sha
})
);
assert_eq!(
bare.find_reference("refs/heads/topic")
.unwrap()
.target()
.unwrap()
.to_string(),
sha
);
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/actual\n",
origin.display()
),
)
.unwrap();
let shorthand = context
.create_cmd()
.current_dir(&checkout)
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.args([
"--server",
&server_url,
workflow.to_str().unwrap(),
"--target",
"acme/actual@topic",
])
.output()
.unwrap();
assert!(shorthand.status.success(), "{}", output_stderr(&shorthand));
let requests = requests.lock().unwrap();
assert_eq!(requests[0]["target"], requests[1]["target"]);
environment.assert_calls(3);
versions.assert_calls(2);
create.assert_calls(2);
}
#[test]
fn create_clone_targets_require_exact_git_observations() {
let context = test_context!();
@ -1633,3 +1727,278 @@ draft = false
assert!(pull_request.enabled);
assert!(!pull_request.draft);
}
#[test]
fn run_selection_source_target_cross_product_keeps_workflow_goal_and_target_independent() {
let context = test_context!();
let server = MockServer::start();
let local_env = mock_environment(&server, "local", "local");
let docker_env = mock_environment(&server, "docker", "docker");
let plugin_env = mock_environment(&server, "plugin", "host");
let versions = mock_workflow_version_registrations(&server);
let requests = Arc::new(Mutex::new(Vec::new()));
let create = mock_intent_create(&server, &unique_run_id(), Arc::clone(&requests));
let root = tempfile::tempdir().unwrap();
let caller = root.path().join("caller");
let source = root.path().join("source");
let target = root.path().join("target");
write_workflow(&caller, ".fabro/workflows/review", "Caller");
write_workflow(&source, ".fabro/workflows/review", "Remote");
write_workflow(&target, ".fabro/workflows/review", "Target");
std::fs::write(caller.join("goal.txt"), "Caller goal").unwrap();
std::fs::write(target.join("goal.txt"), "Target goal").unwrap();
init_remote_fixture(&source, "trunk");
let target_sha = init_remote_fixture(&target, "release");
let config = root.path().join("gitconfig");
std::fs::write(&config, format!("[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n[url \"file://{}\"]\n insteadOf = https://github.com/acme/app\n",source.display(),target.display())).unwrap();
let local_id = fabro_manifest::resolve_local_workflow_package(
std::path::Path::new("review"),
&caller,
None,
)
.unwrap()
.closure()
.root_id();
let remote_id =
fabro_manifest::collect_workflow_versions(std::path::Path::new("review"), &source)
.unwrap()
.root_id();
let file_id = fabro_manifest::resolve_local_workflow_package(
std::path::Path::new(".fabro/workflows/review/workflow.toml"),
&caller,
None,
)
.unwrap()
.closure()
.root_id();
assert_ne!(local_id, remote_id);
for source_kind in ["name", "file", "git", "shorthand"] {
for target_kind in ["inferred", "path", "git", "git-plugin", "shorthand"] {
let mut command = context.create_cmd();
command
.current_dir(&caller)
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0");
command.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--goal-file",
"goal.txt",
]);
command.arg(if source_kind == "shorthand" {
"acme/workflows@trunk:review"
} else if source_kind == "file" {
".fabro/workflows/review/workflow.toml"
} else {
"review"
});
if source_kind == "git" {
command.args([
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
"trunk",
]);
}
match target_kind {
"shorthand" => {
command.args(["--target", "acme/app@release", "--environment", "docker"]);
}
"path" => {
command.args(["--target-from", "../target", "--environment", "local"]);
}
"git" | "git-plugin" => {
command.args([
"--target-repo",
"acme/app",
"--target-branch",
"release",
"--environment",
if target_kind == "git-plugin" {
"plugin"
} else {
"docker"
},
]);
}
_ => {
command.args(["--environment", "docker"]);
}
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{source_kind}/{target_kind}: {}",
output_stderr(&output)
);
let requests = requests.lock().unwrap();
let intent = requests.last().unwrap();
assert_eq!(
intent["workflow_version_id"],
match source_kind {
"git" | "shorthand" => remote_id,
"file" => file_id,
_ => local_id,
}
.to_string()
);
assert_eq!(intent["goal"], "Caller goal");
assert_eq!(intent["target"], match target_kind {
"path" => json!({"kind":"folder","path":target.canonicalize().unwrap()}),
"git" | "git-plugin" | "shorthand" =>
json!({"kind":"git","repo":"acme/app","branch":"release","sha":target_sha}),
_ => json!({"kind":"none"}),
});
}
}
local_env.assert_calls(4);
docker_env.assert_calls(12);
plugin_env.assert_calls(4);
versions.assert_calls(20);
create.assert_calls(20);
}
#[test]
fn create_leaves_remote_workflow_run_submitted_without_starting() {
let context = test_context!();
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/review", "Remote");
init_remote_fixture(&source, "trunk");
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n",
source.display()
),
)
.unwrap();
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = mock_workflow_version_registrations(&server);
let run_id = unique_run_id();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
});
let start = server.mock(|when, _then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
});
let trace = root.path().join("trace");
let output = context
.create_cmd()
.current_dir(root.path())
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.env("GIT_TRACE", &trace)
.args([
"review",
"--workflow-repo",
"acme/workflows",
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--json",
])
.output()
.unwrap();
assert!(output.status.success(), "{}", output_stderr(&output));
environment.assert();
version.assert();
create.assert();
start.assert_calls(0);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
json!({"run_id":run_id})
);
let trace = std::fs::read_to_string(&trace).unwrap();
assert_eq!(
trace
.lines()
.filter(|line| line.contains("built-in: git fetch "))
.count(),
1,
"source fetched more than once"
);
}
#[test]
fn remote_workflow_explicit_acquisition_failure_has_no_fallback_or_server_mutations() {
let context = test_context!();
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = mock_workflow_version_registrations(&server);
let create = mock_intent_create(&server, &unique_run_id(), Arc::new(Mutex::new(Vec::new())));
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/other", "Other");
init_remote_fixture(&source, "trunk");
// The caller is a GitHub-origin checkout with an unpushed branch, which
// Docker target observation would publish if it ran first.
let caller = root.path().join("caller");
let origin = root.path().join("origin.git");
git2::Repository::init_bare(&origin).unwrap();
write_workflow(&caller, ".fabro/workflows/review", "Caller");
init_remote_fixture(&caller, "main");
git2::Repository::open(&caller)
.unwrap()
.remote("origin", "https://github.com/acme/app")
.unwrap();
write_workflow(
&context.home_dir.join(".fabro/workflows"),
"review",
"Installed",
);
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n[url \"file://{}\"]\n insteadOf = https://github.com/acme/app\n",
source.display(),
origin.display()
),
)
.unwrap();
for reference in [
"trunk",
"missing",
"1111111111111111111111111111111111111111",
] {
let output = context
.create_cmd()
.current_dir(&caller)
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.args([
"review",
"--workflow-repo",
"acme/workflows",
"--workflow-ref",
reference,
"--server",
&format!("{}/api/v1", server.base_url()),
])
.output()
.unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
}
environment.assert_calls(3);
version.assert_calls(0);
create.assert_calls(0);
// Acquisition failed before target observation, so nothing was pushed.
assert!(
git2::Repository::open_bare(&origin)
.unwrap()
.find_reference("refs/heads/main")
.is_err(),
"a failed remote workflow acquisition must not publish the target branch"
);
}

View file

@ -261,9 +261,9 @@ fn dump_exports_completed_run_snapshot() {
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
checkpoints/0026.json
events.jsonl
graph.fabro
run.json

View file

@ -333,7 +333,7 @@ fn exec_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let mut cmd = context.exec_cmd();
@ -398,7 +398,7 @@ 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.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let server = MockServer::start();
server.mock(|when, then| {
@ -594,7 +594,7 @@ fn exec_server_target_auth_failure_exits_with_4() {
assert_eq!(output.status.code(), Some(4));
assert_eq!(
fatal_error_line(&output.stderr),
"LLM error: Authentication error for openai: Authentication required."
"LLM error: Authentication required."
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = console::strip_ansi_codes(&stderr);
@ -650,7 +650,7 @@ fn exec_direct_provider_auth_failure_stays_exit_1() {
assert_eq!(output.status.code(), Some(1));
assert_eq!(
fatal_error_line(&output.stderr),
"LLM error: Authentication error for anthropic: bad key"
"LLM error: provider anthropic bad key"
);
}

View file

@ -12,8 +12,8 @@ fn help() {
Usage: fabro [OPTIONS] [COMMAND]
Commands:
run Register a local workflow version, create a run, and start it
create Register a local workflow version and create a submitted run
run Register a workflow version, create a run, and start it
create Register a workflow version and create a submitted run
start Start a created workflow run on the server
attach Attach to a running or finished workflow run
events View the event log of a workflow run

View file

@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject};
use fabro_mcp::client::McpClient;
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_mcp::test_support::McpStdioTestClient as McpClient;
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use httpmock::Method::{GET, POST};
@ -38,6 +38,7 @@ const MCP_RUN_TOOL_NAMES: &[&str] = &[
"fabro_run_interact",
"fabro_run_pair",
"fabro_run_search",
"fabro_workflow_version_create",
];
async fn assert_mcp_run_tool_count(client: &McpClient) {
@ -644,7 +645,7 @@ async fn stdio_server_initializes_and_lists_run_tools() {
.find(|(name, _, _)| name == "fabro_run_create")
.map(|(_, _, schema)| schema)
.expect("fabro_run_create tool should be listed");
assert_create_schema_accepts_string_and_object_specs(create_schema);
assert_create_schema_requires_version_ids(create_schema);
client
.shutdown()
.await
@ -736,15 +737,15 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let workflow_version_id = register_mcp_workflow(&client, &workflow).await;
let create = call_tool_json(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-test" }
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }}
}]
}),
)
@ -780,7 +781,7 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
"labels": {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"source_directory": null,
"repo_origin_url": null,
"goal_preview": "Run tests and report results",
"goal_truncated": false
@ -803,15 +804,15 @@ async fn mcp_run_tools_use_default_local_server_without_server_flag() {
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &[]).await;
let workflow_version_id = register_mcp_workflow(&client, &workflow).await;
let create = call_tool_json(
&client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-default-server-test" },
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-default-server-test" }},
"start": false
}]
}),
@ -1387,7 +1388,7 @@ async fn mcp_lifecycle_tools_manage_real_run() {
"labels": {
"source": "mcp-test"
},
"source_directory": "[SOURCE_DIRECTORY]",
"source_directory": null,
"repo_origin_url": null,
"goal": "Run tests and report results"
}
@ -1847,12 +1848,35 @@ async fn mcp_get_rejects_blank_run_id_before_auth_or_network() {
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_workflow_version_validation_happens_before_auth_or_network() {
let context = test_context!();
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let error = call_tool_error_text(
&client,
"fabro_workflow_version_create",
serde_json::json!({"entrypoint":"workflow","files":{}}),
)
.await;
assert_eq!(
error,
"entrypoint `workflow` is not present in workflow files"
);
assert_mcp_run_tool_count(&client).await;
client
.shutdown()
.await
.expect("MCP client should shut down");
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_create_validation_errors_happen_before_auth_or_network() {
let context = test_context!();
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let too_many = (0..51)
.map(|index| serde_json::json!({ "workflow": format!("wf-{index}.fabro") }))
.map(
|_| serde_json::json!({"workflow_version_id":"a".repeat(64), "target":{"kind":"none"}}),
)
.collect::<Vec<_>>();
let empty = call_tool_error_text(
@ -1872,13 +1896,14 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": "simple.fabro",
"inputs": { "decision": null }
"workflow_version_id": "a".repeat(64),
"target": {"kind":"none"},
"args": {"inputs": { "decision": null }}
}]
}),
)
.await;
let conflicting_goal_sources = call_tool_error_text(
let conflicting_goal_sources = call_tool_parameter_error(
&client,
"fabro_run_create",
serde_json::json!({
@ -1895,7 +1920,7 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
assert!(many.contains("runs"), "{many}");
assert!(null.contains("decision"), "{null}");
assert!(
conflicting_goal_sources.contains("goal and goal_file are mutually exclusive"),
conflicting_goal_sources.contains("fabro_workflow_version_create"),
"{conflicting_goal_sources}"
);
assert_mcp_run_tool_count(&client).await;
@ -1906,41 +1931,24 @@ async fn mcp_create_validation_errors_happen_before_auth_or_network() {
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_create_string_shorthand_deserializes_before_auth() {
async fn mcp_create_requires_explicit_target_and_rejects_old_shorthand_before_auth() {
let context = test_context!();
let harness =
RealAuthHarness::start_with_dev_token(fabro_test::GitHubAppState::default()).await;
let target_url = harness.api_target();
let workflow = context.install_fixture("simple.fabro");
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
let result = client
.call_tool(
"fabro_run_create",
serde_json::json!({ "runs": [workflow] }),
std::time::Duration::from_secs(30),
)
.await
.expect("string shorthand should deserialize and return a tool-level auth error");
assert_eq!(result.is_error, Some(true), "tool should return error");
let error = result
.content
.first()
.and_then(|content| serde_json::to_value(content).ok())
.and_then(|content| content["text"].as_str().map(ToOwned::to_owned))
.expect("tool error should include text");
assert!(!error.contains("CreateRunSpec"), "{error}");
assert!(
error.contains("Run `fabro auth login` to authenticate."),
"{error}"
);
assert_mcp_run_tool_count(&client).await;
client
.shutdown()
.await
.expect("MCP client should shut down");
harness.shutdown().await;
let client = spawn_mcp_client(&context, &["--server", "http://127.0.0.1:9"]).await;
let error = call_tool_parameter_error(
&client,
"fabro_run_create",
serde_json::json!({"runs":["simple.fabro"]}),
)
.await;
assert!(error.contains("fabro_workflow_version_create"), "{error}");
let error = call_tool_error_text(
&client,
"fabro_run_create",
serde_json::json!({"runs":[{"workflow_version_id":"a".repeat(64)}]}),
)
.await;
assert!(error.contains("explicit target"), "{error}");
client.shutdown().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
@ -2748,6 +2756,18 @@ async fn call_tool_json(
.expect("tool result should include structured content")
}
async fn call_tool_parameter_error(
client: &McpClient,
name: &str,
arguments: serde_json::Value,
) -> String {
let error = client
.call_tool(name, arguments, std::time::Duration::from_secs(30))
.await
.expect_err("invalid parameters should produce an MCP protocol error");
error.to_string()
}
async fn call_tool_error_text(
client: &McpClient,
name: &str,
@ -2766,49 +2786,62 @@ async fn call_tool_error_text(
.expect("tool error should include text")
}
fn assert_create_schema_accepts_string_and_object_specs(schema: &serde_json::Value) {
let variants = schema
.pointer("/properties/runs/items/anyOf")
.and_then(serde_json::Value::as_array)
.expect("fabro_run_create runs items should use anyOf");
assert!(
variants.iter().any(|variant| variant["type"] == "string"),
"fabro_run_create should advertise workflow string shorthand: {schema}"
);
let object_variant = variants
.iter()
.find(|variant| variant["type"] == "object")
.unwrap_or_else(|| {
panic!("fabro_run_create should advertise object create specs: {schema}")
fn assert_create_schema_requires_version_ids(schema: &serde_json::Value) {
let item = &schema["properties"]["runs"]["items"];
let item = item
.get("$ref")
.and_then(serde_json::Value::as_str)
.map_or(item, |reference| {
schema
.pointer(
reference
.strip_prefix('#')
.expect("schema reference should be local"),
)
.expect("schema reference should resolve")
});
assert_eq!(item["type"], "object");
assert_eq!(item["additionalProperties"], false);
assert!(
object_variant.pointer("/properties/workflow").is_some(),
"object create spec should include workflow property: {schema}"
);
assert!(
object_variant.pointer("/properties/goal_file").is_some(),
"object create spec should include goal_file property: {schema}"
);
assert!(
object_variant
.get("required")
.and_then(serde_json::Value::as_array)
.is_some_and(|required| required.iter().any(|field| field == "workflow")),
"object create spec should require workflow: {schema}"
item["required"]
.as_array()
.expect("create schema should require fields")
.iter()
.any(|field| field == "workflow_version_id")
);
assert!(item["properties"].get("args").is_some());
assert!(item["properties"].get("workflow").is_none());
assert!(item["properties"].get("goal_file").is_none());
}
async fn register_mcp_workflow(client: &McpClient, workflow: &Path) -> serde_json::Value {
let entrypoint = workflow
.file_name()
.expect("fixture should have a filename")
.to_str()
.expect("fixture filename should be UTF-8");
let content = fs::read_to_string(workflow).expect("workflow fixture should be readable");
let result = call_tool_json(
client,
"fabro_workflow_version_create",
serde_json::json!({
"entrypoint": entrypoint, "files": {entrypoint: content}
}),
)
.await;
result["workflow_version_id"].clone()
}
async fn create_mcp_run(client: &McpClient, workflow: PathBuf, start: bool) -> String {
let workflow_version_id = register_mcp_workflow(client, &workflow).await;
let create = call_tool_json(
client,
"fabro_run_create",
serde_json::json!({
"runs": [{
"workflow": workflow,
"dry_run": true,
"auto_approve": true,
"labels": { "source": "mcp-test" },
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {"dry_run": true, "auto_approve": true, "labels": { "source": "mcp-test" }},
"start": start
}]
}),

View file

@ -106,7 +106,9 @@ fn list_with_filters_renders_server_models_table() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -135,7 +137,9 @@ fn list_with_filters_renders_server_models_table() {
"features": {
"tools": false,
"vision": true,
"reasoning": true
"reasoning": true,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -210,7 +214,9 @@ fn list_uses_configured_server_target_without_server_flag() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -267,7 +273,9 @@ fn list_uses_fabro_config_for_machine_settings() {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []

View file

@ -41,7 +41,9 @@ fn model_json(id: &str, provider: &str, configured: bool) -> serde_json::Value {
"features": {
"tools": true,
"vision": false,
"reasoning": false
"reasoning": false,
"prompt_cache": false,
"sampling": true
},
"controls": {
"reasoning_effort": []
@ -107,7 +109,7 @@ fn help() {
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--reasoning-effort <REASONING_EFFORT>
Request a reasoning-effort level [possible values: low, medium, high, xhigh, max]
Request a reasoning-effort level (minimal, low, medium, high, xhigh, max)
-h, --help
Print help
----- stderr -----

View file

@ -10,8 +10,8 @@ use httpmock::MockServer;
use serde_json::Value;
use super::support::{
created_run_id, mock_environment, mock_workflow_version_registrations, output_stderr,
remote_run_summary_json, run_state, wait_for_event_names,
created_run_id, init_remote_fixture, mock_environment, mock_workflow_version_registrations,
output_stderr, remote_run_summary_json, run_state, wait_for_event_names, write_workflow,
};
use crate::support::{LightweightCli, run_output_filters, run_projection_json, unique_run_id};
@ -119,33 +119,39 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Register a local workflow version, create a run, and start it
Register a workflow version, create a run, and start it
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
<WORKFLOW> Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <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=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--dry-run Execute with simulated LLM backend
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--auto-approve Auto-approve all human gates
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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
--json Output as JSON [env: FABRO_JSON=]
--server <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=]
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow-repo <OWNER/REPO> Acquire workflow source locally from a GitHub OWNER/REPO using native Git credentials
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--workflow-ref <REF> Workflow branch, tag, HEAD (default), or full commit SHA; qualify ambiguous names
--target-from <PATH> Observe this target directory instead of cwd; Folder targets require server filesystem access
--target <OWNER/REPO[@BRANCH]> Target GitHub repository and optional working branch
--target-repo <OWNER/REPO> Target GitHub OWNER/REPO; the execution sandbox still needs its own clone credentials
--target-branch <BRANCH> Target working branch (default: remote default branch), pinned to its observed commit
--dry-run Simulate execution; workflow source may still be fetched and uploaded
--auto-approve Auto-approve all human gates
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--environment <ENVIRONMENT> Named environment for agent tools
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--parent <RUN> 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
----- stderr -----
");
}
@ -993,8 +999,24 @@ fn dry_run_persists_event_history_in_store() {
"event": "sandbox.stop.completed",
"id": "[EVENT_ID]",
"properties": {
"duration_ms": "[DURATION_MS]",
"provider": "local"
"action": "stop",
"correlation_id": "[ULID]",
"duration": {
"nanos": "[NANOS]",
"secs": 0
},
"id": {
"sequence": 5,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_completed"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
@ -1145,3 +1167,98 @@ fn detach_creates_run_dir_with_detach_log() {
"#
);
}
#[test]
fn run_starts_remote_workflow_once_and_failures_do_not_refetch() {
let context = test_context!();
let root = tempfile::tempdir().unwrap();
let source = root.path().join("source");
write_workflow(&source, ".fabro/workflows/review", "Remote");
init_remote_fixture(&source, "trunk");
let config = root.path().join("gitconfig");
std::fs::write(
&config,
format!(
"[url \"file://{}\"]\n insteadOf = https://github.com/acme/workflows\n",
source.display()
),
)
.unwrap();
for failure in ["none", "upload", "create", "start"] {
let server = MockServer::start();
let environment = mock_environment(&server, "default", "docker");
let version = if failure == "upload" {
server.mock(|when, then| {
when.method("POST").path("/api/v1/workflow-versions");
then.status(422).body("fixture registration rejection");
})
} else {
mock_workflow_version_registrations(&server)
};
let run_id = unique_run_id();
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
if failure == "create" {
then.status(422).body("fixture create rejection");
} else {
then.status(201)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let start = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
if failure == "start" {
then.status(422).body("fixture start rejection");
} else {
then.status(200)
.header("content-type", "application/json")
.body(run_status_response(&run_id, "submitted").to_string());
}
});
let trace = root.path().join(format!("trace-{failure}"));
let output = context
.run_cmd()
.current_dir(root.path())
.env("GIT_CONFIG_GLOBAL", &config)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_COUNT", "0")
.env("GIT_TRACE", &trace)
.args([
"acme/workflows:review",
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--detach",
"--json",
])
.output()
.unwrap();
assert_eq!(
output.status.success(),
failure == "none",
"{failure}: {}",
output_stderr(&output)
);
environment.assert();
version.assert();
create.assert_calls(usize::from(failure != "upload"));
start.assert_calls(usize::from(matches!(failure, "none" | "start")));
if failure == "none" {
assert_eq!(
serde_json::from_slice::<Value>(&output.stdout).unwrap(),
serde_json::json!({"run_id":run_id})
);
}
let trace = std::fs::read_to_string(&trace).unwrap();
assert_eq!(
trace
.lines()
.filter(|line| line.contains("built-in: git fetch "))
.count(),
1,
"{failure}: source fetched more than once"
);
}
}

View file

@ -50,19 +50,22 @@ fn help() {
");
}
/// Preview URLs come from whichever provider facet the run's sandbox
/// exposes. The local provider runs on the server host, so its preview is
/// the loopback address for the port.
#[test]
fn sandbox_preview_rejects_non_daytona_run() {
fn sandbox_preview_uses_the_local_provider_loopback_url() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let mut cmd = context.preview();
cmd.args([&setup.run.run_id, "3000"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
http://127.0.0.1:3000
----- stderr -----
× Sandbox provider does not support this capability.
");
}

View file

@ -292,6 +292,15 @@ methods = []
server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)],
expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not configured in the vault.",
},
StartupFailureCase {
name: "github-client-secret-only-in-server-env",
settings: GITHUB_SETTINGS,
server_env: &[
("SESSION_SECRET", TEST_SESSION_SECRET),
("GITHUB_APP_CLIENT_SECRET", "unprovisioned-client-secret"),
],
expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not configured in the vault.",
},
StartupFailureCase {
name: "empty-auth-methods",
settings: EMPTY_AUTH_METHODS_SETTINGS,

View file

@ -209,6 +209,47 @@ pub(crate) fn mock_workflow_version_registrations_recording(
/// Runs a `git` command in `path` for fixture setup, panicking on failure and
/// returning trimmed stdout.
/// Write a minimal `workflow.toml` and `workflow.fabro` pair under
/// `root/directory`; `graph_name` distinguishes fixtures by content.
pub(crate) fn write_workflow(root: &Path, directory: &str, graph_name: &str) -> PathBuf {
let directory = root.join(directory);
std::fs::create_dir_all(&directory).expect("workflow fixture directory should be created");
std::fs::write(
directory.join("workflow.toml"),
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
)
.expect("workflow fixture manifest should be written");
std::fs::write(
directory.join("workflow.fabro"),
format!(
"digraph {graph_name} {{ start [shape=Mdiamond] exit [shape=Msquare] start -> exit }}"
),
)
.expect("workflow fixture graph should be written");
directory.join("workflow.toml")
}
/// Initialize `path` as a repository on `branch` with one commit of its
/// current contents, returning the commit SHA.
pub(crate) fn init_remote_fixture(path: &Path, branch: &str) -> String {
let repo = git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().initial_head(branch),
)
.expect("fixture repository should initialize");
let mut index = repo.index().expect("fixture index should open");
index
.add_all(["."], git2::IndexAddOption::DEFAULT, None)
.expect("fixture files should stage");
let tree_id = index.write_tree().expect("fixture tree should write");
let tree = repo.find_tree(tree_id).expect("fixture tree should exist");
let signature = git2::Signature::now("Fixture", "fixture@example.test")
.expect("fixture signature should be valid");
repo.commit(Some("HEAD"), &signature, &signature, "fixture", &tree, &[])
.expect("fixture commit should succeed")
.to_string()
}
pub(crate) fn run_git(path: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.args(args)
@ -1107,7 +1148,7 @@ async fn append_seeded_simple_completion_events(
serde_json::json!({
"working_directory": context.temp_dir.display().to_string(),
"provider": "local",
"id": format!("local:{}", run.run_id),
"id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await,
"repo_cloned": false,
"clone_origin_url": null,
"clone_branch": null,
@ -1276,7 +1317,7 @@ async fn append_seeded_git_completion_events(
serde_json::json!({
"working_directory": context.temp_dir.display().to_string(),
"provider": "local",
"id": format!("local:{}", run.run_id),
"id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await,
"repo_cloned": false,
"clone_origin_url": null,
"clone_branch": null,

View file

@ -1,4 +1,4 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, sandbox_tests, timeout_for,
@ -6,9 +6,7 @@ use super::{
sandbox_tests!(agent_linear, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_agent_linear(sandbox: &str) {
let context = test_context!();
fn scenario_agent_linear(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -23,7 +21,7 @@ fn scenario_agent_linear(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

Some files were not shown because too many files have changed in this diff Show more