- Add query_string param to handle_webhook (token lives in ?token=...)
- Add run_web_url() and canonical_origin() to ChatEventContext
- Add fabro-config raw struct and resolver section
- Make OpenAPI Principal discriminator update explicit
- Delete superseded no-trait implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Links each technical detail in the spec body to its backing decision in the
appendix, and adds an overview note directing readers to the appendix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Supersedes the earlier Mattermost-only design. Introduces a ChatService
trait (modelled on SandboxProvider), Slack refactor as preparatory step,
full Mattermost implementation, and MS Teams stubs to validate the trait
contract against a pure-inbound-HTTP provider.
Co-Authored-By: Claude Sonnet 4.6 <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>
The slug cannot be changed after creation, so showing it as a read-only
row on the edit page added noise without value. Keep the editable slug
input on the create page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search input used text-sm (20px line-height) while the filter
buttons use text-xs (16px), both with py-2, making the input 4px
taller. Trim the input to py-1.5 so it matches the buttons' 34px
height without resizing the shared filter button components.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The manifest bundler collects Dockerfile path references from both the
named-environment catalog and [run.environment], but the server-side
resolver only inlined [run.environment.image]. A Dockerfile declared
under [environments.<slug>.image] therefore reached the Daytona provider
as an un-inlined Path and tripped its guard ("dockerfile path should have
been resolved to inline content before sandbox creation"), so no run
could use a catalog-defined Dockerfile environment.
Walk layer.environments alongside run.environment when resolving manifest
dockerfiles, mirroring the bundler. Add a regression test proving a
catalog dockerfile path is inlined.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs were falling back to the built-in `default` environment (a bare
daytona-medium snapshot with no Rust toolchain), so every Rust stage in
the smoke workflow failed with exit 127 (cargo/rustc not found). The old
[run.sandbox] config that built a custom snapshot was dropped in the
move to named environments (#360) and never ported.
Add a `fabro-dev` named environment that builds the Daytona snapshot from
.fabro/Dockerfile (Rust + nightly-2026-04-14 + cargo-nextest + bun), with
8 CPU / 16GB RAM, and select it via [run.environment].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the toolbar on /runs?view=list so the runs section under
/automations/:id supports the same client-side filters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cargo.lock churn from the merge with origin/main; the workspace bumped
fabro-environment's version but Cargo.lock still pointed at the
0.246.0-nightly.0 entry until a build refreshed it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list card linked to the singular /automation/:id, which mismatched
the rest of the new automations CRUD surface (/automations,
/automations/new, /automations/:id/edit). Switch the card link and the
slug-preview text on the create form to the plural form, and mount
/automations/:id in the router alongside the existing singular route
(kept as a back-compat alias for any older bookmarks).
Drive-by: fold two adjacent `use super::*` imports into one and reflow
a long `if let` line in the automations handler (linter cleanup; no
behavior change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>