mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
## Summary
Ships the Ask Fabro sidebar on run pages end-to-end: the agent now has
live `fabro_run_interact` and `fabro_run_events` tools scoped to its
owning run, and the web sidebar talks to real session APIs instead of a
scripted adapter. The `?ask=1` prototype gate is dropped in favour of
server-reported `run.ask_fabro.available`.
## What changed and why
### Rust — run-control tools in Ask Fabro sessions (`fabro-server`,
`fabro-workflow`, `fabro-tool`)
**Tool registration** (`fabro-workflow`): `register_fabro_run_tools` is
now `pub`; a new `register_named_fabro_run_tools` variant accepts a name
allowlist so callers can register a subset without forking the catalog
loop. Unknown names are silently ignored.
**Run-scoped backend** (`fabro-tool`): `ClientBackend` gains a
`run_scope: Option<RunId>` field set via `.with_run_scope(run_id)`.
Every method checks the scope before delegating to the HTTP client,
returning an error before a network call is made. `list_store_runs`
returns a single-element vec of the owning run when scoped;
`resolve_run` rejects non-parseable selectors rather than forwarding
them.
**Session wiring** (`fabro-server`): `build_profile` now returns
`Box<dyn AgentProfile>` (mutably accessible) instead of `Arc`;
`build_agent_session` mints a same-run worker token, builds a
`ClientBackend::with_run_scope`, constructs `FabroRunToolServices`, and
calls `register_named_fabro_run_tools` for the two tools before freezing
into an `Arc`. `AppState::self_server_target()` reads the bound address
from the runtime daemon record for the loopback HTTP call.
**Approval gate**: `build_ask_fabro_tool_approval` now fast-paths
`fabro_run_interact` and `fabro_run_events` to `Ok(())`; all other tools
remain subject to the `ReadOnly` auto-approve check. File/shell tools
are still denied.
### Web — real session adapter and sidebar wiring (`fabro-web`)
**`ask-fabro-runtime.ts`** (new): a `ChatModelAdapter` that creates a
session lazily on the first turn (`sessionsApi.createRunSession`),
caches the session id in `sessionStorage` keyed by run id, and streams
turns via `streamSessionTurn`. `applyTurnEvent` maps `run.session.*` SSE
events to assistant-ui `ThreadAssistantMessagePart[]` incrementally
(text deltas, tool-call started/completed pairs). A 404 on stream clears
the cached id so the next turn starts fresh.
**`ask-fabro-sidebar.tsx`**: drops `scriptIndexRef`, `EMPTY_CHAT`, and
the scripted adapter import; accepts `runId` and `defaultModel` props;
constructs the real adapter via `createAskFabroAdapter`.
**`run-detail.tsx`**: removes `?ask=1` / `askEnabled`; reads
`run.ask_fabro.{available, default_model}` from the summary; always
renders an `AskFabroTriggerButton` (disabled with a tooltip when
unavailable); passes `runId` and `defaultModel` to `<AskFabroSidebar>`.
### Architecture
```mermaid
graph TB
Browser -->|SSE turn stream| SessionsHandler
SessionsHandler -->|spawn| AskFabroAgent
AskFabroAgent -->|fabro_run_interact\nfabro_run_events| ClientBackend
ClientBackend -->|HTTP + same-run\nworker token| RunsAPI[Runs API\n/runs/:id]
ClientBackend -->|run_scope check| ClientBackend
RunsAPI -->|403 cross-run| ClientBackend
```
### Design decisions
- **Same-run scoping is double-enforced**: the `ClientBackend` scope
check fires before the HTTP call; the worker token's run scope causes a
403 at the API layer if the check were somehow bypassed.
- **`build_profile` → `Box` not `Arc`**: the profile needs mutable
access for tool registration after construction, so the `Arc` wrapping
is deferred until registration is complete.
- **`sessionStorage` per-run**: one session is reused across sidebar
open/close cycles for the same run tab; a page reload or different run
always starts clean.
- **Mutating actions included**: `interact` exposes
start/cancel/steer/archive/answer. This is intentional per the locked
decisions; the worker-token scope prevents cross-run blast radius.
### Fabro Details
<details>
<summary>Ran 9 stages in 74m 52s for $44.11</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 11s | – | 0 |
| preflight_lint | 2m 22s | – | 0 |
| implement | 38m 37s | $32.02 | 0 |
| simplify_opus | 17m 40s | $6.55 | 0 |
| simplify_gpt | 9m 32s | $5.55 | 0 |
| verify | 3m 43s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **74m 52s** | **$44.11** | **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."]
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: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: fabro <fabro@example.com>
Co-authored-by: fabro <fabro@fabro.sh>
116 lines
3.5 KiB
TOML
116 lines
3.5 KiB
TOML
[package]
|
|
name = "fabro-server"
|
|
edition.workspace = true
|
|
version.workspace = true
|
|
publish = false
|
|
license.workspace = true
|
|
description = "HTTP server for Fabro pipelines"
|
|
|
|
[lib]
|
|
doctest = false
|
|
|
|
[features]
|
|
test-support = []
|
|
|
|
[[test]]
|
|
name = "it"
|
|
path = "tests/it/main.rs"
|
|
required-features = ["test-support"]
|
|
|
|
[lints]
|
|
workspace = true
|
|
|
|
[dependencies]
|
|
fabro-auth = { path = "../fabro-auth" }
|
|
fabro-install = { path = "../fabro-install" }
|
|
fabro-spa = { path = "../fabro-spa" }
|
|
fabro-config = { path = "../fabro-config" }
|
|
fabro-graphviz = { path = "../fabro-graphviz" }
|
|
fabro-hooks = { path = "../fabro-hooks" }
|
|
fabro-interview = { path = "../fabro-interview" }
|
|
fabro-slack = { path = "../fabro-slack" }
|
|
fabro-workflow = { path = "../fabro-workflow" }
|
|
fabro-validate = { path = "../fabro-validate" }
|
|
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker"] }
|
|
fabro-github = { path = "../fabro-github" }
|
|
fabro-agent = { path = "../fabro-agent" }
|
|
fabro-llm = { path = "../fabro-llm" }
|
|
fabro-manifest = { path = "../fabro-manifest" }
|
|
fabro-model = { path = "../fabro-model" }
|
|
fabro-proc = { path = "../fabro-proc" }
|
|
fabro-tool = { path = "../fabro-tool" }
|
|
fabro-types = { path = "../fabro-types" }
|
|
fabro-util = { path = "../fabro-util" }
|
|
fabro-api = { path = "../fabro-api" }
|
|
fabro-client = { path = "../fabro-client" }
|
|
fabro-store = { path = "../fabro-store" }
|
|
fabro-vault = { path = "../fabro-vault" }
|
|
fabro-http.workspace = true
|
|
fabro-redact.workspace = true
|
|
fabro-static.workspace = true
|
|
chrono.workspace = true
|
|
futures-util.workspace = true
|
|
axum = { workspace = true, features = ["ws"] }
|
|
axum-extra.workspace = true
|
|
cookie.workspace = true
|
|
dirs.workspace = true
|
|
globset.workspace = true
|
|
tower = "0.5"
|
|
tower-http = { version = "0.6", features = ["trace"] }
|
|
tokio-stream = { workspace = true, features = ["sync"] }
|
|
tokio-util.workspace = true
|
|
base64.workspace = true
|
|
jsonwebtoken.workspace = true
|
|
hkdf.workspace = true
|
|
tokio.workspace = true
|
|
serde.workspace = true
|
|
serde_json.workspace = true
|
|
serde_yaml = "0.9"
|
|
anyhow.workspace = true
|
|
clap.workspace = true
|
|
toml.workspace = true
|
|
toml_edit.workspace = true
|
|
tracing.workspace = true
|
|
ulid.workspace = true
|
|
uuid.workspace = true
|
|
hmac.workspace = true
|
|
sha2.workspace = true
|
|
hex.workspace = true
|
|
rand.workspace = true
|
|
bytes = "1"
|
|
tempfile = "3"
|
|
object_store.workspace = true
|
|
# reqwest 0.12 is a direct dep only so `NoProxyReqwestConnector` can build a
|
|
# `reqwest::Client` of the version object_store accepts. Rest of the crate
|
|
# talks to HTTP via fabro-http (reqwest 0.13).
|
|
object_store_reqwest = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }
|
|
mime_guess.workspace = true
|
|
regex.workspace = true
|
|
semver.workspace = true
|
|
walkdir.workspace = true
|
|
multer = "3"
|
|
thiserror.workspace = true
|
|
ipnet = "2.11.0"
|
|
percent-encoding.workspace = true
|
|
url = "2"
|
|
zeroize.workspace = true
|
|
strum.workspace = true
|
|
sysinfo = { version = "0.35.2", default-features = false, features = ["system", "disk"] }
|
|
|
|
[build-dependencies]
|
|
fabro-build-support = { path = "../build-support" }
|
|
chrono = { workspace = true }
|
|
|
|
[dev-dependencies]
|
|
tokio = { workspace = true, features = ["test-util", "macros"] }
|
|
tower = "0.5"
|
|
http-body-util = "0.1"
|
|
httpmock = "0.8"
|
|
serde_yaml = "0.9"
|
|
tracing-subscriber.workspace = true
|
|
async-trait.workspace = true
|
|
tokio-util.workspace = true
|
|
fabro-macros = { path = "../fabro-macros" }
|
|
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
|
|
fabro-test = { workspace = true }
|
|
fabro-types = { path = "../fabro-types", features = ["test-support"] }
|