This branch contains both commits:
1. The action_id uniqueness fix from #252 (`fix(slack): make per-button
action_id unique to satisfy Slack's invalid_blocks check`).
2. The new context+link fix (`feat(slack): render context_display and
run link in interview messages`).
The first commit needs to land (or be rebuilt by the maintainer
workflow) before the second is meaningful, because without it
multi-button gates still fail with `invalid_blocks` and the new context
block never reaches Slack. Reviewing #252 first and this issue/PR second
is the cleanest flow.
## What
Two-file change in `lib/crates/fabro-slack/` and
`lib/crates/fabro-server/`. The Slack interview message now includes the
upstream stage's response (`context_display`) and a link back to the
run, so a reviewer in Slack can decide A vs R without opening the web
UI.
## Why
See **#253** for the full repro, screenshots, and threat model. Short
version: today the Slack message contains only the hexagon node's
`label` plus the buttons, which is not enough information to act on.
## Diff shape
- `question_to_blocks` gains a `run_web_url: Option<&str>` argument.
- New helpers:
- `header_section(question, run_web_url)`: bold question text, optional
`stage \`{stage}\`` hint, and an "Open in Fabro" link when the URL is
known.
- `context_section(context_display)`: renders the upstream stage's
response (e.g. plan summary, Dossier URLs) below the header, separated
by a `divider`. Empty context_display is skipped so the message falls
back cleanly to the old two-block shape.
- `escape_slack_controls(text)`: HTML-entity escapes `&`, `<`, `>` in
untrusted strings (question, stage, context_display, answered_blocks
question and answer text). Neutralises LLM-produced payloads like
`<!here>`, `<@U…>`, `<#C…>` while leaving Markdown formatting (`*bold*`,
`_italic_`, `` `code` ``, `~strike~`) intact. Per
https://docs.slack.dev/messaging/formatting-message-text/#escaping.
- `truncate_to_limit`: clamps both the header text and the context block
against Slack's documented 3000-character section text limit, with the
truncation suffix counted against the budget so the result is always
under the cap. Defends against pathological questions or oversized LLM
responses producing `invalid_blocks`.
- `server.rs`: `start_optional_slack_service`'s event subscriber calls
`state.run_web_url(&envelope.event.run_id)` per event and forwards the
result to `SlackService::handle_event`, which threads it into
`question_to_blocks`. Returns `None` (and the link is omitted) when
`server.web.enabled` is `false` or `server.web.url` is unset.
## Tests
10 new in `lib/crates/fabro-slack/src/blocks.rs`:
- `header_includes_run_link_when_url_provided`
- `header_omits_link_when_url_missing`
- `header_shows_stage_when_present`
- `header_truncates_when_inputs_exceed_section_limit`
- `context_display_renders_between_header_and_actions`
- `context_display_truncates_oversized_text_to_fit_slack_budget`
- `empty_context_display_is_skipped`
- `slack_control_chars_in_question_text_are_escaped`
- `slack_control_chars_in_context_display_are_escaped` (covers
`<!here>`/`<@U…>`/`<#C…>` neutralisation and verifies Markdown survives)
- `answered_blocks_escape_slack_control_chars`
84/84 `fabro-slack` tests pass (was 74 after #252). `cargo
+nightly-2026-04-14 fmt --check --all` and `cargo +nightly-2026-04-14
clippy -p fabro-slack -p fabro-server --all-targets -- -D warnings` both
clean.
## Verified end-to-end
Built a patched binary, swapped it for the brew install, triggered a
fresh multi-choice approve gate against a real Slack workspace. The
Slack message now renders with bold "Approve Plan" header, `stage
\`approve\`` hint, "Open in Fabro" link, the upstream plan summary block
(Dossier canonical and version URLs, `tmp-docs/fabro-plan.html` artifact
path, the plan-summary bullets), a divider, and the `[A] Approve` and
`[R] Revise` buttons. Reviewer can act on the gate from Slack alone.
## Latent observation, not in this diff
`lib/crates/fabro-server/src/server.rs::AppState::run_web_url` has a
comment saying it is snapshotted at create-time so attach replays remain
stable when `server.web.url` changes, but the implementation reads
current settings via `server_settings()`/`canonical_origin()`. This
patch is unaffected (per-event call), but the comment looks stale.
## Closes
Closes#253 if you choose to land this directly. Otherwise this PR is
background material for the issue.
---------
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Heads up:
[CONTRIBUTING.md](https://github.com/fabro-sh/fabro/blob/main/CONTRIBUTING.md)
says you don't accept outside PRs, *"Instead of accepting outside pull
requests, we accept bug reports and feature requests as GitHub Issues."*
I filed the canonical bug report as **#251**, and that's where any
actual discussion belongs.
This PR is a courtesy ready-made diff in case it's useful to whoever
supervises the AI workflow that lands this fix. Feel free to close it
without comment; nothing is being asked of you here. I just thought it'd
be useful to have a reference for what I did locally to fix it.
## What
Two-file behaviour fix in `lib/crates/fabro-slack/`: every interview
button on a multi-button gate now gets a Slack-unique `action_id`. Today
they all share `"interview.answer"`, so Slack rejects the
`chat.postMessage` with `invalid_blocks` and
`SlackService::handle_event` silently drops the error.
## Why
Multi-button Slack interview gates never reach Slack. Full reproducer,
MITM-captured `invalid_blocks` response, and root-cause walkthrough are
in **#251**.
## Diff shape
- `blocks.rs`: each button gets a unique suffix.
- `YesNo` / `Confirmation`: `interview.answer.yes` /
`interview.answer.no`
- `MultipleChoice`: `interview.answer.<index>` (index, not raw key, to
dodge Slack's 255-char `action_id` cap and any author-supplied charset
surprises; selected key still rides in the button `value`)
- `interaction.rs`: `parse_interaction` accepts both the legacy
exact-prefix shape (in-flight buttons keep working across upgrade) and
the new suffixed shape, via a pre-computed `ANSWER_ACTION_ID_PREFIX_DOT`
constant so the parse hot path doesn't `format!` on every event.
- Tests: +5 in `interaction.rs` (suffixed yes/no, suffixed multi-choice,
legacy exact prefix, lookalike `interview.answers.yes` rejected,
prefix-sync assertion). Updated the existing block-builder tests to
assert uniqueness instead of the old single constant. One fixture each
in `dispatch.rs` and `connection.rs` updated to the suffixed shape; one
legacy fixture left in each to document backwards compatibility.
74/74 `fabro-slack` tests pass (was 69/69). `cargo +nightly-2026-04-14
fmt --check --all` and `cargo +nightly-2026-04-14 clippy -p fabro-slack
--all-targets -- -D warnings` clean.
## Verified end-to-end
Built a patched `fabro` binary, swapped it for the brew install,
triggered a fresh multi-choice `Approve Plan` gate against a real Slack
workspace, message rendered correctly in the configured channel with two
clickable `[A] Approve` and `[R] Revise` buttons. Before the patch, the
exact same gate produced zero Slack output and only the swallowed
`invalid_blocks` was visible via MITM.
## Suggested follow-up (separate concern, not in this diff)
The silent error swallow in `SlackService::handle_event` (`if let
Ok(posted) = self.client.post_message(...)`) is what hid this bug. Worth
logging at `WARN`. Mentioned in #251 as a separate item.
## Closes
Closes#251 if you choose to land this directly. Otherwise this PR is
just background material for the issue.
Tighten auth state to remove impossible identity branches and stringly error codes.
Route worker JWTs by header metadata, avoid unnecessary auth context cloning, and reuse shared helpers across tests and Slack payload handling.
Carry typed timeout actor metadata through failures instead of deriving it from display text.
Add fabro-static::EnvVars as the shared registry for fixed environment variable names and migrate env reads, clap env bindings, and subprocess/test allowlists to use it.
Add clippy bans for raw std::env lookup APIs so future dynamic env facades must be documented explicitly.
Add the shared fabro-http transport crate and route hand-written HTTP client construction through it.
Use FABRO_HTTP_PROXY_POLICY for test no-proxy defaults, remove direct reqwest deps from ordinary crates, and add clippy bans for raw reqwest entrypoints.
No production deployments exist, so there's no need for migration shims.
Remove all six backwards-compat type aliases (AgentError, SdkError,
CoreError, GraphvizError, StoreError, FabroError) and migrate ~880
callsites to use the canonical Error name directly within each crate,
or qualified imports (e.g., `use fabro_llm::Error as LlmError`) for
cross-crate references. Also fix a pre-existing absolute-path clippy
lint in fabro-server error.rs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve every clippy warning across the workspace when running with
--tests enabled. Previously only library code was lint-clean; test
code had accumulated issues that were invisible without --tests.
Fixes:
- redundant_closure_for_method_calls: |s| s.as_source() -> InterpString::as_source
(effective_settings, resolve_cli/root/server/features, run_event/record_serde,
materialize_run) — add InterpString imports where needed
- absolute_paths: inline fabro_types::settings::* paths -> use imports;
add #![allow(clippy::absolute_paths)] to fabro-cli and fabro-server
IT test harnesses (matching the existing pattern in integration.rs)
- bool_assert_comparison: assert_eq!(x, true) -> assert!(x)
- needless_raw_string_hashes: r#"..."# -> r"..." where no inner quotes
- field_reassign_with_default: mut + field assign -> struct literal with ..Default
- match_same_arms: merge Timeout | Disconnected arms in attach.rs
- needless_pass_by_value: signal_rx by ref in attach.rs
- unreadable_literal: 9999999999 -> 9_999_999_999
- default_trait_access: Default::default() -> BTreeMap::default()
- items_after_statements: move use to function top
- large_futures: allow in integration.rs test module (test-only, not prod)
- filter_map_bool_then: .filter_map(bool::then) -> .filter().map()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Persist pending interviews in run state, deliver accepted answers to workers
through the server-owned control path, and remove the old scratch-file and
WebInterviewer transports.
This also moves Slack onto the canonical server answer flow, adds richer
question metadata to the API and run events, and covers the subprocess
question lifecycle with end-to-end tests.
Aligns naming with the convention that "Config" is for file-level configuration
while "Options" and "Settings" describe runtime parameters. Also applies
rustfmt formatting fixes in web_auth.rs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds GITHUB_BASE_URL and SLACK_BASE_URL environment variable support
so integration tests can redirect traffic to fake servers instead of
hitting live third-party services.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adopts uv's clippy lint configuration: pedantic group at warn priority,
with noisy lints allowed, plus restriction lints for print/dbg/exit/use_self.
Fixes all violations across the workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add clippy.toml with absolute-paths-max-segments = 2 (allowing std/core/alloc)
and enable the absolute_paths = "warn" lint workspace-wide. Fix all ~300
violations across the codebase: replace 3+-segment inline paths with use
statements so call sites read as operations::create() rather than
fabro_workflows::operations::create(). The demo module gets an allow
attribute since it constructs many API types by design.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Configure clippy `wildcard_imports = "warn"` at the workspace level and
opt all 28 crates in via `[lints] workspace = true`. Fix the three
production glob imports that triggered warnings: fabro-sandbox
read_guard, fabro-cli main, and fabro-api demo module (allowed via
attribute since it constructs many API types by design). Document the
import style convention in CLAUDE.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary
- **Unify foreground and detach code paths**: Both `fabro run` modes now
go through the same `create_run() + start_run()` pipeline, with
foreground adding `attach_run()`. Only `--preflight` remains as a
special case.
- **Fix three bugs in create→start→attach path**: (1) `_run_engine`
crashed for `.fabro` workflows by hardcoding `run.toml` — now falls back
to `graph.fabro`; (2) `attach_run` couldn't detect crashed engines due
to zombie processes — `start_run` now returns the `Child` handle; (3)
`create_run` ignored `--run-id`.
- **Configure nextest slow-timeout profiles**: Tighten unit test timeout
to 2s slow / 4s kill, add `e2e` profile with 10s/30s. Switch CI and docs
to `cargo nextest run`.
## Test plan
- [ ] `cargo nextest run --workspace` passes with new timeout profiles
- [ ] `fabro run <workflow>` works in foreground mode (create + start +
attach)
- [ ] `fabro run --detach <workflow>` prints run ID and exits
- [ ] `fabro attach <run>` works standalone (without child handle)
- [ ] `fabro resume <run>` works for both `.toml` and `.fabro` workflows
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The interviewer module (trait + 7 implementations for human-in-the-loop
interactions) had zero dependencies on fabro-workflows internals, making
it a clean extraction. Consumers (fabro-api, fabro-slack) now depend on
fabro-interview directly instead of reaching through fabro-workflows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>