mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-03 02:24:33 +00:00
## 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>
158 lines
6.8 KiB
Text
158 lines
6.8 KiB
Text
---
|
|
title: "Slack"
|
|
description: "Answer human-in-the-loop questions and receive run lifecycle notifications in Slack"
|
|
---
|
|
|
|
Fabro's Slack integration lets your team answer [human-in-the-loop](/workflows/human-in-the-loop) questions without leaving Slack and receive opt-in run lifecycle notifications. When a workflow reaches a human gate, Fabro posts an interactive message to a Slack channel with buttons for each option. Team members click a button (or reply in a thread for freeform input), and the workflow continues.
|
|
|
|
The integration uses Slack's [Socket Mode](https://api.slack.com/apis/socket-mode), so no public URL or webhook endpoint is required — Fabro connects outbound over a WebSocket.
|
|
|
|
## What it looks like
|
|
|
|
When a workflow hits a human gate like this:
|
|
|
|
```dot
|
|
approve [shape=hexagon, label="Approve Plan"]
|
|
|
|
approve -> implement [label="[A] Approve"]
|
|
approve -> plan [label="[R] Revise"]
|
|
```
|
|
|
|
Fabro posts a message to your configured Slack channel:
|
|
|
|
> **Approve Plan**
|
|
>
|
|
> stage `approve`
|
|
>
|
|
> Open in Fabro
|
|
>
|
|
> _(context from the upstream stage, when available)_
|
|
>
|
|
> `[Approve]` `[Revise]`
|
|
|
|
A team member clicks a button, the message updates to show the selection, and the workflow resumes on the chosen path.
|
|
|
|
## Supported question types
|
|
|
|
| Question type | Slack UI |
|
|
|---|---|
|
|
| **Yes/No** | Two buttons: Yes, No |
|
|
| **Confirmation** | Two buttons: Yes, No |
|
|
| **Multiple choice** | One button per option |
|
|
| **Multi-select** | Checkboxes with a Submit button |
|
|
| **Freeform** | Prompt to reply in a thread |
|
|
|
|
For freeform questions, Fabro posts a message asking the user to reply in the thread. The reply text (with any `@mention` prefix stripped) becomes the answer.
|
|
|
|
## Setup
|
|
|
|
### 1. Create a Slack App
|
|
|
|
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**
|
|
2. Choose **From scratch**, give it a name (e.g. "Fabro"), and select your workspace
|
|
|
|
### 2. Enable Socket Mode
|
|
|
|
1. In the app settings sidebar, go to **Socket Mode**
|
|
2. Toggle **Enable Socket Mode** on
|
|
3. Create an **App-Level Token** with the `connections:write` scope
|
|
4. Copy the token (starts with `xapp-`) — this is your `FABRO_SLACK_APP_TOKEN`
|
|
|
|
### 3. Add bot token scopes
|
|
|
|
1. Go to **OAuth & Permissions** in the sidebar
|
|
2. Under **Bot Token Scopes**, add:
|
|
- `chat:write` — post messages to channels
|
|
- `chat:write.public` — post to channels without joining them
|
|
|
|
### 4. Subscribe to events
|
|
|
|
1. Go to **Event Subscriptions** in the sidebar
|
|
2. Toggle **Enable Events** on
|
|
3. Under **Subscribe to bot events**, add:
|
|
- `message.channels` — receive channel messages (needed for freeform thread replies)
|
|
|
|
### 5. Install the app
|
|
|
|
1. Go to **Install App** in the sidebar
|
|
2. Click **Install to Workspace** and authorize
|
|
3. Copy the **Bot User OAuth Token** (starts with `xoxb-`) — this is your `FABRO_SLACK_BOT_TOKEN`
|
|
|
|
### 6. Configure Fabro
|
|
|
|
Add both tokens to your `.env` file:
|
|
|
|
```bash
|
|
FABRO_SLACK_BOT_TOKEN=xoxb-your-bot-token
|
|
FABRO_SLACK_APP_TOKEN=xapp-your-app-token
|
|
```
|
|
|
|
Optionally, set a default interview channel in your [server configuration](/administration/server-configuration):
|
|
|
|
```toml title="settings.toml"
|
|
[server.integrations.slack]
|
|
default_channel = "#fabro-reviews"
|
|
```
|
|
|
|
`default_channel` is used only for human-in-the-loop interview prompts. Run lifecycle notifications use per-run or per-workflow `[run.notifications]` routes instead.
|
|
|
|
### 7. Invite the bot
|
|
|
|
Invite the bot to any channel where you want it to post questions:
|
|
|
|
```
|
|
/invite @Fabro
|
|
```
|
|
|
|
Or use the `chat:write.public` scope to post to public channels without an invite.
|
|
|
|
## How it works
|
|
|
|
Fabro uses the same [web interviewer](/execution/interviews#web) that powers the web UI. When a human gate fires:
|
|
|
|
1. Fabro builds a [Block Kit](https://api.slack.com/block-kit) message from the question, stage hint, upstream context, optional run link, and answer controls
|
|
2. Posts the message to the configured Slack channel
|
|
3. The workflow blocks, waiting for an answer
|
|
|
|
When a user interacts:
|
|
|
|
- **Button click** — Slack delivers the interaction over the Socket Mode WebSocket. Fabro maps the button's action ID back to the question and submits the answer. The original message is updated to show the selection.
|
|
- **Thread reply** (freeform) — The user replies in the message thread. Fabro receives the reply as a message event, matches it to the pending question via the thread timestamp, and submits the text as the answer.
|
|
|
|
The connection is maintained with automatic reconnection and exponential backoff (1s initial, 30s max). If the WebSocket disconnects, Fabro reconnects transparently — no manual intervention needed.
|
|
|
|
## Run lifecycle notifications
|
|
|
|
Slack run lifecycle notifications are opt-in per run or workflow through the `[run.notifications]` namespace. Notification settings do not live in server config.
|
|
|
|
```toml title="workflow.toml"
|
|
[run.notifications.deploys]
|
|
enabled = true
|
|
provider = "slack"
|
|
events = ["run.started", "run.completed", "run.failed"]
|
|
|
|
[run.notifications.deploys.slack]
|
|
channel = "#deploys"
|
|
```
|
|
|
|
Each enabled route posts one message when a matching event is emitted. Lifecycle messages include the run ID, a link back to Fabro when the server has a web URL, the workflow label, result and duration for terminal events, and pull request details when the run has already created or linked a PR.
|
|
|
|
The route-level Slack channel is required for lifecycle notifications. The channel may be a literal (`"#deploys"`) or an environment interpolation (`"{{ env.DEPLOYS_SLACK_CHANNEL }}"`). If the channel is missing, empty, or cannot be resolved, Fabro logs a warning and skips that route without affecting the run or other notification routes.
|
|
|
|
Lifecycle notifications are one-way and fire-and-forget. They never accept answers, register reply threads, update prior messages, or interact with interview state.
|
|
|
|
## Environment variables
|
|
|
|
| Variable | Required | Description |
|
|
|---|---|---|
|
|
| `FABRO_SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (`xoxb-...`). Used to post and update messages. |
|
|
| `FABRO_SLACK_APP_TOKEN` | Yes | App-Level Token (`xapp-...`). Used to connect via Socket Mode. |
|
|
|
|
Both must be set for the Slack integration to activate. If either is missing or empty, Slack is disabled silently. A server-level `default_channel` is not required for lifecycle notifications; it is only needed when you want interview prompts to use a default Slack channel.
|
|
|
|
## Limitations
|
|
|
|
- **Single workspace** — Fabro connects to one Slack workspace at a time.
|
|
- **One answer per question** — The first person to click a button or reply in a thread provides the answer. Subsequent interactions on the same question are ignored.
|
|
- **No Slack steering** — Slack does not support [steering](/human-tools/steering) running agents.
|
|
- **Lifecycle notifications are one-way** — `run.started`, `run.completed`, and `run.failed` messages are posted once per matching route and are not updated later.
|