Production code must not panic without a clear explanation of *why* the
failure is impossible. This PR upgrades panic-adjacent messages across
the codebase to meet that standard, and converts two genuine runtime
panics into proper error handling.
## What changed
**Invariant-explaining `expect` messages** — all existing `expect("short
label")` calls that guarded hard-coded literals, just-inserted map
entries, just-pushed Vec elements, or hard-coded regex/template strings
now carry a sentence explaining *why* the None/Err path cannot be
reached (e.g. `"node was just inserted by ensure_node, so get_mut cannot
return None"`). No behavior changes.
**`assert_eq!` → `panic!` with justification** in `strategy.rs` — the
bare assert is replaced with an explicit `panic!` whose message names
every existing call site that enforces the `CodexDevice ↔ OpenAI`
invariant, making future regressions easier to diagnose.
**Genuine runtime errors converted to `Result`** — `select_backend` /
`select_backend_for_gh_command` in the upgrade command previously called
`.expect()` on `http_client()`, which can fail due to TLS or environment
issues. Both functions now return `Result<Backend>` and propagate the
error to the CLI boundary.
**Signal handler panics degraded to warnings** in `serve.rs` —
`ctrl_c()` and `unix::signal()` failures no longer panic the server;
instead they log a warning and park the future, allowing the server to
keep running without graceful-shutdown support rather than crashing on
startup.
**Telemetry thread spawn failure** in `fabro-telemetry` — instead of
panicking, a failure to spawn the background thread logs a debug message
and silently disables telemetry, which is the correct degradation for an
optional observability feature.
**OS RNG `expect` messages** — three sites (`random_secret`,
`random_auth_code`, `generate_dev_token`) now explain that a failure
means the system RNG is broken and the security of the generated value
would be compromised, justifying the panic boundary.
### Fabro Details
<details>
<summary>Ran 3 stages in 45m 16s for $11.65</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| work | 34m 22s | $9.00 | 0 |
| audit | 10m 35s | $2.65 | 0 |
| **Total** | **45m 16s** | **$11.65** | **0** |
</details>
<details>
<summary>Ran <code>Goal.fabro</code> (4 nodes and 5 edges)</summary>
```dot
digraph Goal {
graph [
goal="Complete the user-provided goal",
rankdir=LR,
max_node_visits=30
]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [
label="Work",
thread_id="goal",
fidelity="full",
max_visits=12,
prompt="@prompts/continue.md"
]
audit [
label="Completion Audit",
thread_id="goal",
fidelity="full",
goal_gate=true,
retry_target="work",
output_schema="routing",
output_retries=2,
max_visits=12,
prompt="@prompts/audit.md"
]
start -> work -> audit
audit -> exit [label="Done", condition="outcome=succeeded"]
audit -> work [label="Continue", condition="outcome=failed || preferred_label=Continue"]
audit -> work [label="No clear verdict"]
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
## Summary
Settings > Integrations now reflects the server's actual integration
readiness instead of only static `settings.toml` booleans. This adds
`/api/v1/system/integrations` as the runtime source of truth, covering
server config, vault credential presence, and Slack Socket Mode
connection state.
## What Changed
- Added shared `fabro-types` integration status models and reused them
from `fabro-api` to avoid duplicate API/domain types.
- Added `GET /api/v1/system/integrations` to the OpenAPI spec, Rust
server routes, demo routes, and generated TypeScript client.
- Reports GitHub and Slack status as `disabled`, `missing_credentials`,
`configured`, `connecting`, `connected`, or `error`, with non-secret
metadata and missing credential names.
- Tracks Slack Socket Mode runtime state from the Slack connection loop
and respects explicit `server.integrations.slack.enabled = false` even
when vault tokens exist.
- Updated the Integrations settings page to read the new runtime
endpoint, so a vault-configured Slack setup no longer appears simply as
disabled.
## Verification
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-api system_integrations`
- `cargo nextest run -p fabro-config
resolved_server_integrations_are_slack_only_for_chat`
- `cargo nextest run -p fabro-slack
run_event_loop_notifies_connected_status`
- `cargo nextest run -p fabro-server --features test-support --test it
get_system_integrations`
- `cargo nextest run -p fabro-server`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cd apps/fabro-web && bun test
app/routes/settings-integrations.test.tsx app/lib/query-keys.test.ts`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun run build`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
## Summary
This improves Slack support from setup through day-to-day operator
visibility. The server now resolves Slack credentials through the same
`process env -> server.env` path as other server secrets and logs
whether Slack is enabled or which credential variables are missing.
Run lifecycle notifications now use a structured Block Kit layout
instead of one dense markdown section. Messages get a header, workflow
summary, metadata fields, optional failure details, optional PR
metadata, and an Open in Fabro context link.
The public docs now explain local `server.env` setup, Docker/process-env
setup, expected startup logs, lifecycle smoke testing, terminal
`run.failed` semantics, and the current route-based
`[run.notifications]` format.
## Validation
- `cargo test -p fabro-slack`
- `cargo check -p fabro-server`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo dev docs check`
- `git diff --check`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
## Summary
Two related changes land together: the sandbox configuration surface is
replaced with a named-environment model, and `InterviewOption` gains
`description` and `preview` fields needed for the mid-stage agent
interview tools described in the plan.
## What changed
### Named environments (was `[run.sandbox]`)
`[run.sandbox]` and its provider-specific sub-tables
(`[run.sandbox.daytona]`, `[run.sandbox.docker]`) are replaced by a
two-level model:
- **`[environments.<slug>]`** — reusable catalog entries with a unified
shape: `provider`, `image`, `resources`, `network`, `lifecycle`,
`labels`, `volumes`, `env`.
- **`[run.environment] id = "<slug>"`** — selects which environment a
run uses.
- **`[run.environment.<field>]`** — sparse run-level overrides applied
on top of the selected environment.
The OpenAPI schema drops `RunSandboxSettings`, `DaytonaSettings`,
`DaytonaSnapshotSettings`, `DaytonaNetworkLayer`, and `DockerSettings`
in favour of `EnvironmentSettings`, `RunEnvironmentSettings`, and the
new sub-schemas (`EnvironmentImageSettings`,
`EnvironmentResourcesSettings`, `EnvironmentNetworkSettings`,
`EnvironmentLifecycleSettings`, `EnvironmentVolumeSettings`). The
`--sandbox` CLI flag becomes `--environment`.
All docs, example configs, `.fabro/project.toml`, and the
automation-detail / run-settings UI panels are updated to the new shape.
The run-settings page renames "Sandbox" → "Environment" and reads from
the new field paths.
### `InterviewOption` metadata fields
`description` and `preview` are added to the canonical `InterviewOption`
type (OpenAPI, helpers.ts, interview-dock, human-qa renderer). Both are
treated as untrusted model-authored text — stored and displayed as plain
strings, never rendered as HTML. The `interview-dock` test asserts that
raw HTML in `preview` is not rendered. Option `description` is shown as
secondary text under the label in choice and multi-select buttons.
### `StageModelUsage` projection
`provider_used` on `RunStageInfo` and stage projections is promoted from
a freeform object to a typed `StageModelUsage` schema (with `mode`,
`provider`, `model`, `reasoning_effort`, `speed`). The
`extractStageModel` event-scraping helper is replaced by
`formatStageModelUsageLabel` and `stageModelUsageTitle`, which work
directly from the projection field. The `Stage` interface gains
`providerUsed` and the `EventsToolbar` consumes it.
### Other schema additions
`ReasoningEffort` enum, `small_default` on model info,
`SubAgentProjection`/`SkillsProjection`/`McpServerProjection` inline in
stage projections, and `TodoListProjection` moved from the run-state
top-level `todos_by_list` map into per-stage `todos`.
### Plan summary
- Replace `[run.sandbox]` config with `[environments.<slug>]` +
`[run.environment]` selection across config, OpenAPI, UI, and docs.
- Extend `InterviewOption` with `description` and `preview`; render
`description` in choice/multi-select buttons.
- Promote `provider_used` to a typed `StageModelUsage` schema; drop
event-scraping in favour of the projection field.
- Add `ReasoningEffort`, `small_default`, subagent/skills/MCP
stage-projection schemas to OpenAPI.
### Fabro Details
<details>
<summary>Ran 9 stages in 93m 2s for $48.56</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 4s | – | 0 |
| preflight_lint | 2m 16s | – | 0 |
| implement | 39m 28s | $35.95 | 0 |
| simplify_opus | 22m 6s | $8.66 | 0 |
| simplify_gpt | 7m 18s | $1.66 | 0 |
| verify | 6m 33s | – | 0 |
| fixup | 12m 34s | $2.29 | 0 |
| **Total** | **93m 2s** | **$48.56** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Fabro <fabro@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
## Summary
Extends the Slack integration to post `run.started`, `run.completed`,
and `run.failed` notifications, configured per-run or per-workflow
through `[run.notifications]` rather than server config. Interview
behavior is unchanged and keeps its own state.
### What changed and why
**`SlackService` is now started whenever Slack credentials are
present**, regardless of whether `default_channel` is set. Previously,
the service required `default_channel` to initialize, which blocked
lifecycle notifications for users who have no interview default.
`default_channel` is now `Option<String>` and is only consulted in the
`InterviewStarted` path.
**`handle_event` receives the full `EventEnvelope` and `AppState`**
instead of just the `RunEvent`. Lifecycle handling needs to read the
cached run projection (for `[run.notifications]` routes) and scan prior
events (for PR details and the `run.started` event name), both of which
require `AppState`.
**Lifecycle path in `handle_event`** (`RunStarted` / `RunCompleted` /
`RunFailed`):
1. Reads the run projection to find enabled Slack routes whose `events`
list contains the current event name.
2. For terminal events, scans prior run events to recover
`PullRequestCreated` details and the `run.started` event name.
3. Resolves each route's channel (supporting `{{ env.VAR }}`
interpolation); warns and skips on missing/empty/unresolved channels
without affecting other routes.
4. Posts once per matching route concurrently via `join_all`; post
failures are logged, never propagated.
**`fabro-slack/src/blocks.rs`** adds `run_lifecycle_blocks` and helpers
separate from the interview builders:
- `RunLifecycleKind` uses `strum::IntoStaticStr` for the title string.
- All untrusted fields go through `escape_slack_controls` +
`truncate_to_limit`.
- `compact_duration` formats milliseconds into human-readable strings
(`1.2s`, `1m 5s`, `2h 30m`, …).
- PR line includes number, optional URL link, and optional HTML-escaped
title.
**`SlackClient::with_api_base_and_http`** is added as a test constructor
so server tests can point the client at a `MockServer` without going
through the normal builder path.
### Design decisions
- Lifecycle notifications are fire-and-forget and never touch
`posted_messages` or `thread_registry`, keeping interview and
notification state fully separate.
- `default_channel` is only used for interviews; lifecycle channel
always comes from `[run.notifications.<name>.slack].channel`. This
matches the goal of not promoting per-run config into server config.
- PR title is sourced only from prior `PullRequestCreated` events — no
GitHub API call is made at notification time. If only a
`PullRequestLink` is available in the projection, number and URL are
included but title is omitted.
- Workflow label resolution follows a priority chain: workflow name →
workflow slug → graph name → `run.started` event name → raw event name.
### Plan Summary
- Make `SlackService` start without `default_channel`; gate interview
path on `default_channel` presence.
- Add `handle_lifecycle_event` that filters routes, loads prior events,
builds blocks, resolves channels, and fans out posts.
- Add `run_lifecycle_blocks` Block Kit builder with escaping,
truncation, and `compact_duration`.
- Add server integration tests covering: started/completed/failed
posting, route filtering, missing/unresolved channel skipping, PR
details from prior events, and interview/lifecycle state isolation.
- Update public docs for Slack integration and run configuration.
### Fabro Details
<details>
<summary>Ran 9 stages in 53m 38s for $22.76</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 2s | – | 0 |
| preflight_compile | 1m 58s | – | 0 |
| preflight_lint | 2m 11s | – | 0 |
| implement | 23m 3s | $14.18 | 0 |
| simplify_opus | 16m 37s | $6.36 | 0 |
| simplify_gpt | 5m 30s | $2.23 | 0 |
| verify | 3m 31s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **53m 38s** | **$22.76** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bhelmkamp@users.noreply.github.com>
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>