Merge branch 'main' of github.com:fabro-sh/fabro

This commit is contained in:
Bryan Helmkamp 2026-04-12 19:06:39 -04:00
commit 53b5ec6376
20 changed files with 434 additions and 19 deletions

View file

@ -1 +1 @@
6c53fc29b5f1309bfd026a883a23006db2bb441c
f67bebae4b58380b84341d57471e75a197e76e45

View file

@ -1 +1 @@
6c53fc29b5f1309bfd026a883a23006db2bb441c
f67bebae4b58380b84341d57471e75a197e76e45

View file

@ -73,7 +73,7 @@ jobs:
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@nextest
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: cargo nextest run --workspace
test-macos:
@ -88,5 +88,5 @@ jobs:
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@nextest
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: cargo nextest run --workspace

View file

@ -1,15 +1,15 @@
FROM rust:1-bookworm AS rust-builder
FROM rust:1-bookworm@sha256:4ec71e955e6c08aeb238885083222ddff79d82eb87654a96c76e38e94da1a53b AS rust-builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY lib/crates lib/crates
COPY openapi openapi
RUN cargo build --release --bin fabro
FROM oven/bun:1
FROM oven/bun:1@sha256:c9aa897b6028d54e510c4babd41bcdd481ad4a4b5030ad31135a9cabfe2c24df
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates nodejs npm && \
rm -rf /var/lib/apt/lists/*
RUN npm i -g mintlify && \
RUN npm i -g mintlify@4.2.507 && \
sed -i 's/const version = __VERSION__/const version = "0.0.0"/' \
/usr/local/lib/node_modules/mintlify/node_modules/katex/dist/katex.mjs

View file

@ -1,5 +1,5 @@
---
title: "System management API"
title: "System management API and log rotation"
date: "2026-04-06"
---
@ -14,6 +14,10 @@ fabro system events # global event stream
fabro system prune # clean up completed runs
```
## Automatic log rotation
Server logs now rotate daily and are automatically cleaned up after 7 days. Previously, log files could grow unbounded on long-running servers, requiring manual cleanup.
## More
<Accordion title="API">
@ -21,6 +25,11 @@ fabro system prune # clean up completed runs
- New `GET /api/v1/system/events` endpoint streams the global event log via SSE
- New `GET /api/v1/system/df` endpoint returns disk usage by run
- New `POST /api/v1/system/prune` endpoint removes completed run data
- Removed deprecated `GET /runs/{id}/verification`, sessions, retros, and steer endpoints
</Accordion>
<Accordion title="CLI">
- Removed `fabro skill install` command (skills are now managed through workflow definitions)
</Accordion>
<Accordion title="Improvements">

View file

@ -0,0 +1,32 @@
---
title: "Worker lifecycle hardening and object-backed artifacts"
date: "2026-04-07"
---
## Reliable worker lifecycle
Run workers now operate under tighter server supervision. The server tracks worker health, properly terminates active workers during force removal, and propagates cancellation into running command stages. Resumed runs no longer attempt to clean up stale workers from a previous session, preventing spurious errors after restarts.
## Object-backed artifact storage
Artifacts and offloaded agent context are now persisted as content-addressed objects in the global blob store instead of the local scratch directory. This makes artifacts portable across sessions and available through the server API regardless of which machine originally produced them.
## More
<Accordion title="API">
- Removed deprecated workflows and steer endpoints
</Accordion>
<Accordion title="CLI">
- `fabro run attach` now streams events over SSE for more reliable output
</Accordion>
<Accordion title="Improvements">
- Runs now record creation provenance (CLI, API, or scheduled trigger)
</Accordion>
<Accordion title="Fixes">
- Fixed `fabro run attach` not following paginated event streams
- Fixed terminal runs blocking deletion during the grace period
- Fixed cancelled workers not stopping in-progress command stages
</Accordion>

View file

@ -0,0 +1,31 @@
---
title: "Embedded web UI and fabro uninstall"
date: "2026-04-08"
---
## Embedded web UI
The web dashboard is now bundled directly into the Fabro server binary. When you run `fabro server start`, the UI is available immediately at the server's address with no separate build step or dev server required. The web UI is optional and can be disabled in server settings. A demo mode toggle lets you explore the interface with sample data without connecting to a live workspace.
## `fabro uninstall`
A new `fabro uninstall` command cleanly removes Fabro's local state, server data, and configuration files. Previously, uninstalling required manually tracking down scattered directories.
```bash
fabro uninstall # interactive confirmation
fabro uninstall --force # skip confirmation
```
## More
<Accordion title="CLI">
- Added host-only TCP bind support for the server (e.g., `127.0.0.1:8080` without TLS)
</Accordion>
<Accordion title="Fixes">
- Fixed GitHub App setup flow failing on nullable webhook secrets, duplicate POST requests, and incorrect port detection
- Fixed session cookie decryption errors on server restart
- Fixed devcontainer lifecycle commands not being cancelled during shutdown
- Fixed setup commands continuing to run after the server received a shutdown signal
- Fixed dark theme not being selected by default for new users
</Accordion>

View file

@ -0,0 +1,31 @@
---
title: "Settings v2 and events schema v2"
date: "2026-04-09"
---
## Settings v2
The TOML configuration format has been redesigned for clarity and consistency. Settings are now organized into logical sections (`[run]`, `[server]`, `[cli]`, `[hooks]`, `[sandbox]`, `[mcp]`) with typed fields throughout. Run goals can now reference external files using a tagged union syntax, so you can keep large goal prompts separate from your workflow config.
```toml
[run]
goal = { file = "goals/deploy-review.md" }
model = "claude-sonnet-4-6"
[server]
host = "127.0.0.1"
port = 8080
```
Comments in your `settings.toml` are now preserved when Fabro edits the file during setup and registration.
## Events schema v2
The event wire format has been flattened and enriched. Each event envelope now carries stage scope (visit count, parallel group and branch IDs) and actor information directly, rather than requiring consumers to reconstruct context from surrounding events. The TypeScript API client has been regenerated to match.
## More
<Accordion title="Improvements">
- Server auth resolver now fails closed instead of panicking on unexpected states
- Config parse errors now include the file path for easier debugging
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "GitHub App owner selection"
date: "2026-04-10"
---
## GitHub App owner selection
When running `fabro install`, you can now choose whether to install the GitHub App under your personal account or an organization. Previously, the app was always installed under the authenticated user's personal account, requiring manual reconfiguration for org-level access.
## More
<Accordion title="Fixes">
- Fixed an infinite loop when a goal-gate retry targeted a terminal node
- Fixed detached workers not exiting cleanly after post-run shutdown
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "Unified template syntax"
date: "2026-04-11"
---
## Unified template syntax
Workflow definitions and configuration files now share the same template syntax for variable interpolation. Previously, workflow DOT files and TOML config files used slightly different templating rules, which made it easy to use the wrong syntax in the wrong context. The unified syntax works everywhere Fabro processes templates.
## More
<Accordion title="Improvements">
- Project state files moved from the workspace root into `.fabro/` for a cleaner directory layout
- Config parse errors now show the file path where the error occurred
</Accordion>

View file

@ -0,0 +1,15 @@
---
title: "Typed secrets and fabro pr create --force"
date: "2026-04-12"
---
## Typed secrets
Secrets now carry metadata describing their type and purpose. The new secrets API lets you inspect which secrets are configured, what they're used for, and whether they're present — without exposing the values themselves. This makes it easier to diagnose missing credentials and understand what a workflow needs before running it.
## More
<Accordion title="CLI">
- Added `--force` option to `fabro pr create` to skip confirmation prompts
- `fabro doctor` now checks that the storage directory exists and is writable
</Accordion>

View file

@ -251,6 +251,12 @@
"group": "April 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-04-12",
"changelog/2026-04-11",
"changelog/2026-04-10",
"changelog/2026-04-09",
"changelog/2026-04-08",
"changelog/2026-04-07",
"changelog/2026-04-06",
"changelog/2026-04-04",
"changelog/2026-04-02",

View file

@ -45,7 +45,9 @@ The rest of this page describes the `app` strategy, which is required for browse
1. Navigate to the web app (default `http://localhost:3000`). If no GitHub App is configured, you'll be redirected to the setup page automatically.
2. Click **Register GitHub App**. This takes you to GitHub with a pre-filled [App Manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) containing:
2. Choose where to register the app. If you have the `gh` CLI installed, Fabro detects your GitHub username and any organizations you administer and lets you pick. For organization-owned apps, the app is registered under that org's settings. If `gh` is not available, the app is registered under your personal account.
3. Click **Register GitHub App**. This takes you to GitHub with a pre-filled [App Manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) containing:
| Permission | Level | Purpose |
|---|---|---|
@ -56,15 +58,15 @@ The rest of this page describes the `app` strategy, which is required for browse
| Issues | Write | Create issues from workflows |
| Emails | Read | Read verified email for OAuth login |
3. Review the permissions on GitHub and click **Create GitHub App**.
4. Review the permissions on GitHub and click **Create GitHub App**.
4. GitHub redirects back to Fabro, which automatically:
5. GitHub redirects back to Fabro, which automatically:
- Exchanges the temporary code for permanent app credentials
- Writes `app_id`, `client_id`, and `slug` to `~/.fabro/settings.toml`
- Stores `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` in `<data_dir>/server.env`
- Marks the change as restart-bound so the server must be restarted before login
5. **Install the app** on your GitHub account or organization. Go to `https://github.com/settings/apps/<your-app-slug>/installations` and install it on the repositories Fabro should access.
6. **Install the app** on your GitHub account or organization. Go to `https://github.com/settings/apps/<your-app-slug>/installations` and install it on the repositories Fabro should access.
### Verify the configuration

View file

@ -414,14 +414,16 @@ Create a GitHub pull request from a completed workflow run. Uses the run's persi
```bash
fabro pr create <run-id>
fabro pr create <run-id> --model claude-opus-4-6
fabro pr create <run-id> --force
```
| Argument / Flag | Description |
|---|---|
| `<RUN_ID>` | Run ID or prefix (required) |
| `--model <MODEL>` | LLM model for generating the PR description |
| `-f, --force` | Create PR even if the run status is not success or partial success |
The run must have completed successfully (or with partial success) and have a stored diff with changes.
The run must have completed successfully (or with partial success) and have a stored diff with changes. Use `--force` to override the status check.
### `fabro pr list`
@ -823,6 +825,21 @@ fabro install --web-url https://fabro.example.com
|---|---|---|
| `--web-url <URL>` | Web UI base URL for OAuth callback endpoints | `http://localhost:3000` |
When configuring a GitHub App, the wizard detects your GitHub organizations (via the `gh` CLI) and lets you choose whether to register the app under your personal account or an organization.
## `fabro uninstall`
Remove Fabro's local state, server data, and configuration files. Stops the server if it is running, removes the data directory, cleans up shell PATH entries, and optionally removes the binary.
```bash
fabro uninstall
fabro uninstall --yes
```
| Flag | Description |
|---|---|
| `--yes` | Skip confirmation prompt |
---
## `fabro secret set`
@ -840,10 +857,11 @@ fabro secret set ANTHROPIC_API_KEY sk-ant-...
## `fabro secret list`
List server-owned secret names. Values are never returned after storage.
List server-owned secrets with their name, type, and last-updated timestamp. Values are never returned after storage.
```bash
fabro secret list
fabro secret list --json
```
## `fabro secret rm`

View file

@ -672,6 +672,9 @@ pub(crate) struct PrCreateArgs {
/// LLM model for generating PR description
#[arg(long)]
pub(crate) model: Option<String>,
/// Create PR even if the run status is not success/partial_success
#[arg(short, long)]
pub(crate) force: bool,
}
#[derive(Args)]

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use anyhow::Result;
@ -21,6 +21,7 @@ use tokio::process::Command as TokioCommand;
use crate::args::{DoctorArgs, GlobalArgs};
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
use crate::user_config;
pub(crate) struct DepSpec {
pub name: &'static str,
@ -231,6 +232,64 @@ fn check_legacy_env(path: Option<PathBuf>) -> CheckResult {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StorageDirStatus {
path: PathBuf,
exists: bool,
readable: bool,
writable: bool,
}
fn probe_storage_dir(path: &Path) -> StorageDirStatus {
let exists = path.is_dir();
let readable = exists && std::fs::read_dir(path).is_ok();
let writable = exists && tempfile::tempfile_in(path).is_ok();
StorageDirStatus {
path: path.to_path_buf(),
exists,
readable,
writable,
}
}
fn check_storage_dir(status: &StorageDirStatus) -> CheckResult {
let display = status.path.display();
let details = vec![
CheckDetail::new(format!(
"Exists: {}",
if status.exists { "yes" } else { "no" }
)),
CheckDetail::new(format!(
"Readable: {}",
if status.readable { "yes" } else { "no" }
)),
CheckDetail::new(format!(
"Writable: {}",
if status.writable { "yes" } else { "no" }
)),
];
let is_healthy = status.exists && status.readable && status.writable;
CheckResult {
name: "Storage directory".to_string(),
status: if is_healthy {
CheckStatus::Pass
} else {
CheckStatus::Error
},
summary: display.to_string(),
details,
remediation: if is_healthy {
None
} else if !status.exists {
Some(format!("Create the directory: mkdir -p {display}"))
} else {
Some(format!("Fix permissions on {display}"))
},
}
}
fn check_version_parity(server_version: &str) -> CheckResult {
let cli_version = FABRO_VERSION;
if server_version == cli_version {
@ -351,6 +410,11 @@ pub(crate) async fn run_doctor(
p.exists().then_some(p)
};
let settings = user_config::load_settings().unwrap_or_default();
let storage_dir_path = user_config::storage_dir(&settings)
.unwrap_or_else(|_| fabro_util::Home::from_env().storage_dir());
let storage_dir = probe_storage_dir(&storage_dir_path);
let mut report = CheckReport {
title: "Fabro Doctor".to_string(),
sections: vec![CheckSection {
@ -362,6 +426,7 @@ pub(crate) async fn run_doctor(
.then_some(settings_config_path),
&legacy_config_paths,
),
check_storage_dir(&storage_dir),
check_legacy_env(legacy_env_path),
],
}],
@ -529,6 +594,81 @@ mod tests {
assert!(result.summary.contains("legacy secrets file"));
}
// -- check_storage_dir --
#[test]
fn probe_storage_dir_existing_dir_is_readable_and_writable() {
let dir = tempfile::tempdir().unwrap();
let status = probe_storage_dir(dir.path());
assert_eq!(status, StorageDirStatus {
path: dir.path().to_path_buf(),
exists: true,
readable: true,
writable: true,
});
}
#[test]
fn probe_storage_dir_missing_dir_is_not_usable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing");
let status = probe_storage_dir(&path);
assert_eq!(status, StorageDirStatus {
path,
exists: false,
readable: false,
writable: false,
});
}
#[test]
fn check_storage_dir_pass() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/home/user/.fabro"),
exists: true,
readable: true,
writable: true,
});
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.summary, "/home/user/.fabro");
assert!(result.remediation.is_none());
assert_eq!(result.details.len(), 3);
}
#[test]
fn check_storage_dir_not_exists() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/tmp/nonexistent-fabro-doctor-test-xyz"),
exists: false,
readable: false,
writable: false,
});
assert_eq!(result.status, CheckStatus::Error);
assert!(result.summary.contains("nonexistent-fabro-doctor-test-xyz"));
assert!(result.remediation.as_deref().unwrap().contains("mkdir -p"));
}
#[test]
fn check_storage_dir_not_writable() {
let result = check_storage_dir(&StorageDirStatus {
path: PathBuf::from("/home/user/.fabro"),
exists: true,
readable: true,
writable: false,
});
assert_eq!(result.status, CheckStatus::Error);
assert_eq!(result.summary, "/home/user/.fabro");
assert_eq!(
result.remediation.as_deref(),
Some("Fix permissions on /home/user/.fabro")
);
}
#[test]
fn check_version_parity_warns_on_mismatch() {
let result = check_version_parity("0.0.0-test");

View file

@ -42,6 +42,9 @@ pub(super) async fn create_command(
match conclusion.status {
StageStatus::Success | StageStatus::PartialSuccess => {}
status if args.force => {
tracing::warn!("Run status is '{status}', proceeding because --force was specified");
}
status => bail!("Run status is '{status}', expected success or partial_success"),
}

View file

@ -223,8 +223,10 @@ async fn main_inner() -> (String, Result<()>) {
.output
.verbosity
== OutputVerbosity::Verbose;
let exit_code =
commands::doctor::run_doctor(&args, verbose, &globals, printer).await?;
let exit_code = Box::pin(commands::doctor::run_doctor(
&args, verbose, &globals, printer,
))
.await?;
std::process::exit(exit_code);
}
Commands::Discord => {
@ -247,7 +249,7 @@ async fn main_inner() -> (String, Result<()>) {
}
Commands::Repo(ns) => commands::repo::dispatch(ns, &globals, printer).await?,
Commands::Install(args) => {
commands::install::run_install(&args, &globals, printer).await?;
Box::pin(commands::install::run_install(&args, &globals, printer)).await?;
}
Commands::Uninstall(args) => {
commands::uninstall::run_uninstall(&args, &globals, printer).await?;

View file

@ -1,6 +1,6 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run, setup_failed_run};
#[test]
fn help() {
@ -23,6 +23,7 @@ fn help() {
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--model <MODEL> LLM model for generating PR description
-f, --force Create PR even if the run status is not success/partial_success
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
@ -79,3 +80,35 @@ fn pr_create_uses_store_run_record_without_run_json() {
error: Run has no run_branch was it run with git push enabled?
");
}
#[test]
fn pr_create_failed_run_rejects_without_force() {
let context = test_context!();
let run = setup_failed_run(&context);
let mut cmd = context.command();
cmd.args(["pr", "create", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Run status is 'fail', expected success or partial_success
");
}
#[test]
fn pr_create_failed_run_proceeds_with_force() {
let context = test_context!();
let run = setup_failed_run(&context);
let mut cmd = context.command();
cmd.args(["pr", "create", "--force", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Run has no run_branch was it run with git push enabled?
");
}

View file

@ -378,6 +378,66 @@ worktree_mode = "never"
WorkspaceRunSetup { run, workspace_dir }
}
pub(crate) fn setup_failed_run(context: &TestContext) -> RunSetup {
let workspace_dir = context.temp_dir.join("failed-run");
std::fs::create_dir_all(&workspace_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display()));
write_text_file(
&workspace_dir.join("fail.fabro"),
r#"digraph Fail {
graph [goal="Always fail", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
boom [shape=parallelogram, script="exit 1", goal_gate=true]
start -> boom -> exit
}
"#,
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"_version = 1
[workflow]
graph = "fail.fabro"
[run]
goal = "Always fail"
[run.sandbox]
provider = "local"
[run.sandbox.local]
worktree_mode = "never"
"#,
);
// The workflow is expected to fail (script exits 1), but the CLI may still
// exit 0. The `pr create` tests verify that the conclusion has a fail status.
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&workspace_dir);
cmd.timeout(COMMAND_TIMEOUT);
cmd.env("OPENAI_API_KEY", "test");
cmd.args([
"--run-id",
run_id.as_str(),
"--auto-approve",
"--no-retro",
"--sandbox",
"local",
"--provider",
"openai",
"run.toml",
]);
let _output = cmd.output().expect("command should execute");
RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
}
}
fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup {
let run_id = unique_run_id();
let mut cmd = context.run_cmd();