## Summary
Fixes#508.
This changes Graphviz render preparation so Fabro DOT is normalized
before graph-level style defaults are injected. That keeps leading
comments such as `// ... {{ goal }} ...` from being mistaken for the
graph body opening brace, while continuing to reuse the existing
parser/normalizer path for Fabro-specific syntax like dotted attribute
keys.
## Verification
- `cargo nextest run -p fabro-graphviz`
- `cargo +nightly-2026-04-14 fmt --check --all`
Co-authored-by: Chad Woolley <thewoolleyman@gmail.com>
## Problem
The `local` sandbox uses the run's `source_directory` (the CLI's cwd at
invocation time) as its working directory and `create_dir_all`s it on
the server (`LocalSandbox::initialize` in `fabro-sandbox`). That is
correct when the CLI and the server share a host — the agent operates
directly on the user's project tree.
When the server is **remote** from the CLI — e.g. `fabro serve` running
in a container in Kubernetes, driven over HTTP with the `local` sandbox
— the client's cwd (e.g. `/Users/alice/project`) does not exist on the
server. The sandbox then tries to create that path as the (often
unprivileged) server user and fails at init:
```
sandbox.failed provider="local" error="Failed to create working directory" causes=["Permission denied (os error 13)"]
```
and the run dies with `workflow_error` before the agent starts.
## Fix
When `source_directory` is absent or does not exist on the server, fall
back to a server-writable `workspace` directory under the run's scratch
dir instead of recreating the client path. **Same-host behavior is
unchanged**: an existing `source_directory` is still used as-is.
The selection is extracted into a small pure helper,
`local_working_directory(source_directory, run_dir)`, so it can be
unit-tested directly.
## Testing
- `cargo test -p fabro-workflow local_working_directory` — 3 new tests
(existing source dir → used; absent → fallback;
present-but-missing-on-server → fallback)
- `cargo check -p fabro-workflow`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Thanks for fabro @brynary!
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
Adds a CRUD interface for **server-managed Environments** at
`/settings/environments`, driven by the `/api/v1/environments` REST API
(list / create / retrieve / replace / delete), and reshapes how built-in
environments are provisioned and protected.
The page lives in the **Workflows** settings nav section (also
introduced in this branch), positioned before Variables.
## Why
The Environments REST API shipped (#453) but had no UI — environments
could only be managed via the API/CLI. This gives operators a web UI
alongside Variables and Secrets, and along the way tightens the model:
environments are seeded at install time (not silently re-created on
every boot), and the `default` fallback is an ordinary, deletable
environment.
## Web UI
**Pages & component**
- `settings-environments.tsx` — list view: provider badge,
image/resource summary, row actions (Edit/Delete). **"New environment"
is a dropdown** of the enabled sandbox providers; the chosen provider is
fixed for the environment's lifetime.
- `settings-environments-new.tsx` / `settings-environments-edit.tsx` —
create/edit flows; create reads the provider from a query param.
- `environment-form.tsx` — shared form, reorganized:
- **General** panel (merged identity + image): id, and an **image-source
selector** (Image reference *vs* inline Dockerfile) that shows,
requires, and sends only the selected, mutually-exclusive source.
- **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8,
memory 1–16 GB, disk 1–20 GB), each always writing a concrete value.
- **Environment variables** key/value editor.
- **Advanced** progressive-disclosure section holding **Network** (a
single "Block all network access" toggle — allow-all vs block) and
**Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by
default when any advanced value is non-default.
- The in-form **provider control and the Labels editor were removed** —
labels remain API-managed and are round-tripped untouched so UI edits
never clear them.
**Data layer**: `environmentsApi` client, `queryKeys.environments`,
`useEnvironments` / `useEnvironment` SWR hooks.
**Nav & routing**: "Environments" item in the Workflows section before
Variables; routes registered in `router.tsx`.
## Backend: seed at install, deletable `default`
- **Seeding moved to install time.** The server no longer seeds
built-ins on startup; `EnvironmentStore::load_or_seed` → `load`
(load-only). A new public `seed_environments(dir)` (idempotent,
preserves operator edits) is called by both the web installer and the
CLI installer. An uninstalled instance therefore has no managed
environments, and a run selecting an absent environment fails explicitly
(`unknown environment: default`) rather than resurrecting a built-in.
- **`default` is no longer protected.** The delete guard and the
`Protected` error variant are gone; deleting `default` succeeds (204)
and removes the run fallback on purpose — forcing an explicit choice.
`local` is unchanged (reserved, in-memory).
- **`volumes` removed** from environment settings across the OpenAPI
spec, generated Rust + TS clients, config layers,
sandbox/server/workflow plumbing, docs, and tests.
## API contract details honored
- Edit sends the environment `revision` as `If-Match`; 409 conflicts
surface a "changed since you opened it" message.
- The REST API accepts inline Dockerfiles only — the form never sends a
Dockerfile path.
## Verification
- Rust: `cargo build` (touched crates) ✅, `cargo nextest -p
fabro-environment` 21/21 ✅, server env unit + `tests/it` integration 2/2
+ 15/15 ✅, `clippy` (nightly, touched crates, all targets) clean ✅, `fmt
--check` clean ✅. Full `--workspace` suite not run here — worth a CI
pass.
- Web: `bun run typecheck` ✅, `bun run build` ✅,
`environment-form.test.ts` 5/5 ✅. Web suite: 512 pass / 1 unrelated
pre-existing `RunDetail` failure.
- **Not visually verified in-browser** — the local app is login-gated
and automated loads redirect to `/login`; rendering of the form, the
New-environment dropdown, and `default` delete should be confirmed in a
logged-in session.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Release Repro <release-repro@example.com>
The first feature payoff of the gateway refactor series (#481–#496):
OpenRouter lands as **pure configuration over the `openai_compatible`
codec** — no new adapter, no new `AdapterKind`, no OpenRouter codec
fork. Redone from #438, which prototyped this pre-refactor as ~2,500
lines including a dedicated adapter and parallel codec plumbing; this
PR's fabro-llm diff is the usage-superset decode plus a TOML file.
## What's here (3 commits)
**Per-model `billing_policy` override (fabro-model)** — a model row may
override its provider's billing family: the aggregator case, where
Claude served through an OpenAI-compatible provider bills
Anthropic-style cache reads/writes. `pricing_for`/`billing_facts_for`
and the resolved `Route` read the model-effective policy; unknown
passthrough model ids keep the provider policy. Pinned by a pricing test
(cache writes bill at 1.25× input under the override, $0 under the
provider's OpenAI default).
**Aggregator usage superset in the `openai_compatible` codec** — the
wire usage struct gains tolerant optional fields:
- `prompt_tokens_details.cached_tokens` / `cache_write_tokens` and
`completion_tokens_details.reasoning_tokens` normalize into their
disjoint `TokenCounts` buckets with the same subtraction convention as
the `openai_responses` codec
- in-band `usage.cost` (OpenRouter returns it on every response)
surfaces as `Response.cost_usd` with `cost_source = authoritative`, on
both blocking and streamed responses — #494's client-side estimate
stamping already defers to it by construction
- **deliberate behavior change owned here**: compat providers that
report cached-token details now see them split out of `input_tokens`
(previously ignored — the wire pin placed in PR 0 anticipating exactly
this change flips, and two new OpenRouter-shaped wire pins land)
**The provider package** — `openrouter.toml` (disabled by default, the
Ollama opt-in pattern; curated vendor-namespaced model list; Claude rows
set `billing_policy = "anthropic"`; attribution headers deliberately not
sent unless the operator opts in via `extra_headers`),
`OPENROUTER_API_KEY` env/secret registry entries, a gitleaks rule for
`sk-or-v1-` keys, a live e2e test asserting authoritative cost, and docs
(integration guide + models concept + config reference).
## Deliberate scope cuts (fidelity follow-ups, per the plan)
- `reasoning_details[]` parse + verbatim multi-turn echo,
`cache_control` multipart emission, `provider`/`native_finish_reason`
field reads — the new wire pin proves they're tolerated and ignored
today
- Typed reasoning-param-style / routing codec params — no catalog row
can request reasoning effort yet (no `controls.reasoning_effort`
declared), and routing prefs already pass through
`provider_options.openrouter` verbatim via the existing
adapter-name-keyed merge; typed params land when an operator-level knob
actually needs them
- The OpenRouter Anthropic skin (`/api/v1/messages`) — a future pure
config row pairing the existing `anthropic_messages` codec with bearer
transport
## Verification
- `cargo nextest run --workspace --no-fail-fast`: 6724 passed; only the
known 5 pre-existing environment-dependent fabro-workflow failures
(identical on main)
- Wire snapshots: one deliberate flip
(`decode_usage_ignores_token_details` →
`decode_usage_parses_token_details`) + two new OpenRouter pins (blocking
cost/cache-write, streamed cost); all other snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean
- Builtin catalog unchanged for existing providers: OpenRouter is
`enabled = false`, so the #493 route-equivalence table is untouched
Credit to #438 for the provider research, catalog curation, gitleaks
rule, and docs structure.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Why
The build script in `apps/fabro-web/scripts/build.ts` hardcoded two
paths
that assumed packages live in `apps/fabro-web/node_modules/`:
- `./node_modules/.bin/tailwindcss` (the Tailwind CLI invocation)
- `join(rootPath, "node_modules", "@pierre", "diffs", ...)` (the worker
asset copy)
This repo uses Bun workspaces (root `package.json` has `workspaces:
['apps/*',
'lib/packages/*']`), so `bun install` hoists all packages to the repo
root.
Any fresh contributor install broke `bun run dev` immediately with:
```
ENOENT: no such file or directory, posix_spawn './node_modules/.bin/tailwindcss'
```
followed by:
```
ENOENT: no such file or directory, lstat '.../apps/fabro-web/node_modules/@pierre/diffs/...'
```
## What changed
- `tailwindcss` is now resolved via `Bun.which("tailwindcss")`, which
searches
`PATH` and the workspace root `node_modules/.bin/`, with the old path as
fallback.
- `pierreWorkerDir` now resolves from a `workspaceRoot` derived via
`new URL("../../..", import.meta.url)` (repo root), matching where Bun
actually
installs workspace dependencies.
## Verification
`bun run dev` from `apps/fabro-web/` completes a full build successfully
after a
clean `bun install` from the repo root.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
PR 8 of the gateway refactor series (after #493) — the optional closer:
`Client` dispatch goes through the route machinery #493 introduced,
instead of an inline ad-hoc lookup.
## What's here
`Client::resolve_provider`'s hand-rolled catalog hop
(`catalog.get(model)` → provider id) becomes
`adapter_registry::resolve_route`. Fallback order is byte-identical:
explicit `request.provider` wins, then the model's catalog route, then
the default provider, then the existing configuration error.
This puts route resolution on the live request path, so the
route-equivalence table from #493 now pins actual dispatch rather than a
helper nothing calls: a new live-dispatch sweep asserts every built-in
model's request lands on the provider its route names, alongside
explicit-provider-wins and unknown-model-default pins.
## Scope notes
- **No public API change** — `resolve_provider` is private; all frozen
`Client` methods are untouched.
- The route's `codec`/`deployment_id` still aren't handed to adapters:
`ProviderAdapter::complete(&Request)` is frozen (prod-implemented in
fabro-cli), and every allowed pairing equals the adapter's built-in
codec until the feature PRs. This PR is deliberately just the dispatch
seam, so the OpenRouter redo's Client-side wiring is a no-op.
## Verification
- `cargo nextest run --workspace --no-fail-fast` (post-rebase onto
#493's merge): green except the same 5 pre-existing
environment-dependent fabro-workflow failures, identical on main
- clippy `-D warnings` + pinned-nightly fmt clean
- Wire snapshots untouched
This closes the refactor series. Remaining: the already-open cost PR
(#494), then the feature redos — OpenRouter (#438) and Bedrock (#459).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Standalone pre-OpenRouter step, pulled forward from the #438 triage (the
gateway-refactor plan's "additive feature PR alongside the redo"):
completion responses carry a USD cost with provenance.
## What's here
**`Response.cost_usd` + `Response.cost_source`** — new optional fields
(`skip_serializing_if` keeps the wire shape byte-identical when unset).
`CostSource` (`authoritative` | `estimated`) lives in fabro-model's
billing vocabulary next to `UsdMicros`/`TokenCounts`, since the API
layer reuses it.
**`fabro-llm/src/cost.rs`** — `estimate_cost_usd`, a thin wrapper over
the existing `Catalog::price_tokens` billing machinery (billing-policy-
and speed-aware), ported from #438's prototype with attribution. One fix
over the prototype: model aliases and provider names are canonicalized
before building the `ModelRef` — `ModelPricing::bill` rejects
non-canonical refs, so the original would silently skip cost on alias
requests (caught by a new test).
**Client-level stamping** — one generic post-decode site instead of
#438's ~8 per-adapter sites (which predate the codec refactor):
`Client::complete` stamps blocking responses and `Client::stream` stamps
`Finish` events, beneath the middleware chain so middleware observes
final responses. Codecs stay wire-translation-only — zero wire-snapshot
churn — and every registered adapter (including custom
`register_provider` ones) gets the same treatment. Stamping never
overwrites an existing cost, so future authoritative in-band costs
(OpenRouter) take precedence by construction.
**API surface** — `cost_usd`/`cost_source` on `CompletionResponse`
(OpenAPI spec + handler + regenerated TS client). The streaming endpoint
already carries cost implicitly since `Finish` events serialize the
`Response` verbatim; this makes the blocking surface match. `CostSource`
reuses the canonical fabro-model type via `with_replacement`, with the
standard round-trip test pinning type identity and JSON parity.
## Deliberately not here (stays with the OpenRouter redo per the plan's
hard rule)
- Authoritative `usage.cost` parsing in the `openai_compatible` codec
wire structs
- Cached-token usage parsing (changes observable usage values)
- Per-model `billing_policy` schema field
## Verification
- `cargo nextest run --workspace --no-fail-fast`: 6701 passed; only the
known 5 pre-existing environment-dependent fabro-workflow failures
(identical on main)
- All fabro-llm wire snapshots unmodified; new pins: cost estimation
unit tests (incl. alias canonicalization), Client stamping tests
(blocking, streaming, beneath middleware, no-catalog), fabro-api
`CostSource` round-trip
- clippy `-D warnings` + pinned-nightly fmt clean; `bun run typecheck`
clean in fabro-web
Independent of the route-vocabulary work in #493 — branches directly off
main. After both land, the OpenRouter redo shrinks to config + typed
codec params + authoritative-cost decode.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PR 7 of the gateway refactor series (after #481, #488, #487, #489, #491)
— the series capstone: the wire dialect becomes route vocabulary in
fabro-model config instead of a structural implication of the adapter
type.
## What's here
**`fabro-model/src/codec.rs` (new)** — `CodecKind`
(`anthropic_messages`, `openai_responses`, `openai_compatible`,
`gemini_generate`; strum per house style).
`CodecKind::default_for(AdapterKind)` reproduces the historical
adapter→dialect fusion exactly.
**Catalog schema** — optional `codec` on provider rows and model rows
(the multiplexer case), sparse-merged with the existing `.or()` pattern.
Omitted everywhere in the built-in catalog, so **all defaults reproduce
today's routes**. Explicit pairings outside the adapter's default are
rejected at catalog build (`UnsupportedProviderCodec` /
`UnsupportedModelCodec`) so no new route combination is silently enabled
by configuration — the field is vocabulary for the OpenRouter/Bedrock
feature PRs, not a new capability. `Catalog::effective_codec` mirrors
`effective_agent_profile`. fabro-config mirrors the field through
`LlmLayer` (`ProviderSettings.codec`, `ModelSettings.codec`) and the
catalog-settings conversion.
**Route resolution** — `adapter_registry::resolve_route(catalog, model)`
assembles `(provider row, model row)` into `Route { provider, transport,
codec, deployment_id, billing_policy, agent_profile }`.
**Route-equivalence table test** — every built-in model row pinned to
its resolved tuple as an executable table (23 rows), with a coverage
assert so a new built-in model can't land without a deliberate table
edit. This is the "compat mapping as an executable table, not a comment"
test from the plan.
**`AdapterConfig` cleanup** — the OpenAI-only fields (`codex_mode`,
`org_id`, `project_id`) move out of the shared struct into
`AdapterKindOptions::OpenAi(OpenAiAdapterOptions)`; the client populates
them only for OpenAi-kind routes, which is the only factory that ever
read them.
## Deliberate scope cuts
- **No per-model `billing_policy`** — that schema change exists solely
for the OpenRouter redo, which owns it.
- **`codec_params` and `supports_count_tokens` stay adapter-internal** —
the registry `Route` carries what the catalog defines; the per-route
knobs in the adapters' `RouteConfig` move out when a second
codec/transport pairing actually exists (OpenRouter's anthropic skin /
Bedrock). Wiring `resolve_route` into `Client` request dispatch is the
optional PR 8 and is likewise deferred.
- **No user-facing docs for `codec`** — every accepted value equals the
default, so there is nothing actionable to document yet; docs land with
the first feature PR that enables a non-default pairing.
## Verification
- `cargo nextest run --workspace --no-fail-fast` (re-run post-rebase
onto #491's merge): 6701 passed; the only failures are the same 5
pre-existing environment-dependent fabro-workflow failures noted in
#491, identical on main
- fabro-llm: 548 passed — all wire snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean
This ends the refactor series: the seams exist. Next up are a standalone
cost PR (`cost.rs` + `Response.cost_usd`/`CostSource`, pulled forward
from the #438 triage as its own pre-OpenRouter step) and then the
feature redos — OpenRouter (#438: one TOML + typed codec params) and
Bedrock (#459: sigv4/eventstream transport + config, private codec layer
deleted).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changelog: add entries for 2026-05-28 through 2026-06-09, regenerate
the 2026-05-26/27 entries to cover their full days, and add the
missing 2026-05-27 navigation entry.
Product docs: scope workflow templating docs to prompt + goal (#474),
document the server-managed environments directory and seeded
built-ins (#446/#453), add a new Automations page, and list the
Automations/Environments/Variables endpoints in the API reference nav.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR 6 of the gateway refactor series (after #481, #488, #487, #489):
collapse the four per-adapter transport copies into one `transport`
module. Net −157 lines, and every cross-adapter duplication flagged in
the #487/#488 simplify findings is resolved here.
## What moved where
**`transport.rs` (new)** — how bytes travel, dialect-blind:
- `HttpTransport` (promoted from `providers::http_api::HttpApi`):
client, auth key, base URL, timeouts
- `LineReader` + `parse_retry_after` + `parse_rate_limit_headers` (moved
from `providers::common`, re-export shims kept there for the frozen
fabro-cli imports; `LineReader::new` keeps its 2-arg signature)
- `complete_via_http` / `send_for_body`: blocking send with the shared
timeout/error/status warn logs, non-2xx mapped through
`Codec::decode_error`
- `stream_via_http` + one SSE decode loop, parameterized by
`SseFraming::{EventBlocks, DataLines}` — replaces the four verbatim
`StreamLoop` + unfold copies and the four divergent framers (anthropic's
`parse_sse_block`, openai's `parse_sse_message`, the inline data-line
handling in openai_compatible/gemini, and fabro_server's private block
parser)
**`codec/mod.rs`** — gains the dialect-neutral pure helpers
`parse_error_body` and `extract_system_prompt` (moved from
`providers::common`), so the codec layer no longer imports from the
transport-side providers module.
**Adapters** — shrink to auth + route config + codec composition.
`send_and_read_response` and its `error_code_field` parameter are
deleted: the dialect error-body key now lives only in the codecs, and
any future `decode_error` override applies to blocking and streaming
paths alike.
## Unified SSE framing semantics (deliberate decisions)
The four framers disagreed on edge cases; the shared framer picks one
behavior, stated here rather than chosen silently:
- data payloads are trimmed; multi-line `data:` payloads join with `\n`;
CRLF tolerated in both modes
- comment (`:`), blank, and non-data lines are skipped
- events with an **empty payload are dropped** rather than handed to the
decoder — previously anthropic would error the whole stream on a bare
`data:` line and openai_compatible would feed the decoder an empty
string (also an error); openai/gemini already skipped
All streaming wire snapshots pass unmodified through the shared loop,
and the framer has direct unit tests for these cases.
## Behavior notes (beyond the framing edge cases)
- **Error values are byte-identical**: `Codec::decode_error`'s default
is exactly the `parse_error_body("type")` + `error_from_status_code`
path the deleted call sites inlined; gemini's gRPC-aware override is
what its paths already used.
- **Logging only**: gemini's blocking paths gain the shared
timeout/error/status warn logs (they had none); count-tokens requests
are uniformly tagged `operation="input_token_count"` (previously only
openai's was). The openai count-tokens logging pin passes unchanged.
- gemini's timeout error message now uses the configured provider name
instead of a hardcoded `gemini:` prefix (visible only on custom-named
gemini routes).
## Verification
- `cargo nextest run --workspace`: green except the 5 pre-existing
fabro-workflow failures that fail identically on main
(environment-dependent, unrelated)
- fabro-llm: 545 passed — all PR 0 wire snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean
- fabro-cli compiles against the frozen `providers::common::{LineReader,
parse_retry_after}` paths
Next in the series: PR 7 (codec on the route in fabro-model) — route
vocabulary + the route-equivalence table test.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Final dialect extraction in the gateway refactor series (after #481 /
#485, sibling of #487 and #488): the Gemini `generateContent` wire
translation moves out of `providers/gemini.rs` into
`codec/gemini_generate/`, behind the `Codec` / `StreamDecoder` traits.
The adapter becomes a thin transport shell (1,607 → ~400 lines) owning
auth (`x-goog-api-key`), base URL, and the streaming byte loop; all
translation is in the codec.
Two commits, each independently green:
1. **Add the codec** (`wire`/`encode`/`decode`/`stream`/`mod`) —
compiling but unused behind a scoped `dead_code` allow.
2. **Rewire the adapter** to it, migrate the ~22 unit tests, and add the
previously missing stream-decoder tests.
Gemini is the simplest route story in the series — no provider-name
branching, no mode flags, count-tokens always available, no forced
streaming — so there is no route config and **no `CodecParams` changes**
(this PR is conflict-free with #487/#488 apart from one `mod` line; if
it lands after them, the unit-struct `CodecParams` literals become
`::default()` on rebase, mechanical).
It does exercise two trait seams the other codecs don't:
- **Fully-formed endpoints from the codec**: model-in-path
`:generateContent` / `:streamGenerateContent?alt=sse` / `:countTokens`
ride on `EncodedRequest.endpoint` (the count body wraps the request in
`generateContentRequest`).
- **The first `decode_error` override**: Gemini's gRPC-status mapping
(`error_from_grpc_status` with HTTP-status fallback) moves behind the
codec; the adapter feeds it status + body + retry-after. The send-side
timeout mapping stays transport-side.
Other moves, wholesale and already pure: synthetic-UUID
tool-call/response ids, the id→name recovery map for `functionResponse`,
usage arithmetic (cache subtraction + tool-use addition +
thoughts→reasoning), default `safety_settings` injection (flagged
profile-ish in a comment, unchanged), `thoughtSignature` round-trip, and
the `provider_options.gemini` merge. `translate_messages` goes sync:
file-backed Image/Audio/Document attachments resolve via the shared
`attachments::resolve` (#485) before encode.
The streaming decoder preserves Gemini's distinct stream-end contract
exactly: data-only SSE (no event types, no `[DONE]`), and `finish()`
synthesizes the `Finish` from accumulated state unconditionally at
byte-stream end — there is no terminal wire event.
## Behavior preservation
No behavior change. The 32 gemini wire snapshots from #471 (encode
round-trips, attachments, response_format, provider_options merges,
streaming happy path / tool deltas / reasoning deltas / the
unconditional-Finish stream-end pin) pass unmodified, and the full
fabro-llm suite is green at 525: all 22 migrated tests plus 10 new ones
— 9 stream-decoder unit tests (gemini previously had **zero**:
text/thought deltas, reasoning→text transition, single-chunk function
calls, finish-reason handling, Finish synthesis with and without a wire
finish reason, ToolCalls inference, malformed-chunk errors) and 1
pinning the three model-in-path endpoints.
## Testing
- `cargo nextest run -p fabro-llm` — 525 passed (126 wire snapshots
included)
- `cargo check --workspace`
- `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D
warnings`
- `cargo +nightly-2026-04-14 fmt --check`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Next dialect extraction in the gateway refactor series (after #481 /
#485, sibling of the anthropic extraction): the OpenAI Responses API
wire translation moves out of `providers/openai.rs` into
`codec/openai_responses/`, behind the `Codec` / `StreamDecoder` traits.
The adapter becomes a thin transport shell (2,784 → 692 lines) owning
auth (bearer + org/project headers), base URL, the streaming byte loop,
and route config; all translation is in the codec.
Two commits, each independently green:
1. **Add the codec** (`wire`/`encode`/`decode`/`stream`/`mod`) —
compiling but unused behind a scoped `dead_code` allow.
2. **Rewire the adapter** to it and migrate the ~54 unit tests into the
codec submodules they now cover.
Key moves:
- **Codex mode splits along the codec seam**: encode-side param omission
(`temperature`/`top_p`/`max_output_tokens` omitted, `instructions`
always sent) rides on a new `CodecParams::openai_codex` flag; the
transport-side half (blocking requests served via streaming) is route
config on the adapter. No provider-name branching — codex is OpenAI's
only route split.
- **`translate_input` goes sync**: its only async-ness was file-path
image loading, now handled by the shared `attachments::resolve` (#485)
in the adapter before encode (images only; audio/documents render as
text placeholders in the codec without I/O).
- The invariant-dense pieces move wholesale, already pure: opaque
`openai_reasoning`/`openai_message` item round-trip, the `fc_…`/`call_…`
dual-id preservation via `provider_metadata`, custom-tool (apply_patch)
emission and raw-input accumulation, `store: false` + `include:
["reasoning.encrypted_content"]`.
- The SSE state machine becomes `SseAccumulator` behind `StreamDecoder`:
the transport owns byte reading + framing; the decoder is fed framed
`RawEvent`s, resolves the event type from the SSE `event:` line or the
JSON `type` field, and `finish()` synthesizes nothing
(`response.completed`/`incomplete` are the finishers — matching the old
EOF behavior exactly).
Coordination note: this PR makes the same unit→fielded `CodecParams`
change as the sibling anthropic extraction (each adds only its own
fields) — whichever lands second resolves a trivial field-union conflict
in `codec/mod.rs`.
## Behavior preservation
No behavior change. The 33 openai_responses wire snapshots from #471
(codex mode, dual-id round-trip, opaque items, attachment drop-on-error,
response_format, streaming happy path / tool deltas / reasoning deltas /
failure events) pass unmodified, and the full fabro-llm suite is back to
count (516: all 54 migrated tests plus one new test pinning the
count-tokens endpoint + filtered body on the codec).
## Testing
- `cargo nextest run -p fabro-llm` — 516 passed (126 wire snapshots
included)
- `cargo check --workspace`
- `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D
warnings`
- `cargo +nightly-2026-04-14 fmt --check`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Dialect extraction in the gateway refactor series (after #481 / #485,
sibling of #487): the Anthropic Messages wire translation moves out of
`providers/anthropic.rs` into `codec/anthropic_messages/`, behind the
`Codec` / `StreamDecoder` traits. The adapter becomes a thin transport
shell owning auth, base URL, the streaming byte loop, and route config;
all translation is in the codec.
Three commits, each independently green:
1. **Add the codec** (`wire`/`encode`/`decode`/`stream`/`mod`) —
compiling but unused behind a scoped `dead_code` allow.
2. **Rewire the adapter** to it and migrate the ~70 unit tests into the
codec submodules they now cover.
3. **Port #482's Claude Fable 5 handling into the codec layout** (see
below).
Key moves:
- **Route config replaces the request-time `provider_name ==
"anthropic"` branches**: auth scheme (x-api-key vs bearer), version/beta
headers, the count-tokens availability gate, and Kimi-over-anthropic
forced streaming resolve once per call into a `RouteConfig`. Dialect
headers ride on `CodecParams` (`AnthropicVersion::Header("2023-06-01")`
+ beta-header emission for the direct route; inert defaults for Kimi).
- **`build_api_request`'s `(ApiRequest, RequestBuilder)` dual-return
dies**: codec `encode` produces body + headers as data
(`EncodedRequest`); the transport applies them. This also kills the
duplicated header rebuild in `count_input_tokens`.
- **Encode goes sync**: file-backed Image/Document attachments resolve
to inline data via the shared `attachments::resolve` (#485) in the
adapter before encode (drop-on-error preserved; audio stays a text
placeholder in the codec).
- The SSE state machine becomes `SseAccumulator` behind `StreamDecoder`:
the transport owns byte reading + `\n\n` framing; the decoder is fed
framed `RawEvent`s. `finish()` returns nothing — `message_stop` is the
only finisher, matching today's no-synthesis contract.
- json_schema synthetic-tool machinery (encode injection, decode
extraction, stream rewrite) moves intact around the shared
`SYNTHETIC_TOOL_NAME`.
### The #482 (Claude Fable 5) port
#482 modifies the old-layout `anthropic.rs` directly, so this branch
re-homes its behavior into the codec structure (commit 3):
`stop_details` on the wire type, the Fable encode gates keyed off the
deployment id (no default adaptive `thinking`, no `temperature`/`top_p`,
no legacy 1M-context beta header — which now lands **once** instead of
twice, since both routes share `build_headers`), refusal →
failover-eligible content-filter errors in decode and stream, and the
`validate_request` rejection of manual thinking configs. The port is
inert until the Fable catalog entry lands. Validated by merging #482's
head into this branch on a scratch branch: the only conflict is
`anthropic.rs` (resolved as this branch's version), and **all of #482's
Fable/refusal tests pass against the codec implementation** (521
fabro-llm tests + fabro-model/fabro-workflow 1286 green on the merged
tree). If #482 merges first, this PR's rebase resolves the same
single-file conflict the same way.
Coordination note: this PR makes the same unit→fielded `CodecParams`
change as #487 (each adds only its own fields) — whichever lands second
resolves a trivial field-union conflict in `codec/mod.rs`.
## Behavior preservation
No behavior change. The anthropic wire snapshots from #471 (direct
route, Kimi-over-anthropic bearer/no-version pin, prompt-cache with
catalog, json_schema, count-tokens wire, streaming happy path / tool
deltas / error events / no-message_stop-no-Finish) pass unmodified, and
the full fabro-llm suite is back to count (515).
## Testing
- `cargo nextest run -p fabro-llm` — 515 passed (126 wire snapshots
included)
- Scratch-merge validation against #482's head — 521 passed incl. its 6
Fable/refusal tests; `cargo nextest run -p fabro-model -p
fabro-workflow` — 1286 passed
- `cargo build --workspace`
- `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D
warnings`
- `cargo +nightly-2026-04-14 fmt --check`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Adds Anthropic Claude Fable 5 as a first-class Fabro model without
changing the default Anthropic model. The catalog now exposes
`claude-fable-5` with `fable` and `claude-fable` aliases, 1M context,
128k max output, effort levels, vision/tools, prompt caching, and the
documented pricing.
The Anthropic adapter now handles Fable's API behavior directly: it uses
the `claude-fable-5` API ID, omits the legacy 1M context beta header,
avoids injecting default `thinking`, preserves `output_config.effort`,
omits deprecated `temperature`/`top_p` sampling fields for Fable, and
rejects unsupported manual enabled/disabled thinking configs locally.
Fable refusals are converted into content-filter LLM errors with
`stop_details` preserved. Those refusal errors are fallback-eligible, so
existing `run.model.fallbacks` chains work for both prompt and agent
paths, while no-fallback refusals surface clearly as LLM errors.
## Live QA
Manually exercised the PR branch against a live Anthropic API key from
`~/.fabro.bak/.env.bak` using a temporary local harness that was removed
before commit. The run covered non-streaming completion via `fable`,
token counting via `claude-fable`, streaming completion, the deep
model-test path with tools/reasoning, local rejection of manual thinking
config, and a live refusal probe. The live run initially exposed
Anthropic's Fable rejection of `temperature`; this PR now strips
deprecated sampling fields for Fable and the live harness then passed
6/6 checks.
## Testing
- `cargo test -p fabro-llm --test live_fable_manual -- --nocapture
--test-threads=1` -> 6 passed against live Anthropic, temporary harness
removed afterward
- `cargo nextest run -p fabro-llm
encode_fable_uses_api_id_effort_and_omits_1m_beta`
- `cargo nextest run -p fabro-model -p fabro-llm -p fabro-workflow` ->
1808 passed, 41 skipped
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo insta pending-snapshots` -> no pending snapshots
- `git diff --check`
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Next step of the gateway refactor (after #481): a small, codec-agnostic
step for resolving file-backed attachments to inline data, shared by the
per-dialect codec extractions that follow (anthropic, openai_responses,
gemini).
Codec `encode` is sync and never touches the filesystem. Today each
adapter loads file-path `Image`/`Document`/`Audio` parts inline via
`common::load_file_as_base64` mid-translation; the codec split needs
that I/O hoisted out so encode can stay pure. `attachments::resolve`
does it: clone the request, load each file-path part (per the caller's
`AttachmentPolicy`) into inline bytes + MIME, drop the part on load
error (the long-standing contract), and leave non-file URLs and
already-inline data untouched.
- `AttachmentPolicy { images, documents, audio }` — each dialect adapter
constructs the policy it wants when it wires this in (anthropic:
images+documents; openai: images only; gemini: all three).
- `common::load_file_bytes` (raw bytes + MIME) factored out of
`load_file_as_base64`, which now delegates to it.
Splitting this out of the anthropic extraction makes the three
dialect-codec PRs independent siblings — they can go up and land in
parallel once this merges.
Added ahead of its consumers, so the module sits behind a justified
`dead_code` allow until the first dialect codec calls it (the anthropic
PR drops the allow). No behavior change.
## Testing
- `cargo nextest run -p fabro-llm` — 515 passed (including the 126 wire
snapshots; byte-identical, nothing reachable changes)
- `cargo check --workspace`
- `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D
warnings`
- `cargo +nightly-2026-04-14 fmt --check`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Fixes the `openai_twin_*` parity-matrix failures that have been on
`main` since #449: every multi-turn scenario whose scripted response
includes text fails on its second turn with 400 `"message input items
require supported content"`.
## Root cause
Two twin behaviors collided (bisected: passes at #447, fails at #449):
1. **The twin's streaming `response.output_item.done` for message items
omitted the `content` array** (`test/twin/openai/src/sse.rs`) — it sent
only `id`/`type`/`status`/`role`, where the real API sends the completed
item in full. The openai adapter preserves message output items verbatim
(`ContentPart::Other { kind: OPENAI_MESSAGE }`) and replays them as
assistant history on the next turn — required so reasoning items keep
their "required following item" in Responses round-trips. So the replay
arrived content-less.
2. **#449 tightened the twin's input validation** to also validate
explicit `type: "message"` items (previously only type-less items were
validated as messages; anything with an explicit type was accepted
unchecked). The twin started rejecting its own round-tripped output.
The new validation caught a real infidelity in the emitter — the emit
side is what's wrong.
Nobody noticed because **CI never runs the twin e2e suites**: `rust.yml`
runs `--profile ci` without `--run-ignored`, so the parity matrix only
runs when someone invokes the e2e profile locally.
## Fix
- The streamed message `output_item.done` now carries its `output_text`
content, matching the real API and the twin's own non-streaming
`responses_json()`.
- The input validator accepts `output_text` parts on **assistant**
message items (the real API allows these; the twin's non-streaming
responses already require it for faithful replay). Non-assistant
`output_text` parts get a dedicated rejection message.
## Tests
- New contract test
`responses_stream_message_item_done_round_trips_as_input`: streams a
response, asserts the completed message item carries its `output_text`
content, and replays the item verbatim as assistant-history input,
asserting the twin accepts its own output.
- `cargo nextest run -p twin-openai` — 56 passed
- `cargo nextest run -p fabro-agent -E 'test(parity)' --run-ignored
only` — **91/91 passed** (was 7 failing)
- `cargo nextest run -p fabro-llm --run-ignored only` — 10 passed
- `cargo nextest run --workspace` — green apart from two pre-existing
env-dependent `fabro-workflow` failures that reproduce on clean `main`
in shells with provider API keys exported (unrelated; CI is green on
them because it has no such keys)
- clippy `-D warnings` / fmt — clean
Found while reviewing #481 (whose parity runs surfaced this); #481
itself is unaffected — it doesn't touch the openai adapter or the twin,
and the failures exist on its merge-base.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## CI (separate commit, drop if unwanted)
`ci: run twin-mode e2e suites on Linux` adds a step to the existing
Linux test job running the ignored twin-mode suites for the packages
that are fully green today (`fabro-agent`, `fabro-llm`, `twin-openai`) —
104 tests, ~1s on a warm build, no secrets needed (live-only tests
self-skip in twin mode). This is what would have caught the #449
regression. The remaining ignored suites (fabro-cli twin tests,
Docker/Daytona sandbox tests, fabro-spa asset test) need their own fixes
before joining; widen the `-E` filter as they're cleaned up. Note the
step deliberately avoids the `e2e` nextest profile, since
`NEXTEST_PROFILE=e2e` implies strict mode, which fails on missing
secrets.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
# Interpolation foundation (InterpString v2)
First step of unifying config-string interpolation across Fabro. This PR
is the
**behavior-neutral foundation** only — it introduces the type machinery
and a
clippy gate, but changes no field's interpolation behavior. The actual
field
work follows as separate stacked PRs, sequenced **reduce-first**:
narrowing
changes (demote fields that shouldn't interpolate, de-template DOT
attrs) land
before capability additions (resolve env in MCP / prepare / hooks).
## Why
Config strings interpolate `{{ ... }}` inconsistently today — some
fields
resolve `{{ env.X }}`, others are typed as if they do but silently pass
the
literal template text downstream. We're converging on three field types
(`String`, `InterpString`, and later an importable template for
prompts/goals)
with four namespaces (`env`, `vars`, `secrets`, `inputs`). This PR lays
the
`InterpString` foundation; it does not migrate any field.
## What's in it
- Segments generalize to `Token { namespace, name }` with a `Namespace`
enum
(`env`/`vars`/`secrets`/`inputs`). `secrets`/`inputs` are **reserved** —
parsed as tokens ahead of their resolvers.
- `ResolveCtx` with per-namespace lookups. `resolve_with()` fails loudly
(`Unavailable`) for a token whose namespace isn't provided in context;
`substitute_with()` substitutes provided namespaces and preserves the
rest.
`resolve()` / `substitute_variables()` are thin wrappers over one core
path.
- `ResolveEnvError` → `ResolveError { namespace, name, kind: Missing |
Unavailable }`
(message text unchanged for env/vars; the kind no longer bakes the
namespace
in, so it scales to four namespaces without an enum explosion).
- `Provenance` tracks secret-sourced names alongside env-sourced, for
uniform
redaction later.
- **`as_source()` is clippy-gated** (`disallowed-methods`). It keeps its
name;
every call site carries an `#[expect(..., reason)]` classifying it
(serialization, error display, known-leak-pending-fix, demotion-pending,
test). The lint turns the leak surface into a greppable, reasoned
work-list
and the method stays for its permanent uses (serde round-trip of the
unresolved template + diagnostics).
- fabro-server: five duplicate `process_env_var` facades and two
duplicate
`resolve_interp` helpers consolidated into one `crate::interp` module.
## Behavior changes (honest list)
- **`{{ secrets.* }}` / `{{ inputs.* }}` are now reserved.** On main
they
weren't recognized as tokens → silent literal passthrough. Now, at
`resolve()` consumers they **fail loud** (`Unavailable`) instead of
passing
the literal string through (nobody wants the literal characters as a
value —
strictly better, but technically a change). At `as_source` sites they
round-trip unchanged. Actual resolution lands in later enhancing PRs.
- Some fabro-server resolution errors gain a `"failed to resolve
<source>"`
context line.
Otherwise behavior-neutral: every field resolves exactly as it did on
main.
## What's deferred to follow-up PRs (reduce-first order)
- **Reducing / cleanup (next):** demote leak fields to `String`
(`run.model.*`, `cli.exec.model.*`, `run.git.author.*`,
`run.scm.owner/repository`); de-template `condition`/`label`/`model`/
`provider`/`speed` and `output_schema`.
- **Enhancing (after):** resolve `{{ env.* }}` in MCP transports,
prepare
steps, and hooks; wire `secrets`/`inputs`.
## Verification
- `cargo build --workspace`
- `cargo nextest run --workspace` → 6449 passed, 181 skipped
- `cargo +nightly fmt --check --all`
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` →
clean
## Reviewer notes
- The reserved-namespace `Unavailable` error for `secrets`/`inputs` is
**intentional**, not a missing case — they're parsed ahead of their
resolvers so misuse fails loud instead of leaking.
- `as_source` is clippy-gated but keeps its name deliberately — the gate
is
the enforcement; renaming was avoided as unnecessary churn.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Introduces the `Codec` / `StreamDecoder` trait seam in `fabro-llm` and
extracts the OpenAI Chat Completions wire logic behind it as the first
conforming codec. Two commits:
1. **`codec/mod.rs`** — the pure translation contract (`encode` /
`decode_response` / `stream_decoder`, plus defaulted
`encode_count_tokens` / `decode_count_tokens` / `decode_error`) and its
data types (`CodecCtx`, `CodecParams`, `EncodedRequest`, `RawEvent`). A
codec knows *what the bytes say*; it owns no HTTP, auth, or base URL.
2. **`codec/openai_compatible/`** — the Chat Completions codec split
into `wire` / `translate` / `request` / `response` / `stream`.
`providers/openai_compatible.rs` shrinks from 1,608 → ~330 lines: a thin
transport shell that keeps the public
struct/builders/auth/`validate_request`, owns the streaming byte loop +
SSE `data:` framing, and delegates all translation to the codec. The two
hand-rolled stream unfolds collapse into one.
This is the first step of a gateway refactor that separates codec (wire
dialect) from transport/auth/route, so later work (Bedrock, OpenRouter)
becomes mostly config rather than parallel adapters.
## Behavior
No behavior change. The public adapter API
(`OpenAiCompatibleAdapter::new` / `with_name` / `with_catalog` / …) is
unchanged, and **all 126 wire snapshots pass without edits** — the
parity proof that the extracted codec produces byte-identical output.
The 29 in-module unit tests move into the codec submodules alongside the
code they exercise.
## On the trait
`openai_compatible` is the simplest dialect, so its `impl Codec` is just
three methods — count-tokens and error mapping inherit the defaults. The
contract is defined in full now (a scoped `dead_code` allow on
`codec/mod.rs` covers the seams the anthropic/openai/gemini codecs will
exercise in follow-up PRs) so those extractions only *override* methods,
never extend the trait.
Extracting a real codec refined two trait signatures vs. the initial
sketch: the canonical `Request` lives in `CodecCtx` (decoders need it
for tool-argument parsing and the stream model fallback), and the
header-parsed `rate_limit` threads into `decode_response` /
`stream_decoder`. `on_event` returns `Result` so dialect error events
propagate as stream errors.
## Tests
- `cargo nextest run -p fabro-llm` — 515 passed (incl. 126 wire
snapshots, unmodified)
- `cargo nextest run --workspace` — green
- fabro-agent `parity_matrix` (the frozen `OpenAiCompatibleAdapter`
contract) — green
- `cargo +nightly fmt --check` / `clippy --all-targets -- -D warnings` —
clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Fixes the 33 `fabro-llm::it wire::*` snapshot failures currently red on
`main`.
These are a **semantic merge conflict** between two PRs that landed in
parallel, not a behavior regression:
- **#450** made the canonical `fabro_types::Message` omit absent
optional
fields (`name`, `tool_call_id`) via `#[serde(skip_serializing_if =
"Option::is_none")]`,
to match the OpenAPI completions wire contract.
- **#471** added the per-dialect wire snapshots in parallel, authored
against the older shape that emitted explicit `"name": null` /
`"tool_call_id": null`.
Each PR was green on its own branch (#450 never contained #471's
snapshots; #471 predated #450's serde change). They only collided once
both sat on `main` together — and because the serde attribute and the
snapshots live in different files, there was no textual git conflict to
flag it at merge time.
## What changed
Regenerated the 33 affected snapshots (anthropic / gemini /
openai_compatible / openai_responses) via `cargo insta accept`. The
**only** change in every snapshot is the removal of the two trailing
null fields:
```diff
- ],
- "name": null,
- "tool_call_id": null
+ ]
```
No decode/stream behavior changed; the new shape is the intended
canonical serialization.
## Test plan
- [x] `cargo nextest run -p fabro-llm` — 515 passed, 0 failed
- [x] Verified the diff across all 33 snapshots is uniformly the
null-field omission (plus the `],`→`]` reflow), nothing else
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Adds a new `/playground` route where users build a Fabro workflow by
chatting with Ask Fabro on the right while watching a live canvas
re-render on the left. The workflow can be downloaded as a `.fabro.zip`
or — eventually — launched as a real Fabro run; today the "Run for
real" button POSTs to `/api/v1/runs` and redirects to the resulting
`/runs/{id}` page, with a placeholder project/repo/folder picker.
The feature is built as a standalone component subtree under
`apps/fabro-web/app/components/playground/` with no `AppShell` or
`react-router` dependencies, so it can be re-embedded in other contexts
later by passing `chatEndpoint`, `authMode`, and an optional
`realRunRedirect` prop.
## What changed
**Frontend (`apps/fabro-web/`)**
- New `/playground` route + `<Playground>` component tree.
- Live SVG canvas via `@viz-js/viz` with click-to-inspect (read-only
node detail panel), pan, zoom, fit-to-window, and a simulated walk
through the graph driven by a Play button.
- Docked chat sidebar (assistant-ui) wired to the new
`/api/v1/playground/chat` endpoint, with auto-retry on parse failure
and a playground-specific tool-call summary that reads
`Wrote workflow.fabro (N nodes, M edges)`.
- File tabs (`workflow.fabro` / `workflow.toml` / `README.md`),
`.fabro.zip` download via `fflate`, and a "Run for real" toolbar
button that POSTs an inline `RunManifest` to `/api/v1/runs`.
- Draft persists across page refreshes via `localStorage`.
**Backend (`lib/crates/fabro-server/`)**
- New `POST /api/v1/playground/chat` SSE endpoint. Server is stateless
across turns: each request carries the full draft, the server runs
the LLM with a single `write_workflow_file` tool, streams
`StreamEvent` frames back, and lets the client own diffing/animating
the result into the canvas.
- Request-size caps before the LLM call (50 messages, 100 nodes, 200
edges) so a misbehaving or malicious client can't drag multi-MB
transcripts through token billing.
**Spec / wire contract**
- OpenAPI: new `playground/chat` operation + four new schemas
(`CreatePlaygroundChatRequest`, `PlaygroundWorkflowDraft`,
`PlaygroundWorkflowNode`, `PlaygroundWorkflowEdge`).
- `lib/packages/fabro-api-client` not regenerated yet (the playground
uses raw `fetch`); reviewers who want the TS client to pick up the
new types can run `bun run generate` in that package.
## Key design decisions
1. **Single `write_workflow_file` tool, not six per-op tools.** The
first cut exposed `add_node`/`update_node`/`connect`/etc. as
discrete tool calls. The model would routinely add nodes without
wiring them up, leaving the canvas in a broken half-state. Pivoted
to a single tool that takes the full new `workflow.fabro` content;
the browser parses the DOT, diffs it against the local draft, and
animates the resulting reducer ops in. The model only has to "get
the file right", and the canvas still paints node-by-node thanks
to the client-side animator.
2. **Stateless server.** Each chat turn POSTs the full current draft;
nothing is persisted server-side. Keeps the endpoint cheap, makes
refresh-resumption trivial (browser owns the truth), and means the
same endpoint can later sit behind a rate-limited anonymous variant
without growing per-session state.
3. **Standalone component subtree.** `<Playground>` has no
`AppShell`/router/store dependencies. All cross-cutting concerns
flow in as props (`chatEndpoint`, `authMode`, `realRunRedirect`).
This is the structural hook that makes future re-embedding possible
without a refactor.
4. **Chat is the only mutation path.** Click-to-inspect on the canvas
is read-only. Bi-directional canvas editing was explicitly cut from
scope to keep one source of truth for "how the workflow changed."
5. **Inline `RunManifest` instead of temp-dir-then-clone.** The
playground has no project to run against, so the `Run for real`
modal builds a `RunManifest` that carries the full DOT and
`workflow.toml` source inline (`workflows[key].{source, config}`).
`cwd` is pinned to a fixed `/tmp/fabro-playground` constant — no
LLM-controlled segment in a filesystem-looking field.
6. **React effects policy compliance.** All `useEffect` calls in
playground component code go through the existing primitives in
`app/hooks/effects.ts` (`useDocumentEvent`, `useInterval`) or a
purpose-named hook (`useCanvasRender`).
## Still outstanding (planned follow-ups)
- [ ] **Actually kicking off the ad-hoc run.** "Run for real" today
POSTs a manifest with a placeholder project/repo/folder
fieldset. The intent is to reuse the project-picker pattern
being introduced on the in-flight automations branch — once
that pattern lands, the disabled inputs in
`run-for-real-modal.tsx` become the live surface.
- [ ] **Header link to `/playground`.** No nav entry yet; users have
to type the URL directly.
- [ ] **Live SSE-driven canvas overlay** via
`GET /api/v1/runs/{id}/attach` — currently the modal redirects
to the standard run-view page; the "watch it build on the
playground canvas" experience comes when the `stage.*` events
are wired through.
- [ ] **Regenerate `lib/packages/fabro-api-client`** so the new types
ship to TS consumers.
- [ ] **Smoke test:** end-to-end download → unzip →
`fabro run <name>` round-trip.
- [ ] **`scripts/build.ts` dist-symlink bug:** `pruneOldBuilds` can
delete the directory `apps/fabro-web/dist` points at, which
pins the dev server in 503 "build in progress" forever.
Workaround documented; the real fix is a separate PR.
## Test plan
- [ ] `cd apps/fabro-web && bun run test app/components/playground/` —
111 tests pass
- [ ] `cd apps/fabro-web && bun run typecheck` — clean
- [ ] `cargo test -p fabro-server playground` — 6 tests pass
- [ ] Visit `/playground`; the canvas renders the welcome `start → ??? →
exit` ghost.
- [ ] Type "build me a release-notes workflow" in chat; nodes/edges
animate in; ack reads `Wrote workflow.fabro (N nodes, M edges)`.
- [ ] Click a node → inspector panel populates; click empty canvas →
deselects.
- [ ] Click `Simulate`; nodes light up `start → ... → exit` along the
resolved path.
- [ ] Click `Download .fabro`; unzip; `cd <unzipped> && fabro run
<name>` runs locally.
- [ ] Click `Run for real` → modal opens → confirm → POST succeeds →
redirected to `/runs/{id}` → run executes.
- [ ] Refresh the page; the draft persists from localStorage.
- [ ] Click `Start over` → `Yes`; canvas resets to welcome state.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## What
Threads the configured provider name through the response and error
paths so custom-named providers report their real identity. Previously
several sites used hardcoded literals:
- streamed `Response.provider` always said `"anthropic"` / `"openai"` /
`"gemini"` regardless of the configured name;
- OpenAI's **non-stream** `Response.provider` ignored `with_name`
entirely;
- `ProviderErrorDetail.provider` in error paths (stream error events,
HTTP/gRPC status mapping, request error contexts) carried the same
literals.
Now the name flows through anthropic's `StreamAccumulator`, openai's
`SseStreamState` / error-json mapper / complete + error paths, and
gemini's stream state and error helpers.
## Why
One adapter code path already serves multiple providers — e.g. Kimi runs
through the anthropic adapter via `with_name`, and the seven compat
providers share one adapter. The "this file == this provider" assumption
baked into the literals is wrong for those routes: a Kimi request that
429s reported `"Server error from anthropic"`, and its usage/error
records were misattributed. The fix was also inconsistent before this
change — some paths already used `provider_name` while the stream paths
didn't, so the same request could be attributed differently depending on
whether it streamed.
This is foundational for an upcoming gateway refactor that makes
codec/transport/provider orthogonal, where identity must travel with the
route as data rather than being hardcoded per adapter.
## Behavior change
The one intentional, behavior-visible delta: **custom-named providers**
now report their configured name in `Response.provider` and
`ProviderErrorDetail.provider`. Built-in default-named providers are
byte-identical — the wire snapshot suite from #471 passes unmodified.
Failover/retry policy keys on `ProviderErrorKind` and the `retryable()`
/ `failover_eligible()` flags, never on the provider string, so the
error-detail change is display/log/signature-only (confirmed by a
consumer sweep).
## Tests
New per-dialect `custom_named_*` wire tests pin the intentional deltas,
including a capture of the Kimi-over-anthropic route shape (bearer auth,
no `anthropic-version` header) — useful as a pin for the route-config
work later in the refactor.
- `cargo nextest run -p fabro-llm` — 515 passed
- `cargo +nightly fmt --check` / `clippy --all-targets -- -D warnings` —
clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Adds wire snapshot tests pinning the exact encode/decode/stream behavior
of all four provider adapters — **109 tests / 117 insta snapshots** in
`fabro-llm/tests/it/wire/{anthropic,openai_compatible,openai_responses,gemini}.rs`,
driven by a shared canonical request corpus in `tests/it/support.rs`.
Tests only; no `src/` changes.
Each test points a real adapter at a local httpmock server,
side-channels the full received request (method, path, headers, body)
out of an `is_true` matcher closure, responds with a canned provider
body or scripted SSE transcript, and snapshots both the captured wire
request and the decoded canonical `Response` / `Vec<StreamEvent>`.
## Why
This is the behavior-pinning net for an upcoming refactor series that
separates fabro-llm's wire translation (codec) from transport/auth
concerns. The refactor must be behavior-preserving; these snapshots make
that checkable per PR instead of asserted. The anthropic and gemini
dialects have no twin coverage, so these tests are the only net for
those paths.
httpmock matcher-capture is used for all four dialects (rather than twin
request-logs for the OpenAI ones): the corpus deliberately exercises
shapes a strict twin would reject (provider_options merges,
response_format variants, bad-file-path attachment parts), one mechanism
is cheaper to maintain than two, and the twin already validates the
OpenAI dialects via `parity_matrix` and the server scenario tests.
## Coverage
Per dialect:
- **Encode** — multi-turn/system mapping, `tool_choice` ×4, tool
round-trips (incl. error results), thinking round-trips, attachments
(inline data, URL passthrough, silent bad-file-path drop, audio
fallback), `response_format` (json + json_schema), sampling params,
per-dialect `provider_options` merges (incl. the adapter-name-keyed
compat case), catalog-driven reasoning effort and prompt cache (beta
header), and the count-tokens wire route.
- **Decode** — finish-reason mappings, each dialect's distinct usage
arithmetic (anthropic direct cache reads with `reasoning_tokens: 0`;
openai-responses cached/reasoning subtraction; gemini `(prompt − cached)
+ tool_use_prompt`; compat prompt/completion only), thinking/tool/opaque
items, dual-id (`fc_…`/`call_…`) preservation.
- **Stream** — tool-call and reasoning deltas, error events (pinning
`retryable`/`failover_eligible`), and each dialect's stream-end
contract: anthropic emits no `Finish` without `message_stop`;
openai_compatible synthesizes one only if content started (both halves
of the minimax tolerance pinned); gemini synthesizes unconditionally.
Notable current behaviors pinned as-is (documented divergences, not
changed here): `ToolResult.image_data` is dropped by every encoder;
`ToolChoice::None` drops the whole `tools` array on anthropic only;
canonical `Thinking` parts are dropped by openai-responses/gemini;
`Request.metadata` is dropped by compat/gemini; gemini ignores
`reasoning_effort` and mints synthetic UUID tool-call/response ids
(normalized to `[UUID]` in snapshots).
## Test plan
- `cargo nextest run -p fabro-llm` — 498 passed (new `it` target run
twice to verify snapshot determinism incl. UUID normalization)
- `cargo +nightly fmt --check --all` / `cargo +nightly clippy -p
fabro-llm --all-targets -- -D warnings` — clean
- `fabro-llm/tests/integration.rs` and
`fabro-agent/tests/it/parity_matrix.rs` untouched
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Limit DOT templates to prompt + goal
Part of unifying config interpolation in Fabro. Per the field taxonomy,
full
MiniJinja templates (`ImportableTemplate`) should be limited to
**`prompt`
(node) and `goal` (graph)** — the content fields that legitimately need
`{{ inputs.* }}` / `{{ goal }}`. Every other graph/node/edge attribute
should
be a plain value, not a Turing-complete template.
This is a **behavior-reducing** slice and is **independent of the
InterpString
foundation PR** (it touches the MiniJinja/template-engine path, not the
`InterpString` config path), so it branches off `main` and can be
reviewed on
its own.
## What changes
- `TemplateTransform::render_attrs` still renders node `prompt`
(unchanged) and
the graph `goal` (rendered separately, as before), but **no longer
renders**
`label`, `model`, `provider`, `speed`, edge `label`, or `condition`.
Those
are left as literal text.
- When a now-demoted attribute still contains `{{ … }}` / `{% … %}`, a
`detemplated_attribute` **warning** is emitted so authors can migrate
(the
syntax is now literal, not rendered).
- `condition` keeps its dedicated routing-expression evaluator
(`evaluate_condition` / `parse_condition_expr`); only the Jinja
pre-render is
removed, so routing still works exactly as before.
- `output_schema` becomes a string-or-`@file` value, not a template:
`FileInliningTransform` still resolves an `@file` reference but loads
its
contents **verbatim**, and neither the inline value nor the loaded file
is
MiniJinja-rendered.
`prompt` and `goal` are unaffected — both inline and `@file` forms are
still
MiniJinja-rendered (the `@` only selects whether the template is in-band
or
loaded from a file).
## Behavior change
`{{ … }}` in a demoted attribute (`label`/`model`/`provider`/`speed`/
`condition`/`output_schema`) is now **literal text** instead of being
rendered.
A parse-time `detemplated_attribute` warning flags any remaining
occurrences so
they're not silently dropped. This was rarely a sensible thing to do
anyway
(e.g. `label = "{{ goal }}"` would splat the entire goal into a short
display
label).
## Verification
- `cargo build --workspace`
- `cargo nextest run -p fabro-workflow` → 1164 passed
- `cargo +nightly fmt --check --all`
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` →
clean
- No pending `insta` snapshots
## Tests
- `template_transform_renders_prompt_and_leaves_other_attrs_literal` —
`prompt`
still renders; node/graph/edge `label` stay literal; one migration
warning per
demoted label.
- `file_inlining_transform_does_not_render_templates_in_output_schema`
and
`file_inlining_transform_loads_output_schema_file_verbatim` —
`output_schema`
inline and `@file` contents are used verbatim, no Jinja.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The automations page collapsed loading, no-automations, and no-search-
match into a single branch that always rendered `No automations match
"{query}"`. With an empty query that read `No automations match ""`,
which also flashed during the initial fetch and when the trigger filter
(not the search) excluded everything.
Split into loading / error / true-empty / no-match states using the
shared EmptyState/ErrorState/LoadingState components. The true-empty
state is now a "Create your first automation" panel with a primary CTA,
and the search/filter toolbar is hidden when there is nothing to filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an "Allow local sandboxes" checkbox (checked by default) below the
Docker/Daytona choice in the web installer, and stop unconditionally
enabling all three providers when generating settings.toml. The wizard
now enables only the selected runtime plus local when allowed; the
unselected runtime is written as `enabled = false` so the config
resolver does not default it back on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit the policy via Content-Security-Policy-Report-Only instead of the
enforcing header so browsers log violations without blocking resources
while we debug remaining CSP issues. The policy string is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Permit GitHub App manifest form posts and signed HTTPS VNC preview iframes while keeping the rest of the SPA CSP locked down. Mirror the policy in the split-web Caddy config.
Group Variables and Secrets under a new "Workflows" nav section between
General and Administration, and move Security under Administration next to
Server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Enforce Cargo lockfile use across CI and release automation so jobs fail
on stale `Cargo.lock` state instead of resolving dependencies
implicitly. This adds `--locked` to Rust CI, release builds/tests,
nightly release tagging, the TypeScript workflow's embedded Rust build,
and helper-owned Cargo calls in `fabro-dev`.
This also fixes the Linux CI flake exposed by the PR: canceling a
durably blocked in-process run could take the abort path while the
workflow was still unwinding a human-input gate, causing
`run.failed(cancelled)` to be followed by `run.unblocked`. That invalid
event order broke projection rebuilds and made `GET /runs/{id}` return
404. Cancellation now uses the durable lifecycle status when selecting
the in-process blocked-run path, so the pending interview is cancelled
before the terminal event is emitted.
The release command's intentional `cargo update --workspace` step is
unchanged, because that step updates `Cargo.lock` after bumping the
workspace version.
## Testing
- `cargo nextest run --locked -p fabro-dev --features dev -E
'test(dry_run_computes_stable_version_from_date) |
test(dry_run_prints_equivalent_build_commands)'`
- `cargo --locked dev release --dry-run --skip-tests --release-date
2026-01-01`
- `cargo --locked dev docs check`
- `cargo nextest run --locked -p fabro-server --features test-support
cancel_durably_blocked_in_process_run_cancels_pending_interview_without_abort_signal
--status-level fail --final-status-level fail --show-progress none`
- `cargo nextest run --locked -p fabro-server --features test-support
--test it scenario::lifecycle --profile ci --status-level fail
--final-status-level fail --show-progress none --no-fail-fast`
- Linux Docker stress reproduction: `cargo nextest run --locked -p
fabro-server --features test-support --test it
scenario::lifecycle::full_http_lifecycle_cancel --profile ci
--stress-count 200 --status-level fail --final-status-level fail
--show-progress none`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --locked -p fabro-dev --features dev
--all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy --locked -p fabro-server --features
test-support --all-targets -- -D warnings`
- `git diff --check`
Full `cargo nextest run --locked -p fabro-dev --features dev` currently
has two unrelated policy-test failures:
`policy::catalog_builtin_references_stay_in_allowlist` and
`policy::workflow_template_rendering_call_sites_stay_in_allowlist`.
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
---------
Co-authored-by: Release Repro <release-repro@example.com>