Merge remote-tracking branch 'origin/main'

# Conflicts:
#	lib/crates/fabro-types/src/lib.rs
#	lib/crates/fabro-types/src/status.rs
This commit is contained in:
Bryan Helmkamp 2026-04-22 17:59:59 -04:00
commit e34affcf94
No known key found for this signature in database
58 changed files with 643 additions and 948 deletions

View file

@ -111,6 +111,20 @@ When interpolating values into shell command strings (in `fabro-workflow`), alwa
- **Functions**: import the parent module, call as `module::function()``use fabro_workflow::operations; operations::create(...)`
- **No glob imports** in production code (`use foo::*`). Globs are acceptable in test modules and preludes. Enforced by clippy `wildcard_imports` lint.
## Enum string/int conversions (strum)
For any enum where a variant maps to a fixed string or integer, derive it with `strum` instead of hand-writing `impl Display`, `impl FromStr`, `as_str()`, `fn all()`, or `const ALL: &[Self]`. Hand-written variant→string maps drift across the three impls on every rename.
- `strum::Display` replaces hand-written `impl fmt::Display` whose body is a match over string literals.
- `strum::EnumString` replaces hand-written `impl FromStr`. The `Err` type becomes `strum::ParseError` — adjust callers that assumed `Err = String`.
- `strum::IntoStaticStr` replaces `impl From<E> for &'static str`. When an existing `as_str(self) -> &'static str` is on the public API, keep it as a one-line wrapper: `pub fn as_str(self) -> &'static str { self.into() }`.
- `strum::EnumIter`, `strum::VariantArray`, `strum::VariantNames` replace hand-written `fn all()` / `const ALL` / `&[&'static str]` arrays. Do NOT use these if the hand-written list intentionally excludes variants (e.g. `Provider::ALL` skips `OpenAiCompatible`).
- `strum::FromRepr` replaces `fn from_u8`/`from_i32`. Note: it returns `Option<Self>`, so don't adopt it when the existing conversion has a `_ => default` fallback — that's a behavior change, not a cleanup.
Align strum with serde. When the enum also derives `Serialize`/`Deserialize` with `#[serde(rename_all = "...")]`, add the matching `#[strum(serialize_all = "...")]`. For variant aliases, use `#[strum(to_string = "canonical", serialize = "alias")]` — strum picks the last `serialize` for `Display`/`IntoStaticStr` otherwise, so `to_string` is needed to pin the canonical form.
Skip strum when parsing is fuzzy (URL/path detection, structured IDs, multi-token formats), when a variant carries a `String` catch-all, or when `Display` does dynamic formatting.
## Snapshot tests (insta)
Many CLI tests use `insta` inline snapshots. When a snapshot needs updating:

27
Cargo.lock generated
View file

@ -1719,6 +1719,7 @@ dependencies = [
"chrono",
"clap",
"dirs",
"fabro-proc",
"fabro-types",
"fabro-util",
"ipnet",
@ -1789,6 +1790,7 @@ dependencies = [
"nom",
"regex",
"serde",
"strum",
"thiserror 2.0.18",
]
@ -1873,6 +1875,7 @@ dependencies = [
"rand 0.9.4",
"serde",
"serde_json",
"strum",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@ -1913,6 +1916,7 @@ dependencies = [
"insta",
"serde",
"serde_json",
"strum",
]
[[package]]
@ -1983,6 +1987,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"strum",
"tar",
"tempfile",
"tokio",
@ -2206,6 +2211,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"strum",
"tempfile",
"toml 0.8.23",
"ulid",
@ -6289,6 +6295,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"

View file

@ -80,6 +80,7 @@ percent-encoding = "2"
minijinja = "2"
fabro-http = { path = "lib/crates/fabro-http" }
graphviz-sys = { git = "https://github.com/fabro-sh/graphviz-sys" }
strum = { version = "0.28", features = ["derive"] }
[workspace.lints.rust]
unsafe_code = "deny"

View file

@ -9,6 +9,7 @@ AI coding agents are powerful but unpredictable. You either babysit every step o
[![Rust](https://github.com/fabro-sh/fabro/actions/workflows/rust.yml/badge.svg)](https://github.com/fabro-sh/fabro/actions/workflows/rust.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE.md)
[![docs](https://img.shields.io/badge/docs-fabro.sh-357F9E)](https://docs.fabro.sh)
![Discord](https://img.shields.io/discord/1256822430505373696)
```bash
# With Claude Code

View file

@ -50,7 +50,7 @@ After each node, the metadata branch is updated with:
- **`run.json`** — Refreshed projection snapshot with the new current checkpoint
- **`stages/{node_id}@{visit}/...`** — Per-stage execution trace files (prompts, responses, status, diffs, stdout/stderr, and tool metadata)
- **`retro/*.md`** — Retro prompt/response text when present
- **`stages/retro/*.md`** — Retro prompt/response text when present
## What's in a checkpoint

View file

@ -143,4 +143,4 @@ Retros are also available via the REST API. See the [list retros](/api-reference
## Storage
Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes retro text under `retro/` alongside `run.json`, stage files, and the rest of the exported run data.
Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes retro text under `stages/retro/` alongside `run.json`, stage files, and the rest of the exported run data.

View file

@ -38,14 +38,14 @@ Reconstructed metadata branches and `fabro store dump` exports now use the same
- `run.json` for the current projection snapshot, including the current checkpoint
- `graph.fabro` for workflow source
- `retro/*.md` for retro prompt/response text
- `stages/retro/*.md` for retro prompt/response text
- `stages/{node_id}@{visit}/...` for per-stage prompt, response, status, diff, stdout, and stderr files
`fabro store dump` adds export-only history surfaces on top of that shared layout:
- `events.jsonl` for the durable event stream
- `checkpoints/*.json` for checkpoint history snapshots
- `artifacts/nodes/{node_id}/visit-{n}/...` for exported artifact payloads
- `artifacts/{node_id}@{visit}/...` for exported artifact payloads
## Browsing runs

View file

@ -420,12 +420,10 @@ pub async fn run_with_args_and_client(
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
// Parse provider string to enum early for compile-time safety
let provider: Provider = args
.provider
.as_deref()
.unwrap_or("anthropic")
let provider_str = args.provider.as_deref().unwrap_or("anthropic");
let provider: Provider = provider_str
.parse()
.map_err(|e: String| anyhow::anyhow!("{e}"))?;
.map_err(|_| anyhow::anyhow!("unknown provider: {provider_str}"))?;
// Build LLM client — use provided client or create from env
let mut client = if let Some(c) = llm_client {

View file

@ -390,12 +390,12 @@ mod tests {
store
.write_snapshot(
&run_id,
&[("retro/prompt.md", b"how did it go?")],
&[("stages/retro/prompt.md", b"how did it go?")],
"finalize run",
)
.unwrap();
let data = branch_entry(dir.path(), &run_id, "retro/prompt.md");
let data = branch_entry(dir.path(), &run_id, "stages/retro/prompt.md");
assert_eq!(data, b"how did it go?");
let spec = MetadataStore::read_run_spec(dir.path(), &run_id)

View file

@ -1301,10 +1301,6 @@ pub(crate) struct ServerServeArgs {
#[command(flatten)]
pub(crate) storage_dir: StorageDirArgs,
/// Path to the server record file
#[arg(long)]
pub(crate) record_path: PathBuf,
#[command(flatten)]
pub(crate) serve_args: ServeArgs,
}

View file

@ -21,6 +21,8 @@ use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_for};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
use fabro_config::{ResolveError, Storage, envfile};
use fabro_install::{
@ -28,7 +30,6 @@ use fabro_install::{
write_github_app_settings, write_token_settings,
};
use fabro_model::Provider;
use fabro_server::bind::Bind;
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::settings::cli::{CliLayer, OutputFormat};
@ -51,7 +52,7 @@ use crate::args::{
DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs,
InstallNonInteractiveArgs, ServerTargetArgs,
};
use crate::commands::server::{record, start, stop};
use crate::commands::server::{start, stop};
use crate::gh::GhCli;
use crate::shared::provider_auth::{
ApiKeySource, authenticate_provider, authenticate_provider_with_api_key_source,
@ -1152,7 +1153,7 @@ fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)])
return Ok(());
}
let env_path = Storage::new(storage_dir).runtime_state().env_path();
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&env_path, secrets.iter().cloned())
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
Ok(())
@ -1214,7 +1215,7 @@ fn persist_github_install_changes(
writes: &PendingGitHubInstallWrite<'_>,
) -> Result<()> {
let storage = Storage::new(storage_dir);
let server_env_path = storage.runtime_state().env_path();
let server_env_path = storage.runtime_directory().env_path();
let vault_path = storage.secrets_path();
let previous_server_env = std::fs::read_to_string(&server_env_path).ok();
let previous_vault = std::fs::read_to_string(&vault_path).ok();
@ -1490,7 +1491,8 @@ async fn run_install_github_inner(
.clone_path()
.unwrap_or_else(default_storage_dir)
});
let server_was_running = record::active_server_record(&storage_dir)?.is_some();
let server_was_running =
ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some();
let mut doc: toml::Value = toml::from_str(&existing_config_contents)
.context("failed to parse existing settings.toml")?;
@ -1580,7 +1582,9 @@ async fn run_install_github_inner(
.contains(&ServerAuthMethod::DevToken)
.then(|| {
dev_token::read_dev_token_file(
&Storage::new(&storage_dir).runtime_state().dev_token_path(),
&Storage::new(&storage_dir)
.runtime_directory()
.dev_token_path(),
)
})
.flatten();
@ -1638,7 +1642,8 @@ async fn run_install_inner(
let emoji = console::Emoji("⚒️ ", "");
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = local_server::storage_dir(&cli_settings)?;
let server_was_running = record::active_server_record(&storage_dir)?.is_some();
let server_was_running =
ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some();
let fabro_dir = fabro_util::Home::from_env().root().to_path_buf();
let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME);
let existing_config_contents = std::fs::read_to_string(&config_path).ok();
@ -1827,7 +1832,9 @@ async fn run_install_inner(
&fabro_util::Home::from_env().dev_token_path(),
)?;
dev_token::write_dev_token(
&Storage::new(&storage_dir).runtime_state().dev_token_path(),
&Storage::new(&storage_dir)
.runtime_directory()
.dev_token_path(),
&token,
)?;
fabro_util::printerr!(
@ -1878,7 +1885,7 @@ async fn run_install_inner(
" {} Saved {} runtime secrets to {}",
s.green.apply_to(""),
server_env_pairs.len(),
path::contract_tilde(&Storage::new(&storage_dir).runtime_state().env_path()).display()
path::contract_tilde(&Storage::new(&storage_dir).runtime_directory().env_path()).display()
);
fabro_util::printerr!(
printer,
@ -1913,7 +1920,9 @@ async fn run_install_inner(
.contains(&ServerAuthMethod::DevToken)
.then(|| {
dev_token::read_dev_token_file(
&Storage::new(&storage_dir).runtime_state().dev_token_path(),
&Storage::new(&storage_dir)
.runtime_directory()
.dev_token_path(),
)
})
.flatten();
@ -2577,7 +2586,8 @@ client_id = "client-id"
.unwrap();
let server_env =
std::fs::read_to_string(Storage::new(dir.path()).runtime_state().env_path()).unwrap();
std::fs::read_to_string(Storage::new(dir.path()).runtime_directory().env_path())
.unwrap();
assert!(server_env.contains("SESSION_SECRET=session"));
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key"));
assert_eq!(created.calls_async().await, 2);
@ -2833,7 +2843,12 @@ client_id = "client-id"
.await;
assert!(result.is_err());
assert!(Storage::new(dir.path()).runtime_state().env_path().exists());
assert!(
Storage::new(dir.path())
.runtime_directory()
.env_path()
.exists()
);
assert!(!settings_path.exists());
assert!(stop_called.load(Ordering::SeqCst));
}
@ -2877,7 +2892,7 @@ client_id = "client-id"
fn persist_github_install_changes_replaces_app_env_keys_with_token_secret() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::new(dir.path());
let server_env_path = storage.runtime_state().env_path();
let server_env_path = storage.runtime_directory().env_path();
envfile::write_env_file(
&server_env_path,
&std::collections::HashMap::from([
@ -2939,7 +2954,7 @@ client_id = "client-id"
fn persist_github_install_changes_replaces_token_secret_with_app_env_keys() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::new(dir.path());
let server_env_path = storage.runtime_state().env_path();
let server_env_path = storage.runtime_directory().env_path();
envfile::write_env_file(
&server_env_path,
&std::collections::HashMap::from([("KEEP_ME".to_string(), "1".to_string())]),

View file

@ -1,29 +1,27 @@
use std::path::PathBuf;
use anyhow::Result;
use chrono::Utc;
use fabro_config::ServerRuntimeState;
use fabro_server::bind::BindRequest;
use fabro_config::RuntimeDirectory;
use fabro_config::bind::BindRequest;
use fabro_config::daemon::ServerDaemon;
use fabro_server::serve;
use fabro_server::serve::ServeArgs;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use super::record;
pub(crate) async fn execute(
record_path: PathBuf,
/// Run `serve::serve_command` with scopeguards that write/remove the server
/// daemon record and clean up a Unix socket on exit. Used by both
/// `fabro server serve` and `fabro server start --foreground`.
pub(crate) async fn serve_with_daemon_record(
mut serve_args: ServeArgs,
bind: BindRequest,
storage_dir: PathBuf,
styles: &'static Styles,
printer: Printer,
) -> Result<()> {
let _ = printer;
serve_args.bind = Some(bind.to_string());
let _record_guard = scopeguard::guard(record_path.clone(), |path| {
record::remove_server_record(&path);
let runtime_directory = RuntimeDirectory::new(&storage_dir);
let _record_guard = scopeguard::guard(runtime_directory.clone(), |dir| {
ServerDaemon::remove(&dir);
});
let _socket_guard = if let BindRequest::Unix(ref path) = bind {
@ -35,20 +33,16 @@ pub(crate) async fn execute(
None
};
let log_path = ServerRuntimeState::new(&storage_dir).log_path();
let log_path = runtime_directory.log_path();
let pid = std::process::id();
let daemon_dir = runtime_directory;
Box::pin(serve::serve_command(
serve_args,
styles,
Some(storage_dir),
move |resolved_bind| {
record::write_server_record(&record_path, &record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
started_at: Utc::now(),
})
ServerDaemon::new(pid, resolved_bind.clone(), log_path.clone()).write(&daemon_dir)
},
))
.await

View file

@ -1,5 +1,4 @@
pub(crate) mod foreground;
pub(crate) mod record;
pub(crate) mod start;
pub(crate) mod status;
pub(crate) mod stop;
@ -9,8 +8,8 @@ use std::time::Duration;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_config::bind::{self, Bind, BindRequest};
use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, default_storage_dir};
use fabro_server::bind::{self, Bind, BindRequest};
use fabro_server::install::{self, InstallAppState};
use fabro_server::serve::{self, ServeArgs};
use fabro_util::browser;
@ -124,7 +123,6 @@ pub(crate) async fn dispatch(
}
ServerCommand::Serve(ServerServeArgs {
storage_dir,
record_path,
serve_args,
}) => {
let settings = user_config::load_settings_with_config_and_storage_dir(
@ -139,9 +137,9 @@ pub(crate) async fn dispatch(
);
let storage_dir = local_server::storage_dir(&settings)?;
let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?;
let _ = printer;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
Box::pin(foreground::execute(
record_path,
Box::pin(foreground::serve_with_daemon_record(
ServeArgs {
config: active_config_path,
..serve_args
@ -149,7 +147,6 @@ pub(crate) async fn dispatch(
bind_addr,
storage_dir,
styles,
printer,
))
.await
}

View file

@ -1,166 +0,0 @@
#![expect(
clippy::disallowed_methods,
reason = "CLI server record helpers: sync read/write of local server record file"
)]
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use fabro_config::ServerRuntimeState;
use fabro_config::user::default_storage_dir;
use fabro_server::bind::Bind;
use fabro_util::Home;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ServerRecord {
pub pid: u32,
pub bind: Bind,
pub log_path: PathBuf,
pub started_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct ActiveServerRecord {
pub record: ServerRecord,
pub record_path: PathBuf,
}
pub(crate) fn write_server_record(path: &Path, record: &ServerRecord) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating server record directory {}", parent.display()))?;
}
std::fs::write(path, serde_json::to_string_pretty(record)?)
.with_context(|| format!("Failed to write server metadata to {}", path.display()))
}
pub(crate) fn read_server_record(path: &Path) -> Option<ServerRecord> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub(crate) fn remove_server_record(path: &Path) {
let _ = std::fs::remove_file(path);
}
pub(crate) fn server_record_is_running(record: &ServerRecord) -> bool {
fabro_proc::process_running(record.pid) && server_process_matches(record)
}
fn server_record_path(storage_dir: &Path) -> PathBuf {
ServerRuntimeState::new(storage_dir).record_path()
}
fn legacy_record_path(storage_dir: &Path) -> Option<PathBuf> {
if storage_dir == default_storage_dir() {
Some(Home::from_env().root().join("server.json"))
} else {
None
}
}
fn active_server_record_at_path(path: PathBuf) -> Option<ActiveServerRecord> {
let record = read_server_record(&path)?;
if server_record_is_running(&record) {
Some(ActiveServerRecord {
record,
record_path: path,
})
} else {
remove_server_record(&path);
None
}
}
pub(crate) fn active_server_record_details(
storage_dir: &Path,
) -> Result<Option<ActiveServerRecord>> {
let primary_path = server_record_path(storage_dir);
if let Some(active) = active_server_record_at_path(primary_path.clone()) {
return Ok(Some(active));
}
if let Some(legacy_path) = legacy_record_path(storage_dir) {
if active_server_record_at_path(legacy_path.clone()).is_some() {
bail!(
"Legacy server record {} is still active while current storage record {} is missing.\nStop the old daemon with a legacy Fabro CLI or manually clear the stale daemon before retrying.",
legacy_path.display(),
primary_path.display()
);
}
}
Ok(None)
}
pub(crate) fn active_server_record(storage_dir: &Path) -> Result<Option<ServerRecord>> {
Ok(active_server_record_details(storage_dir)?.map(|active| active.record))
}
#[cfg(unix)]
#[expect(
clippy::disallowed_methods,
reason = "This synchronous process identity probe is shared by async server start and sync server status flows."
)]
fn server_process_matches(record: &ServerRecord) -> bool {
let output = match std::process::Command::new("ps")
.args(["-ww", "-o", "command=", "-p", &record.pid.to_string()])
.output()
{
Ok(output) if output.status.success() => output,
_ => return false,
};
let command = String::from_utf8_lossy(&output.stdout);
command.contains("fabro") && command.contains("server")
}
#[cfg(not(unix))]
fn server_process_matches(_record: &ServerRecord) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
fn test_record(bind: Bind) -> ServerRecord {
ServerRecord {
pid: std::process::id(),
bind,
log_path: PathBuf::from("/tmp/storage/logs/server.log"),
started_at: Utc::now(),
}
}
#[test]
fn write_and_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = ServerRuntimeState::new(dir.path()).record_path();
let record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap()));
write_server_record(&path, &record).unwrap();
let loaded = read_server_record(&path).unwrap();
assert_eq!(loaded.pid, record.pid);
assert_eq!(loaded.bind, record.bind);
}
#[test]
fn active_server_record_returns_none_when_no_file() {
let dir = tempfile::tempdir().unwrap();
assert!(active_server_record(dir.path()).unwrap().is_none());
}
#[test]
fn active_server_record_cleans_stale_dead_pid() {
let dir = tempfile::tempdir().unwrap();
let path = ServerRuntimeState::new(dir.path()).record_path();
let mut record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap()));
record.pid = u32::MAX; // definitely not alive
write_server_record(&path, &record).unwrap();
assert!(active_server_record(dir.path()).unwrap().is_none());
assert!(!path.exists());
}
}

View file

@ -7,12 +7,11 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config};
use fabro_config::{ServerRuntimeState, envfile};
use fabro_server::bind::{Bind, BindRequest};
use fabro_config::{RuntimeDirectory, envfile};
use fabro_server::jwt_auth::auth_method_name;
use fabro_server::serve;
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs};
use fabro_types::settings::ServerAuthMethod;
use fabro_util::printer::Printer;
@ -23,7 +22,6 @@ use tokio::process::Command as TokioCommand;
use tokio::task::spawn_blocking;
use tokio::time;
use super::record;
use crate::local_server;
pub(crate) struct ForegroundServerLogBootstrap {
@ -67,10 +65,10 @@ pub(crate) async fn execute(
}
pub(crate) async fn prepare_foreground_server_log(
storage_dir: &Path,
runtime_directory: &RuntimeDirectory,
) -> Result<ForegroundServerLogBootstrap> {
let lock_file = acquire_lock(storage_dir).await?;
if let Some(existing) = record::active_server_record(storage_dir)? {
let lock_file = acquire_lock(runtime_directory).await?;
if let Some(existing) = ServerDaemon::load_running(runtime_directory)? {
bail!(
"Server already running (pid {}) on {}",
existing.pid,
@ -78,7 +76,7 @@ pub(crate) async fn prepare_foreground_server_log(
);
}
let log_path = ServerRuntimeState::new(storage_dir).log_path();
let log_path = runtime_directory.log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating log directory {}", parent.display()))?;
@ -119,7 +117,8 @@ async fn ensure_server_running_with_bind(
config_path: &Path,
storage_dir: &Path,
) -> Result<Bind> {
if let Some(existing) = record::active_server_record(storage_dir)? {
let runtime_directory = RuntimeDirectory::new(storage_dir);
if let Some(existing) = ServerDaemon::load_running(&runtime_directory)? {
if bind_request
.as_ref()
.is_none_or(|requested| bind_matches_request(&existing.bind, requested))
@ -163,7 +162,7 @@ async fn ensure_server_running_with_bind(
)
.await
{
Ok(()) => record::active_server_record(storage_dir)?
Ok(()) => ServerDaemon::load_running(&runtime_directory)?
.map(|server| server.bind)
.ok_or_else(|| {
anyhow!(
@ -172,7 +171,7 @@ async fn ensure_server_running_with_bind(
)
}),
Err(err) => {
if let Some(existing) = record::active_server_record(storage_dir)? {
if let Some(existing) = ServerDaemon::load_running(&runtime_directory)? {
Ok(existing.bind)
} else {
Err(err)
@ -227,7 +226,7 @@ fn valid_session_secret(secret: &str) -> bool {
session_secret::validate_session_secret(secret).is_ok()
}
fn load_or_create_local_session_secret(storage_dir: &Path) -> Result<String> {
fn load_or_create_local_session_secret(runtime_directory: &RuntimeDirectory) -> Result<String> {
if let Some(secret) = std::env::var("SESSION_SECRET")
.ok()
.filter(|secret| valid_session_secret(secret))
@ -235,7 +234,7 @@ fn load_or_create_local_session_secret(storage_dir: &Path) -> Result<String> {
return Ok(secret);
}
let server_env_path = ServerRuntimeState::new(storage_dir).env_path();
let server_env_path = runtime_directory.env_path();
if let Some(secret) = envfile::read_env_file(&server_env_path)
.ok()
.and_then(|entries| entries.get("SESSION_SECRET").cloned())
@ -262,7 +261,7 @@ async fn execute_foreground(
styles: &'static Styles,
_printer: Printer,
) -> Result<()> {
let session_secret = load_or_create_local_session_secret(&storage_dir)?;
let session_secret = load_or_create_local_session_secret(&RuntimeDirectory::new(&storage_dir))?;
let prior_session_secret = std::env::var_os("SESSION_SECRET");
std::env::set_var("SESSION_SECRET", &session_secret);
let _env_guard =
@ -274,38 +273,7 @@ async fn execute_foreground(
},
);
let runtime_state = ServerRuntimeState::new(&storage_dir);
let record_path = runtime_state.record_path();
let log_path = runtime_state.log_path();
let pid = std::process::id();
let _record_guard = scopeguard::guard(record_path.clone(), |path| {
record::remove_server_record(&path);
});
let _socket_guard = if let BindRequest::Unix(ref path) = bind {
let path = path.clone();
Some(scopeguard::guard(path, |p| {
let _ = std::fs::remove_file(p);
}))
} else {
None
};
Box::pin(serve::serve_command(
serve_args,
styles,
Some(storage_dir),
move |resolved_bind| {
record::write_server_record(&record_path, &record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
started_at: Utc::now(),
})
},
))
.await
super::foreground::serve_with_daemon_record(serve_args, bind, storage_dir, styles).await
}
// ---------------------------------------------------------------------------
@ -320,10 +288,11 @@ async fn execute_daemon(
styles: Option<&Styles>,
printer: Printer,
) -> Result<()> {
let lock_file = acquire_lock(storage_dir).await?;
let runtime_directory = RuntimeDirectory::new(storage_dir);
let lock_file = acquire_lock(&runtime_directory).await?;
let _lock_file = lock_file;
if let Some(existing) = record::active_server_record(storage_dir)? {
if let Some(existing) = ServerDaemon::load_running(&runtime_directory)? {
if announce {
bail!(
"Server already running (pid {}) on {}",
@ -334,14 +303,12 @@ async fn execute_daemon(
return Ok(());
}
let runtime_state = ServerRuntimeState::new(storage_dir);
let log_path = runtime_state.log_path();
let log_path = runtime_directory.log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating log directory {}", parent.display()))?;
}
let record_path = runtime_state.record_path();
let log_file = std::fs::File::create(&log_path)
.with_context(|| format!("creating server log file {}", log_path.display()))?;
let stdout_log = log_file
@ -351,8 +318,6 @@ async fn execute_daemon(
let mut cmd = TokioCommand::new(&exe);
cmd.args(["server", "__serve"])
.arg("--record-path")
.arg(&record_path)
.arg("--bind")
.arg(bind.to_string());
@ -382,7 +347,7 @@ async fn execute_daemon(
cmd.arg("--watch-web");
}
let session_secret = load_or_create_local_session_secret(storage_dir)?;
let session_secret = load_or_create_local_session_secret(&runtime_directory)?;
cmd.arg("--storage-dir").arg(storage_dir);
cmd.env("SESSION_SECRET", &session_secret);
@ -399,7 +364,7 @@ async fn execute_daemon(
.with_context(|| format!("spawning fabro server subprocess {}", exe.display()))?;
if let Ok(Some(status)) = child.try_wait() {
record::remove_server_record(&record_path);
ServerDaemon::remove(&runtime_directory);
let tail = read_log_tail(&log_path, 20);
if !tail.is_empty() {
fabro_util::printerr!(printer, "{tail}");
@ -412,18 +377,27 @@ async fn execute_daemon(
let mut elapsed = Duration::ZERO;
while elapsed < timeout {
if let Some(record) = record::read_server_record(&record_path) {
if try_connect(&record.bind).await {
let daemon = match ServerDaemon::read(&runtime_directory) {
Ok(daemon) => daemon,
Err(err) => {
ServerDaemon::remove(&runtime_directory);
let _ = child.kill().await;
let _ = child.wait().await;
return Err(err);
}
};
if let Some(daemon) = daemon {
if try_connect(&daemon.bind).await {
if announce {
let pid = child.id().unwrap_or_default();
maybe_warn_host_port_fallback(bind, &record.bind, printer);
maybe_warn_host_port_fallback(bind, &daemon.bind, printer);
fabro_util::printerr!(
printer,
"Server started (pid {}) on {}",
pid,
record.bind
daemon.bind
);
if let Bind::Tcp(addr) = &record.bind {
if let Bind::Tcp(addr) = &daemon.bind {
let url = format!("http://{addr}");
let styled = match styles {
Some(s) => format!("{}", s.cyan.apply_to(&url)),
@ -438,7 +412,7 @@ async fn execute_daemon(
}
if let Ok(Some(status)) = child.try_wait() {
record::remove_server_record(&record_path);
ServerDaemon::remove(&runtime_directory);
let tail = read_log_tail(&log_path, 20);
if !tail.is_empty() {
fabro_util::printerr!(printer, "{tail}");
@ -450,7 +424,7 @@ async fn execute_daemon(
elapsed += poll_interval;
}
record::remove_server_record(&record_path);
ServerDaemon::remove(&runtime_directory);
let _ = child.kill().await;
let _ = child.wait().await;
let tail = read_log_tail(&log_path, 20);
@ -470,8 +444,8 @@ fn print_auth_methods(printer: Printer, serve_args: &ServeArgs) {
// Helpers
// ---------------------------------------------------------------------------
async fn acquire_lock(storage_dir: &Path) -> Result<std::fs::File> {
let lock_path = ServerRuntimeState::new(storage_dir).lock_path();
async fn acquire_lock(runtime_directory: &RuntimeDirectory) -> Result<std::fs::File> {
let lock_path = runtime_directory.lock_path();
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating server lock directory {}", parent.display()))?;

View file

@ -2,12 +2,13 @@ use std::path::Path;
use anyhow::Result;
use chrono::Utc;
use fabro_config::RuntimeDirectory;
use fabro_config::daemon::ServerDaemon;
use fabro_util::printer::Printer;
use super::record;
pub(crate) fn execute(storage_dir: &Path, json: bool, printer: Printer) -> Result<()> {
let Some(record) = record::active_server_record(storage_dir)? else {
let runtime_directory = RuntimeDirectory::new(storage_dir);
let Some(daemon) = ServerDaemon::load_running(&runtime_directory)? else {
if json {
fabro_util::printout!(printer, r#"{{"status":"stopped"}}"#);
} else {
@ -17,22 +18,22 @@ pub(crate) fn execute(storage_dir: &Path, json: bool, printer: Printer) -> Resul
};
if json {
let uptime_seconds = (Utc::now() - record.started_at).num_seconds().max(0);
let uptime_seconds = (Utc::now() - daemon.started_at).num_seconds().max(0);
let output = serde_json::json!({
"status": "running",
"pid": record.pid,
"bind": record.bind.to_string(),
"started_at": record.started_at.to_rfc3339(),
"pid": daemon.pid,
"bind": daemon.bind.to_string(),
"started_at": daemon.started_at.to_rfc3339(),
"uptime_seconds": uptime_seconds,
});
fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&output)?);
} else {
let uptime = format_uptime(Utc::now() - record.started_at);
let uptime = format_uptime(Utc::now() - daemon.started_at);
fabro_util::printerr!(
printer,
"Server running (pid {}) on {}, started {} ago",
record.pid,
record.bind,
daemon.pid,
daemon.bind,
uptime
);
}

View file

@ -2,19 +2,19 @@ use std::path::Path;
use std::time::Duration;
use anyhow::Result;
use fabro_server::bind::Bind;
use fabro_config::RuntimeDirectory;
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_util::printer::Printer;
use tokio::time;
use super::record;
pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> Result<bool> {
let Some(active) = record::active_server_record_details(storage_dir)? else {
let runtime_directory = RuntimeDirectory::new(storage_dir);
let Some(daemon) = ServerDaemon::load_running(&runtime_directory)? else {
return Ok(false);
};
let record = active.record;
fabro_proc::sigterm(record.pid);
fabro_proc::sigterm(daemon.pid);
// Use the zombie-aware predicate here: this loop is commonly driven
// against a child of the calling process (tests, install/uninstall
@ -26,21 +26,21 @@ pub(crate) async fn stop_server(storage_dir: &Path, timeout: Duration) -> Result
let poll_interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
while elapsed < timeout {
if !fabro_proc::process_running_strict(record.pid) {
if !fabro_proc::process_running_strict(daemon.pid) {
break;
}
time::sleep(poll_interval).await;
elapsed += poll_interval;
}
if fabro_proc::process_running_strict(record.pid) {
fabro_proc::sigkill(record.pid);
if fabro_proc::process_running_strict(daemon.pid) {
fabro_proc::sigkill(daemon.pid);
time::sleep(Duration::from_millis(100)).await;
}
record::remove_server_record(&active.record_path);
ServerDaemon::remove(&runtime_directory);
if let Bind::Unix(ref path) = record.bind {
if let Bind::Unix(ref path) = daemon.bind {
let _ = std::fs::remove_file(path);
}

View file

@ -773,11 +773,11 @@ mod tests {
);
assert_eq!(
std::fs::read_to_string(output.path().join("retro/prompt.md")).unwrap(),
std::fs::read_to_string(output.path().join("stages/retro/prompt.md")).unwrap(),
"How did it go?"
);
assert_eq!(
std::fs::read_to_string(output.path().join("retro/response.md")).unwrap(),
std::fs::read_to_string(output.path().join("stages/retro/response.md")).unwrap(),
"Smooth enough"
);
@ -805,19 +805,14 @@ mod tests {
assert!(!output.path().join("blobs").exists());
assert_eq!(
std::fs::read(
output
.path()
.join("artifacts/nodes/code/visit-2/src/lib.rs")
)
.unwrap(),
std::fs::read(output.path().join("artifacts/code@2/src/lib.rs")).unwrap(),
b"fn main() {}"
);
assert_eq!(
std::fs::read(
output
.path()
.join("artifacts/nodes/artifact-only/visit-7/logs/output.txt")
.join("artifacts/artifact-only@7/logs/output.txt")
)
.unwrap(),
b"hello"

View file

@ -13,6 +13,8 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use fabro_config::Storage;
use fabro_config::daemon::ServerDaemon;
use fabro_types::settings::CliSettings;
use fabro_types::settings::cli::OutputFormat;
use fabro_util::Home;
@ -21,7 +23,7 @@ use serde::Serialize;
use tracing::warn;
use crate::args::UninstallArgs;
use crate::commands::server;
use crate::commands::server::stop;
use crate::shared::{format_size, print_json_pretty, tilde_path};
use crate::{local_server, user_config};
@ -87,7 +89,8 @@ pub(crate) async fn run_uninstall(
fn build_inventory(home_root: &Path, storage_dir: &Path) -> Result<Inventory> {
let home_size = dir_size(home_root);
let server_running = server::record::active_server_record_details(storage_dir)?.is_some();
let server_running =
ServerDaemon::load_running(&Storage::new(storage_dir).runtime_directory())?.is_some();
let shell_configs = find_shell_configs_with_sentinel();
let (binary_path, binary_is_managed) = resolve_binary(home_root);
@ -264,7 +267,7 @@ async fn execute_uninstall(inventory: &Inventory, json: bool, printer: Printer)
// Unit 3a: Server stop
if inventory.server_running {
server::stop::execute(&inventory.storage_dir, Duration::from_secs(5), printer).await?;
stop::execute(&inventory.storage_dir, Duration::from_secs(5), printer).await?;
result.server_stopped = true;
}

View file

@ -7,7 +7,7 @@
use std::path::PathBuf;
use anyhow::Result;
use fabro_server::bind::BindRequest;
use fabro_config::bind::BindRequest;
use fabro_server::serve::resolve_bind_request_from_settings;
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};

View file

@ -495,16 +495,16 @@ async fn prepare_server_bootstrap(
let settings =
user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?;
let storage_dir = local_server::storage_dir(&settings)?;
let runtime_state = fabro_config::ServerRuntimeState::new(storage_dir.clone());
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.clone());
let foreground_server_log_bootstrap = if foreground {
Some(commands::server::start::prepare_foreground_server_log(&storage_dir).await?)
Some(commands::server::start::prepare_foreground_server_log(&runtime_directory).await?)
} else {
None
};
Ok(PreTracingBootstrap {
sink: logging::InternalLogSink::Server {
path: runtime_state.log_path(),
path: runtime_directory.log_path(),
},
config_log_level: local_server::config_log_level(&settings),
foreground_server_log_bootstrap,
@ -743,16 +743,12 @@ level = "warn"
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let record_path = storage_dir.path().join("server.json");
let cli = Cli::try_parse_from([
"fabro",
"server",
"__serve",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--record-path",
record_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])

View file

@ -9,7 +9,7 @@ use fabro_client::{
apply_bearer_token_auth,
};
pub(crate) use fabro_client::{Client, RunEventStream};
use fabro_server::bind::Bind;
use fabro_config::bind::Bind;
pub(crate) use fabro_types::RunProjection;
use fabro_types::settings::SettingsLayer;
use fabro_util::dev_token::validate_dev_token_format;

View file

@ -237,7 +237,7 @@ mode = "keep-me"
),
);
let server_env_path = Storage::new(&storage_dir).runtime_state().env_path();
let server_env_path = Storage::new(&storage_dir).runtime_directory().env_path();
envfile::write_env_file(
&server_env_path,
&std::collections::HashMap::from([

View file

@ -12,7 +12,7 @@ const TEST_DEV_TOKEN: &str =
fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) {
context.ensure_home_server_auth_methods();
let server_env_path = Storage::new(storage_dir).runtime_state().env_path();
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.expect("merging FABRO_DEV_TOKEN into server.env");
dev_token::write_dev_token(

View file

@ -14,8 +14,8 @@ use std::time::{Duration, Instant};
use fabro_config::{Storage, envfile};
use fabro_test::{
apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files, stop_pid,
test_context, wait_for_log_line, wait_for_path,
apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files, test_context,
wait_for_log_line, wait_for_path,
};
use fabro_util::dev_token;
@ -31,7 +31,7 @@ fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) {
}
fn provision_dev_token_auth(home_dir: &std::path::Path, storage_dir: &std::path::Path) {
let server_env_path = Storage::new(storage_dir).runtime_state().env_path();
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.expect("merging FABRO_DEV_TOKEN into server.env");
dev_token::write_dev_token(&home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN)
@ -367,86 +367,6 @@ fn daemon_start_writes_tracing_to_storage_server_log() {
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
)]
fn start_errors_when_only_a_legacy_running_server_record_exists() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let fabro_home = home_dir.path().join(".fabro");
let storage_dir = fabro_home.join("storage");
let socket_path = home_dir.path().join("legacy.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
write_dev_token_server_settings(&config_path, "");
provision_dev_token_auth(home_dir.path(), &storage_dir);
let start_output = {
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut start, home_dir.path());
start
.args(["server", "start", "--bind"])
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.output()
.expect("server start should run")
};
assert!(
start_output.status.success(),
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&start_output.stdout),
String::from_utf8_lossy(&start_output.stderr)
);
let current_record = storage_dir.join("server.json");
wait_for_path(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_record, &legacy_record).unwrap();
let retry_output = {
let mut retry = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
apply_test_isolation(&mut retry, home_dir.path());
retry
.args(["server", "start", "--bind"])
.arg(home_dir.path().join("new.sock"))
.arg("--config")
.arg(&config_path)
.output()
.expect("server start retry should run")
};
let pid_u64 = serde_json::from_str::<serde_json::Value>(
&std::fs::read_to_string(&legacy_record).unwrap(),
)
.unwrap()["pid"]
.as_u64()
.unwrap();
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
stop_pid(pid);
let _ = std::fs::remove_file(&legacy_record);
let _ = std::fs::remove_file(&socket_path);
assert!(
!retry_output.status.success(),
"server start should fail when only the legacy record exists"
);
let stderr = String::from_utf8_lossy(&retry_output.stderr);
assert!(
stderr.contains(&legacy_record.display().to_string()),
"expected stderr to mention the legacy record path, got:\n{stderr}"
);
assert!(
stderr.contains(&current_record.display().to_string()),
"expected stderr to mention the current record path, got:\n{stderr}"
);
assert!(
stderr.contains("legacy Fabro CLI"),
"expected stderr to instruct manual cleanup, got:\n{stderr}"
);
}
#[test]
#[expect(
clippy::disallowed_methods,

View file

@ -1,4 +1,4 @@
use fabro_test::{fabro_snapshot, isolated_storage_dir, stop_pid, test_context, wait_for_path};
use fabro_test::{fabro_snapshot, isolated_storage_dir, test_context};
#[test]
fn help() {
@ -41,87 +41,3 @@ fn status_when_not_running() {
Server is not running
");
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
)]
fn status_errors_when_only_a_legacy_running_server_record_exists() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let fabro_home = home_dir.path().join(".fabro");
let storage_dir = fabro_home.join("storage");
let socket_path = home_dir.path().join("legacy.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
std::fs::write(
&config_path,
"_version = 1\n\n[server.auth]\nmethods = [\"dev-token\"]\n",
)
.unwrap();
let start_output = {
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
fabro_test::apply_test_isolation(&mut start, home_dir.path());
start.env(
"FABRO_DEV_TOKEN",
"fabro_dev_abababababababababababababababababababababababababababababababab",
);
start
.args(["server", "start", "--bind"])
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.output()
.expect("server start should run")
};
assert!(
start_output.status.success(),
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&start_output.stdout),
String::from_utf8_lossy(&start_output.stderr)
);
let current_record = storage_dir.join("server.json");
wait_for_path(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_record, &legacy_record).unwrap();
let status_output = {
let mut status = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
fabro_test::apply_test_isolation(&mut status, home_dir.path());
status
.args(["server", "status"])
.output()
.expect("server status should run")
};
let pid_u64 = serde_json::from_str::<serde_json::Value>(
&std::fs::read_to_string(&legacy_record).unwrap(),
)
.unwrap()["pid"]
.as_u64()
.unwrap();
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
stop_pid(pid);
let _ = std::fs::remove_file(&legacy_record);
let _ = std::fs::remove_file(&socket_path);
assert!(
!status_output.status.success(),
"server status should fail when only the legacy record exists"
);
let stderr = String::from_utf8_lossy(&status_output.stderr);
assert!(
stderr.contains(&legacy_record.display().to_string()),
"expected stderr to mention the legacy record path, got:\n{stderr}"
);
assert!(
stderr.contains(&current_record.display().to_string()),
"expected stderr to mention the current record path, got:\n{stderr}"
);
assert!(
stderr.contains("legacy Fabro CLI"),
"expected stderr to instruct manual cleanup, got:\n{stderr}"
);
}

View file

@ -254,8 +254,7 @@ include = ["assets/**"]
"run export should hydrate blob refs\n{run_json}"
);
assert_eq!(
fs::read_to_string(output_dir.join("artifacts/nodes/big/visit-1/assets/shared/report.txt"))
.unwrap(),
fs::read_to_string(output_dir.join("artifacts/big@1/assets/shared/report.txt")).unwrap(),
"exported"
);
}

View file

@ -14,8 +14,9 @@ use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::{Duration, Instant};
use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_config::{Storage, envfile};
use fabro_server::bind::Bind;
use fabro_store::EventEnvelope;
use fabro_test::{TestContext, expect_reqwest_status};
use fabro_types::RunId;
@ -667,13 +668,8 @@ fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
.block_on(future)
}
#[derive(Debug, serde::Deserialize)]
struct TestServerRecord {
bind: Bind,
}
pub(crate) fn local_dev_token(storage_dir: &Path) -> Option<String> {
let server_state = Storage::new(storage_dir).runtime_state();
let server_state = Storage::new(storage_dir).runtime_directory();
envfile::read_env_file(&server_state.env_path())
.ok()
@ -682,10 +678,8 @@ pub(crate) fn local_dev_token(storage_dir: &Path) -> Option<String> {
}
pub(crate) fn server_endpoint(storage_dir: &Path) -> Option<(fabro_http::HttpClient, String)> {
let record_path = Storage::new(storage_dir).runtime_state().record_path();
let record = std::fs::read_to_string(record_path)
.ok()
.and_then(|content| serde_json::from_str::<TestServerRecord>(&content).ok())?;
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let daemon = ServerDaemon::read(&runtime_directory).ok().flatten()?;
let mut headers = fabro_http::HeaderMap::new();
if let Some(token) = local_dev_token(storage_dir) {
headers.insert(
@ -694,7 +688,7 @@ pub(crate) fn server_endpoint(storage_dir: &Path) -> Option<(fabro_http::HttpCli
.expect("local dev token should build an authorization header"),
);
}
match record.bind {
match daemon.bind {
Bind::Unix(path) if path.exists() => Some((
fabro_http::HttpClientBuilder::new()
.unix_socket(path)
@ -717,15 +711,11 @@ pub(crate) fn server_endpoint(storage_dir: &Path) -> Option<(fabro_http::HttpCli
}
pub(crate) fn server_target(storage_dir: &Path) -> String {
let record_path = Storage::new(storage_dir).runtime_state().record_path();
let record = std::fs::read_to_string(record_path)
.ok()
.and_then(|content| serde_json::from_str::<TestServerRecord>(&content).ok())
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let daemon = ServerDaemon::read(&runtime_directory)
.expect("server record should parse")
.expect("server record should exist");
match record.bind {
Bind::Unix(path) => path.to_string_lossy().to_string(),
Bind::Tcp(addr) => format!("http://{addr}"),
}
daemon.bind.to_target()
}
async fn get_server_json<T: serde::de::DeserializeOwned>(run_dir: &Path, path: &str) -> T {

View file

@ -5,7 +5,7 @@
use std::fs;
use fabro_test::{fabro_snapshot, stop_pid, test_context, wait_for_path};
use fabro_test::{fabro_snapshot, test_context};
use serde_json::Value;
#[test]
@ -169,97 +169,3 @@ fn not_installed_json() {
assert_eq!(value["status"].as_str(), Some("not_installed"));
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "This integration test moves a live daemon record on disk to simulate an unsupported legacy daemon upgrade."
)]
fn uninstall_yes_fails_when_only_a_legacy_running_server_record_exists() {
let home_dir = tempfile::tempdir_in("/tmp").unwrap();
let fabro_home = home_dir.path().join(".fabro");
let storage_dir = fabro_home.join("storage");
let socket_path = home_dir.path().join("legacy.sock");
let config_dir = tempfile::tempdir_in("/tmp").unwrap();
let config_path = config_dir.path().join("settings.toml");
std::fs::write(
&config_path,
"_version = 1\n\n[server.auth]\nmethods = [\"dev-token\"]\n",
)
.unwrap();
std::fs::create_dir_all(&fabro_home).unwrap();
std::fs::write(
fabro_home.join("settings.toml"),
"_version = 1\n\n[server.auth]\nmethods = [\"dev-token\"]\n",
)
.unwrap();
let start_output = {
let mut start = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
fabro_test::apply_test_isolation(&mut start, home_dir.path());
start.env(
"FABRO_DEV_TOKEN",
"fabro_dev_abababababababababababababababababababababababababababababababab",
);
start
.args(["server", "start", "--bind"])
.arg(&socket_path)
.arg("--config")
.arg(&config_path)
.output()
.expect("server start should run")
};
assert!(
start_output.status.success(),
"server start should succeed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&start_output.stdout),
String::from_utf8_lossy(&start_output.stderr)
);
let current_record = storage_dir.join("server.json");
wait_for_path(&current_record);
let legacy_record = fabro_home.join("server.json");
std::fs::rename(&current_record, &legacy_record).unwrap();
let pid_u64 = serde_json::from_str::<serde_json::Value>(
&std::fs::read_to_string(&legacy_record).unwrap(),
)
.unwrap()["pid"]
.as_u64()
.unwrap();
let pid = u32::try_from(pid_u64).expect("pid fits in u32");
let uninstall_output = {
let mut uninstall = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
fabro_test::apply_test_isolation(&mut uninstall, home_dir.path());
uninstall
.args(["uninstall", "--yes"])
.output()
.expect("uninstall should run")
};
stop_pid(pid);
let _ = std::fs::remove_file(&legacy_record);
let _ = std::fs::remove_file(&socket_path);
assert!(
!uninstall_output.status.success(),
"uninstall --yes should fail when only the legacy record exists"
);
let stderr = String::from_utf8_lossy(&uninstall_output.stderr);
assert!(
stderr.contains(&legacy_record.display().to_string()),
"expected stderr to mention the legacy record path, got:\n{stderr}"
);
assert!(
stderr.contains(&current_record.display().to_string()),
"expected stderr to mention the current record path, got:\n{stderr}"
);
assert!(
stderr.contains("legacy Fabro CLI"),
"expected stderr to instruct manual cleanup, got:\n{stderr}"
);
assert!(
fabro_home.exists(),
"uninstall should not remove ~/.fabro when the legacy daemon detector fires"
);
}

View file

@ -11,7 +11,7 @@ fn start_status_stop_lifecycle() {
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
let server_env_path = fabro_config::Storage::new(&storage_dir)
.runtime_state()
.runtime_directory()
.env_path();
fabro_config::envfile::merge_env_file(&server_env_path, [(
"FABRO_DEV_TOKEN",

View file

@ -20,6 +20,7 @@ workspace = true
anyhow.workspace = true
clap = { workspace = true, optional = true }
chrono.workspace = true
fabro-proc = { path = "../fabro-proc" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
dirs.workspace = true
@ -27,12 +28,12 @@ ipnet = "2.11.0"
serde.workspace = true
serde_json.workspace = true
strsim = "0.11"
tempfile = "3"
toml.workspace = true
tracing.workspace = true
thiserror.workspace = true
ulid.workspace = true
[dev-dependencies]
tempfile = "3"
toml.workspace = true
fabro-types = { path = "../fabro-types", features = ["test-support"] }

View file

@ -26,6 +26,16 @@ impl Bind {
Self::Unix(_) => None,
}
}
/// Fabro server target string — an `http://` URL for TCP or a socket path
/// for Unix, matching the form accepted by `--target` / `ServerTarget`.
#[must_use]
pub fn to_target(&self) -> String {
match self {
Self::Unix(path) => path.to_string_lossy().into_owned(),
Self::Tcp(addr) => format!("http://{addr}"),
}
}
}
impl fmt::Display for Bind {
@ -153,4 +163,16 @@ mod tests {
let bind = BindRequest::TcpHost(IpAddr::V4(Ipv4Addr::LOCALHOST));
assert_eq!(bind.to_string(), "127.0.0.1");
}
#[test]
fn tcp_target_is_http_url() {
let bind = Bind::Tcp("127.0.0.1:3000".parse().unwrap());
assert_eq!(bind.to_target(), "http://127.0.0.1:3000");
}
#[test]
fn unix_target_is_socket_path() {
let bind = Bind::Unix(PathBuf::from("/run/fabro.sock"));
assert_eq!(bind.to_target(), "/run/fabro.sock");
}
}

View file

@ -0,0 +1,193 @@
#![expect(
clippy::disallowed_methods,
reason = "Server daemon metadata uses synchronous local file I/O and process probes."
)]
use std::io::ErrorKind;
use std::path::PathBuf;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::RuntimeDirectory;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use crate::bind::Bind;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServerDaemon {
pub pid: u32,
pub bind: Bind,
pub log_path: PathBuf,
pub started_at: DateTime<Utc>,
}
impl ServerDaemon {
#[must_use]
pub fn new(pid: u32, bind: Bind, log_path: PathBuf) -> Self {
Self {
pid,
bind,
log_path,
started_at: Utc::now(),
}
}
pub fn read(dir: &RuntimeDirectory) -> Result<Option<Self>> {
let record_path = dir.record_path();
let content = match std::fs::read_to_string(&record_path) {
Ok(content) => content,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(anyhow::Error::new(err)
.context(format!("reading server record {}", record_path.display())));
}
};
serde_json::from_str(&content)
.map(Some)
.with_context(|| format!("parsing server record {}", record_path.display()))
}
pub fn load_running(dir: &RuntimeDirectory) -> Result<Option<Self>> {
let Some(daemon) = Self::read(dir)? else {
return Ok(None);
};
if daemon.is_running() {
Ok(Some(daemon))
} else {
Self::remove(dir);
Ok(None)
}
}
pub fn write(&self, dir: &RuntimeDirectory) -> Result<()> {
let record_path = dir.record_path();
let record_dir = record_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(record_dir).with_context(|| {
format!("creating server record directory {}", record_dir.display())
})?;
let temp = NamedTempFile::new_in(record_dir).with_context(|| {
format!("creating temp server record for {}", record_path.display())
})?;
std::fs::write(temp.path(), serde_json::to_string_pretty(self)?)
.with_context(|| format!("writing temp server record for {}", record_path.display()))?;
temp.persist(&record_path)
.map_err(|err| err.error)
.with_context(|| format!("persisting server record {}", record_path.display()))?;
Ok(())
}
pub fn remove(dir: &RuntimeDirectory) {
let record_path = dir.record_path();
if let Err(err) = std::fs::remove_file(&record_path) {
if err.kind() == ErrorKind::NotFound {
return;
}
tracing::warn!(
path = %record_path.display(),
error = %err,
"Failed to remove server record"
);
}
}
#[must_use]
pub fn is_running(&self) -> bool {
fabro_proc::process_running(self.pid) && server_process_matches(self.pid)
}
}
#[cfg(unix)]
fn server_process_matches(pid: u32) -> bool {
let output = match std::process::Command::new("ps")
.args(["-ww", "-o", "command=", "-p", &pid.to_string()])
.output()
{
Ok(output) if output.status.success() => output,
_ => return false,
};
let command = String::from_utf8_lossy(&output.stdout);
command.contains("fabro") && command.contains("server")
}
#[cfg(not(unix))]
fn server_process_matches(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fabro_config::RuntimeDirectory;
use super::{Bind, ServerDaemon};
fn test_daemon(bind: Bind) -> ServerDaemon {
ServerDaemon {
pid: std::process::id(),
bind,
log_path: PathBuf::from("/tmp/storage/logs/server.log"),
started_at: chrono::Utc::now(),
}
}
#[test]
fn write_and_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let runtime_directory = RuntimeDirectory::new(dir.path());
let daemon = test_daemon(Bind::Tcp("127.0.0.1:3000".parse().unwrap()));
daemon.write(&runtime_directory).unwrap();
let loaded = ServerDaemon::read(&runtime_directory).unwrap().unwrap();
assert_eq!(loaded, daemon);
}
#[test]
fn active_server_record_returns_none_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let runtime_directory = RuntimeDirectory::new(dir.path());
assert!(
ServerDaemon::load_running(&runtime_directory)
.unwrap()
.is_none()
);
}
#[test]
fn active_server_record_cleans_stale_dead_pid() {
let dir = tempfile::tempdir().unwrap();
let runtime_directory = RuntimeDirectory::new(dir.path());
let mut daemon = test_daemon(Bind::Tcp("127.0.0.1:3000".parse().unwrap()));
daemon.pid = u32::MAX;
daemon.write(&runtime_directory).unwrap();
assert!(
ServerDaemon::load_running(&runtime_directory)
.unwrap()
.is_none()
);
assert!(!runtime_directory.record_path().exists());
}
#[test]
fn read_surfaces_parse_error_with_path() {
let dir = tempfile::tempdir().unwrap();
let runtime_directory = RuntimeDirectory::new(dir.path());
let record_path = runtime_directory.record_path();
std::fs::create_dir_all(record_path.parent().unwrap()).unwrap();
std::fs::write(&record_path, "not json").unwrap();
let err = ServerDaemon::read(&runtime_directory).unwrap_err();
assert!(
err.to_string()
.contains(record_path.display().to_string().as_str())
);
}
}

View file

@ -7,6 +7,8 @@ extern crate self as fabro_config;
mod defaults;
pub mod bind;
pub mod daemon;
pub mod effective_settings;
pub mod envfile;
pub mod error;
@ -38,7 +40,7 @@ pub use resolve::{
resolve_storage_root, resolve_workflow, resolve_workflow_from_file,
};
use serde::de::DeserializeOwned;
pub use storage::{RunScratch, ServerRuntimeState, Storage};
pub use storage::{RunScratch, RuntimeDirectory, Storage};
pub fn load_and_resolve(
layers: effective_settings::EffectiveSettingsLayers,

View file

@ -9,7 +9,7 @@ pub struct Storage {
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerRuntimeState {
pub struct RuntimeDirectory {
root: PathBuf,
}
@ -48,8 +48,8 @@ impl Storage {
}
#[must_use]
pub fn runtime_state(&self) -> ServerRuntimeState {
ServerRuntimeState::new(self.root.clone())
pub fn runtime_directory(&self) -> RuntimeDirectory {
RuntimeDirectory::new(self.root.clone())
}
#[must_use]
@ -78,7 +78,7 @@ impl Storage {
}
}
impl ServerRuntimeState {
impl RuntimeDirectory {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
@ -163,12 +163,12 @@ mod tests {
use chrono::Local;
use fabro_types::RunId;
use super::{RunScratch, ServerRuntimeState, Storage};
use super::{RunScratch, RuntimeDirectory, Storage};
#[test]
fn storage_accessors_are_relative_to_root() {
let storage = Storage::new("/tmp/fabro-data");
let runtime = ServerRuntimeState::new("/tmp/fabro-data");
let runtime = RuntimeDirectory::new("/tmp/fabro-data");
assert_eq!(storage.root(), std::path::Path::new("/tmp/fabro-data"));
assert_eq!(

View file

@ -19,4 +19,5 @@ fabro-types = { path = "../fabro-types" }
nom = "7"
regex = { workspace = true }
serde = { workspace = true }
strum.workspace = true
thiserror = { workspace = true }

View file

@ -1,9 +1,9 @@
use std::fmt;
use std::str::FromStr;
use strum::{Display, EnumString};
/// Fidelity mode controlling how much prior context is provided to LLM
/// sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Display, EnumString)]
#[strum(serialize_all = "lowercase")]
pub enum Fidelity {
/// Complete context, no summarization — sessions share a thread.
Full,
@ -13,10 +13,13 @@ pub enum Fidelity {
#[default]
Compact,
/// Brief textual summary (~600 token target).
#[strum(serialize = "summary:low")]
SummaryLow,
/// Moderate textual summary (~1500 token target).
#[strum(serialize = "summary:medium")]
SummaryMedium,
/// Detailed per-stage Markdown report.
#[strum(serialize = "summary:high")]
SummaryHigh,
}
@ -31,36 +34,6 @@ impl Fidelity {
}
}
impl fmt::Display for Fidelity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Full => "full",
Self::Truncate => "truncate",
Self::Compact => "compact",
Self::SummaryLow => "summary:low",
Self::SummaryMedium => "summary:medium",
Self::SummaryHigh => "summary:high",
};
write!(f, "{s}")
}
}
impl FromStr for Fidelity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"full" => Ok(Self::Full),
"truncate" => Ok(Self::Truncate),
"compact" => Ok(Self::Compact),
"summary:low" => Ok(Self::SummaryLow),
"summary:medium" => Ok(Self::SummaryMedium),
"summary:high" => Ok(Self::SummaryHigh),
other => Err(format!("unknown fidelity mode: {other}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -266,7 +266,7 @@ fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)])
return Ok(());
}
let env_path = Storage::new(storage_dir).runtime_state().env_path();
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&env_path, secrets.iter().cloned())
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
Ok(())
@ -500,7 +500,7 @@ name = "custom"
assert_eq!(restored.get("EXISTING_SECRET"), Some("keep"));
assert_eq!(restored.get("bad-secret-name"), None);
let server_env = envfile::read_env_file(&storage.runtime_state().env_path()).unwrap();
let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
assert_eq!(
server_env.get("SESSION_SECRET").map(String::as_str),
Some("session")

View file

@ -21,6 +21,7 @@ anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
tokio.workspace = true
uuid.workspace = true
rand.workspace = true

View file

@ -1,8 +1,8 @@
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use fabro_model::Model;
use strum::{EnumString, IntoStaticStr};
use tokio::time;
use crate::client::Client;
@ -10,7 +10,8 @@ use crate::generate::{self, GenerateParams};
use crate::tools::Tool;
use crate::types::{GenerateResult, ReasoningEffort};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, EnumString, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub enum ModelTestMode {
#[default]
Basic,
@ -19,11 +20,8 @@ pub enum ModelTestMode {
impl ModelTestMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Basic => "basic",
Self::Deep => "deep",
}
pub fn as_str(self) -> &'static str {
self.into()
}
#[must_use]
@ -35,19 +33,8 @@ impl ModelTestMode {
}
}
impl FromStr for ModelTestMode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"basic" => Ok(Self::Basic),
"deep" => Ok(Self::Deep),
other => Err(format!("invalid model test mode: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
pub enum ModelTestStatus {
Ok,
Error,
@ -55,11 +42,8 @@ pub enum ModelTestStatus {
impl ModelTestStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Error => "error",
}
pub fn as_str(self) -> &'static str {
self.into()
}
}

View file

@ -411,8 +411,21 @@ pub struct RateLimitInfo {
// --- 3.8 ReasoningEffort ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffort {
Low,
Medium,
@ -423,35 +436,7 @@ pub enum ReasoningEffort {
impl ReasoningEffort {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
}
impl std::fmt::Display for ReasoningEffort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ReasoningEffort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::XHigh),
"max" => Ok(Self::Max),
other => Err(format!(
"invalid reasoning_effort: {other:?} (expected low, medium, high, xhigh, or max)"
)),
}
(*self).into()
}
}
@ -1318,12 +1303,8 @@ mod tests {
}
#[test]
fn reasoning_effort_from_str_rejects_unknown_with_updated_error() {
fn reasoning_effort_from_str_rejects_unknown() {
use std::str::FromStr;
let err = ReasoningEffort::from_str("bogus").expect_err("should reject");
assert!(
err.contains("low, medium, high, xhigh, or max"),
"error should list all accepted levels, got: {err}"
);
assert!(ReasoningEffort::from_str("bogus").is_err());
}
}

View file

@ -15,6 +15,7 @@ workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
[dev-dependencies]
insta.workspace = true

View file

@ -1,6 +1,5 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, IntoStaticStr};
use crate::{Model, Provider};
@ -96,8 +95,21 @@ impl PricePerMTok {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Speed {
Standard,
Fast,
@ -106,28 +118,7 @@ pub enum Speed {
impl Speed {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
}
impl FromStr for Speed {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"standard" => Ok(Self::Standard),
"fast" => Ok(Self::Fast),
other => Err(format!("unknown speed: {other}")),
}
}
}
impl std::fmt::Display for Speed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
self.into()
}
}

View file

@ -1,25 +1,39 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, IntoStaticStr};
// ---------------------------------------------------------------------------
// Provider enum — compile-time safe provider identity
// ---------------------------------------------------------------------------
/// Known LLM provider variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Provider {
Anthropic,
#[serde(rename = "openai", alias = "open_ai")]
#[strum(to_string = "openai", serialize = "open_ai")]
OpenAi,
Gemini,
Kimi,
Zai,
Minimax,
#[strum(to_string = "inception", serialize = "inception_labs")]
Inception,
#[serde(rename = "openai_compatible", alias = "open_ai_compatible")]
#[strum(to_string = "openai_compatible", serialize = "open_ai_compatible")]
OpenAiCompatible,
}
@ -106,40 +120,7 @@ impl Provider {
/// adapter names, and other serialization boundaries.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAi => "openai",
Self::Gemini => "gemini",
Self::Kimi => "kimi",
Self::Zai => "zai",
Self::Minimax => "minimax",
Self::Inception => "inception",
Self::OpenAiCompatible => "openai_compatible",
}
}
}
impl fmt::Display for Provider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Provider {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"anthropic" => Ok(Self::Anthropic),
"openai" | "open_ai" => Ok(Self::OpenAi),
"gemini" => Ok(Self::Gemini),
"kimi" => Ok(Self::Kimi),
"zai" => Ok(Self::Zai),
"minimax" => Ok(Self::Minimax),
"inception" | "inception_labs" => Ok(Self::Inception),
"openai_compatible" => Ok(Self::OpenAiCompatible),
other => Err(format!("unknown provider: {other}")),
}
self.into()
}
}

View file

@ -26,6 +26,7 @@ tokio.workspace = true
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
tracing.workspace = true
base64.workspace = true
fabro-proc = { path = "../fabro-proc" }

View file

@ -1,8 +1,8 @@
use std::fmt;
use std::str::FromStr;
use strum::{Display, EnumString};
/// Sandbox provider for agent tool operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumString)]
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
pub enum SandboxProvider {
/// Run tools on the local host (default)
#[default]
@ -22,29 +22,6 @@ impl SandboxProvider {
}
}
impl fmt::Display for SandboxProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Docker => write!(f, "docker"),
Self::Daytona => write!(f, "daytona"),
}
}
}
impl FromStr for SandboxProvider {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"docker" => Ok(Self::Docker),
"daytona" => Ok(Self::Daytona),
other => Err(format!("unknown sandbox provider: {other}")),
}
}
}
#[cfg(test)]
mod tests {
use super::SandboxProvider;

View file

@ -13,6 +13,7 @@ use axum::{Json, Router, middleware};
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD};
use fabro_auth::{AuthCredential, AuthDetails, credential_id_for};
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::{Storage, resolve_server_from_file};
use fabro_install::{
InstallListenConfig, PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair,
@ -32,7 +33,6 @@ use tokio::time::sleep;
use tower::service_fn;
use tracing::{error, info, warn};
use crate::bind::{Bind, BindRequest};
use crate::error::ApiError;
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::{security_headers, static_files};
@ -827,7 +827,7 @@ async fn post_install_finish(
};
if let Err(err) = dev_token::write_dev_token(
&Storage::new(state.storage_dir.as_ref())
.runtime_state()
.runtime_directory()
.dev_token_path(),
&token,
) {

View file

@ -9,7 +9,6 @@
)]
pub mod auth;
pub mod bind;
mod canonical_origin;
pub mod csp;
#[allow(

View file

@ -5,6 +5,7 @@ use std::time::Duration;
use anyhow::Context;
use clap::Args;
use fabro_config::bind::{self, Bind, BindRequest};
use fabro_config::merge::combine_files;
use fabro_config::user::load_settings_config;
use fabro_config::{Storage, resolve_server_from_file};
@ -26,7 +27,6 @@ use tokio::sync::watch;
use tokio::time::interval;
use tracing::{error, info, warn};
use crate::bind::{self, Bind, BindRequest};
use crate::canonical_origin::resolve_canonical_origin;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
@ -470,7 +470,7 @@ where
};
let storage = Storage::new(&data_dir);
let vault_path = storage.secrets_path();
let server_env_path = storage.runtime_state().env_path();
let server_env_path = storage.runtime_directory().env_path();
let server_secrets = ServerSecrets::load(server_env_path.clone())?;
let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some();
@ -897,6 +897,7 @@ mod tests {
use std::path::PathBuf;
use std::time::Duration;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::parse_settings_layer;
use fabro_types::settings::SettingsLayer;
use fabro_util::Home;
@ -908,7 +909,6 @@ mod tests {
resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled,
server_bind_title, server_title,
};
use crate::bind::{Bind, BindRequest};
fn parse_settings(source: &str) -> SettingsLayer {
let mut layer = parse_settings_layer(source).expect("v2 fixture should parse");

View file

@ -39,6 +39,7 @@ pub use fabro_api::types::{
SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
};
use fabro_auth::parse_credential_secret;
use fabro_config::daemon::ServerDaemon;
use fabro_config::{Storage, resolve_server_from_file};
use fabro_interview::{
Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope,
@ -112,7 +113,6 @@ use tracing::{debug, error, info, warn};
use ulid::Ulid;
use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware};
use crate::bind::Bind;
use crate::canonical_origin::resolve_canonical_origin;
use crate::error::ApiError;
use crate::github_webhooks::{
@ -1538,16 +1538,8 @@ async fn attach_events(
};
let stream =
BroadcastStream::new(state.global_event_tx.subscribe()).filter_map(move |result| {
match result {
Ok(event) => {
if !event_matches_run_filter(&event, run_filter.as_ref()) {
return None;
}
sse_event_from_store(&event).map(Ok::<Event, std::convert::Infallible>)
}
Err(_) => None,
}
filtered_global_events(state.global_event_tx.subscribe(), run_filter).filter_map(|event| {
sse_event_from_store(&event).map(Ok::<Event, std::convert::Infallible>)
});
Sse::new(stream)
@ -1555,6 +1547,16 @@ async fn attach_events(
.into_response()
}
fn filtered_global_events(
event_rx: broadcast::Receiver<EventEnvelope>,
run_filter: Option<HashSet<RunId>>,
) -> impl tokio_stream::Stream<Item = EventEnvelope> {
BroadcastStream::new(event_rx).filter_map(move |result| match result {
Ok(event) if event_matches_run_filter(&event, run_filter.as_ref()) => Some(event),
Ok(_) | Err(_) => None,
})
}
struct PrunePlan {
run_ids: Vec<RunId>,
rows: Vec<PruneRunEntry>,
@ -1571,7 +1573,7 @@ fn build_disk_usage_response(
verbose: bool,
) -> anyhow::Result<DiskUsageResponse> {
let scratch_base_dir = scratch_base(storage_dir);
let logs_base_dir = Storage::new(storage_dir).runtime_state().logs_dir();
let logs_base_dir = Storage::new(storage_dir).runtime_directory().logs_dir();
let runs = scan_runs_with_summaries(summaries, &scratch_base_dir)?;
let mut active_count = 0u64;
@ -3774,33 +3776,6 @@ async fn append_worker_exit_failure(
}
}
#[derive(serde::Deserialize)]
struct WorkerServerRecord {
bind: Bind,
}
fn current_server_target(storage_dir: &std::path::Path) -> anyhow::Result<String> {
let record_path = Storage::new(storage_dir).runtime_state().record_path();
#[expect(
clippy::disallowed_methods,
reason = "sync helper invoked from worker_command (sync) via spawn_blocking at the async \
boundary in execute_run_subprocess; see commit 9d1c0d98c"
)]
let content = std::fs::read_to_string(&record_path)
.map_err(|err| anyhow::anyhow!("failed to read {}: {err}", record_path.display()))?;
let record: WorkerServerRecord = serde_json::from_str(&content).map_err(|err| {
anyhow::anyhow!(
"failed to parse server record {}: {err}",
record_path.display()
)
})?;
Ok(match record.bind {
Bind::Unix(path) => path.to_string_lossy().to_string(),
Bind::Tcp(addr) => format!("http://{addr}"),
})
}
fn worker_command(
state: &AppState,
run_id: RunId,
@ -3810,7 +3785,14 @@ fn worker_command(
let current_exe = std::env::current_exe().context("reading current executable path")?;
let exe = std::env::var_os("CARGO_BIN_EXE_fabro").map_or(current_exe, PathBuf::from);
let storage_dir = state.server_storage_dir();
let server_target = current_server_target(&storage_dir)?;
let runtime_directory = Storage::new(&storage_dir).runtime_directory();
let daemon = ServerDaemon::read(&runtime_directory)?.with_context(|| {
format!(
"server record {} is missing",
runtime_directory.record_path().display()
)
})?;
let server_target = daemon.bind.to_target();
let artifact_upload_token = state
.issue_artifact_upload_token(&run_id)
.map_err(|_| anyhow::anyhow!("failed to sign artifact upload token"))?;
@ -6817,7 +6799,13 @@ async fn list_models(
let provider = match params.provider.as_deref() {
Some(value) => match fabro_model::Provider::from_str(value) {
Ok(provider) => Some(provider),
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
Err(_) => {
return ApiError::new(
StatusCode::BAD_REQUEST,
format!("unknown provider: {value}"),
)
.into_response();
}
},
None => None,
};
@ -6865,7 +6853,13 @@ async fn test_model(
let mode = match params.mode.as_deref() {
Some(value) => match ModelTestMode::from_str(value) {
Ok(mode) => mode,
Err(err) => return ApiError::new(StatusCode::BAD_REQUEST, err).into_response(),
Err(_) => {
return ApiError::new(
StatusCode::BAD_REQUEST,
format!("invalid model test mode: {value}"),
)
.into_response();
}
},
None => ModelTestMode::Basic,
};
@ -7390,11 +7384,14 @@ mod tests {
use axum::body::Body;
use axum::http::{Request, header};
use chrono::Utc;
use fabro_config::bind::Bind;
use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType};
use fabro_model::Provider;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
use serde_json::json;
use tokio_stream::StreamExt as _;
use tower::ServiceExt;
use super::*;
@ -7949,15 +7946,13 @@ allowed_usernames = ["octocat"]
.join(", ")
))
.unwrap();
let record_path = Storage::new(storage_dir).runtime_state().record_path();
std::fs::create_dir_all(record_path.parent().unwrap()).unwrap();
std::fs::write(
&record_path,
serde_json::to_string(&json!({
"bind": Bind::Tcp("127.0.0.1:32276".parse::<std::net::SocketAddr>().unwrap()),
}))
.unwrap(),
let runtime_directory = Storage::new(storage_dir).runtime_directory();
ServerDaemon::new(
std::process::id(),
Bind::Tcp("127.0.0.1:32276".parse::<std::net::SocketAddr>().unwrap()),
runtime_directory.log_path(),
)
.write(&runtime_directory)
.unwrap();
create_app_state_with_env_lookup(settings, 5, move |name| match name {
@ -8185,6 +8180,27 @@ allowed_usernames = ["octocat"]
run_store.append_event(&payload).await.unwrap();
}
fn test_event_envelope(seq: u32, run_id: RunId, body: EventBody) -> EventEnvelope {
EventEnvelope {
seq,
event: RunEvent {
id: format!("evt-{seq}"),
ts: Utc::now(),
run_id,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body,
},
}
}
#[tokio::test]
async fn test_model_unknown_returns_404() {
let app = test_app_with();
@ -11269,6 +11285,36 @@ timeout = "30s"
assert!(matches!(sandbox_id, "sb-first" | "sb-second"));
}
#[tokio::test]
async fn filtered_global_events_streams_only_matching_run_ids() {
let run_one = fixtures::RUN_1;
let run_two = fixtures::RUN_2;
let (event_tx, _) = broadcast::channel(8);
let stream = filtered_global_events(event_tx.subscribe(), Some(HashSet::from([run_one])));
event_tx
.send(test_event_envelope(
1,
run_two,
EventBody::RunQueued(fabro_types::run_event::RunStatusEffectProps::default()),
))
.unwrap();
event_tx
.send(test_event_envelope(
2,
run_one,
EventBody::RunQueued(fabro_types::run_event::RunStatusEffectProps::default()),
))
.unwrap();
drop(event_tx);
let events = stream.collect::<Vec<_>>().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].seq, 2);
assert_eq!(events[0].event.run_id, run_one);
}
#[test]
fn validate_github_slug_accepts_real_names() {
assert!(super::validate_github_slug("owner", "anthropic", 39).is_ok());

View file

@ -395,7 +395,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
let server_env = std::fs::read_to_string(
fabro_config::Storage::new(temp_dir.path())
.runtime_state()
.runtime_directory()
.env_path(),
)
.unwrap();
@ -556,7 +556,8 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
);
let server_env =
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_state().env_path()).unwrap();
std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
.unwrap();
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
assert!(
@ -565,7 +566,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
);
assert!(
!Storage::new(temp_dir.path())
.runtime_state()
.runtime_directory()
.dev_token_path()
.exists(),
"storage dev token file should not be created for App installs"
@ -1372,7 +1373,7 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys(
"{ not valid json"
);
let server_env = std::fs::read_to_string(storage.runtime_state().env_path()).unwrap();
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
assert!(!callback_invoked.load(Ordering::Acquire));
@ -1441,10 +1442,10 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() {
let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path())
.expect("home dev token should exist");
let storage_dev_token =
dev_token::read_dev_token_file(&storage.runtime_state().dev_token_path())
dev_token::read_dev_token_file(&storage.runtime_directory().dev_token_path())
.expect("storage dev token should exist");
assert_eq!(home_dev_token, storage_dev_token);
let server_env = std::fs::read_to_string(storage.runtime_state().env_path()).unwrap();
let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={home_dev_token}")));
}

View file

@ -4,7 +4,6 @@
)]
use std::path::PathBuf;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
@ -13,9 +12,7 @@ use fabro_types::RunId;
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::server::{ServerLayer, ServerStorageLayer};
use http_body_util::BodyExt;
use tempfile::tempdir;
use tokio::time::timeout;
use tower::ServiceExt;
use crate::helpers::{
@ -263,22 +260,20 @@ async fn prune_runs_supports_dry_run_and_deletion() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attach_events_streams_only_matching_run_ids() {
async fn attach_events_returns_sse_stream() {
let (_temp, settings, _storage_dir) = temp_storage_settings();
let app = test_app_with_scheduler(test_app_state_with_options(settings, 5));
let run_one = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
let run_two = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await;
let run_id = RunId::new();
let request = Request::builder()
.method("GET")
.uri(api(&format!("/attach?run_id={run_one}")))
.uri(api(&format!("/attach?run_id={run_id}")))
.body(Body::empty())
.unwrap();
let response = checked_response(
app.clone().oneshot(request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/attach?run_id={run_one}"),
format!("GET /api/v1/attach?run_id={run_id}"),
)
.await;
let content_type = response
@ -288,27 +283,4 @@ async fn attach_events_streams_only_matching_run_ids() {
.to_str()
.unwrap();
assert!(content_type.contains("text/event-stream"));
start_run(&app, &run_one).await;
start_run(&app, &run_two).await;
let mut body = response.into_body();
let mut sse_data = String::new();
while let Ok(Some(Ok(frame))) = timeout(Duration::from_secs(2), body.frame()).await {
if let Some(data) = frame.data_ref() {
sse_data.push_str(&String::from_utf8_lossy(data));
if sse_data.contains(&run_one) {
break;
}
}
}
assert!(
sse_data.contains(&run_one),
"expected filtered stream data: {sse_data}"
);
assert!(
!sse_data.contains(&run_two),
"filtered stream should exclude non-matching run ids: {sse_data}"
);
}

View file

@ -9,8 +9,8 @@ use std::sync::Arc;
use std::time::Duration;
use axum::http::StatusCode;
use fabro_config::{ServerRuntimeState, parse_settings_layer, resolve_server_from_file};
use fabro_server::bind::Bind;
use fabro_config::bind::Bind;
use fabro_config::{RuntimeDirectory, parse_settings_layer, resolve_server_from_file};
use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
use fabro_server::serve::{ServeArgs, serve_command};
@ -64,7 +64,7 @@ fn write_test_config(tempdir: &TempDir, settings: &str) -> PathBuf {
let config_path = tempdir.path().join("settings.toml");
std::fs::write(&config_path, settings).expect("test settings should write");
std::fs::write(
ServerRuntimeState::new(tempdir.path()).env_path(),
RuntimeDirectory::new(tempdir.path()).env_path(),
format!("FABRO_DEV_TOKEN={TEST_DEV_TOKEN}\nSESSION_SECRET={TEST_SESSION_SECRET}\n"),
)
.expect("test env file should write");

View file

@ -13,7 +13,8 @@ use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use assert_cmd::Command;
use fabro_config::{Storage, envfile};
use fabro_config::daemon::ServerDaemon;
use fabro_config::{RuntimeDirectory, Storage, envfile};
use fabro_types::RunId;
use fabro_util::browser;
use regex::Regex;
@ -629,7 +630,7 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
}
fn write_test_server_dev_token(storage_dir: &Path) {
let server_env_path = Storage::new(storage_dir).runtime_state().env_path();
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display()));
}
@ -875,25 +876,15 @@ fn clear_server_storage(table: &mut TomlMap<String, TomlValue>) {
}
}
fn server_record_path(storage_dir: &Path) -> PathBuf {
storage_dir.join("server.json")
}
fn server_record_pid(storage_dir: &Path) -> Option<u32> {
let record_path = server_record_path(storage_dir);
let Ok(content) = std::fs::read_to_string(&record_path) else {
return None;
};
let Ok(record) = serde_json::from_str::<serde_json::Value>(&content) else {
return None;
};
record["pid"]
.as_u64()
.and_then(|pid| u32::try_from(pid).ok())
fn server_runtime_directory(server: &ServerPaths) -> RuntimeDirectory {
Storage::new(&server.storage_dir).runtime_directory()
}
fn server_running(server: &ServerPaths) -> bool {
server_record_pid(&server.storage_dir).is_some_and(fabro_proc::process_running)
ServerDaemon::load_running(&server_runtime_directory(server))
.ok()
.flatten()
.is_some()
}
#[expect(
@ -931,7 +922,7 @@ fn ensure_server_running(fabro_bin: &Path, server: &ServerPaths, config_path: &P
std::fs::create_dir_all(&server.storage_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", server.storage_dir.display()));
write_test_server_dev_token(&server.storage_dir);
let _ = std::fs::remove_file(server_record_path(&server.storage_dir));
ServerDaemon::remove(&server_runtime_directory(server));
let _ = std::fs::remove_file(&server.socket_path);
let mut bootstrap = std::process::Command::new(fabro_bin);
@ -965,28 +956,28 @@ fn ensure_server_running(fabro_bin: &Path, server: &ServerPaths, config_path: &P
reason = "This sync test helper polls child shutdown during cleanup without requiring a Tokio runtime."
)]
fn stop_test_server(server: &ServerPaths) {
let record_path = server_record_path(&server.storage_dir);
let Some(pid) = server_record_pid(&server.storage_dir) else {
let runtime_directory = server_runtime_directory(server);
let Some(daemon) = ServerDaemon::read(&runtime_directory).ok().flatten() else {
let _ = std::fs::remove_file(&server.socket_path);
let _ = std::fs::remove_file(&record_path);
ServerDaemon::remove(&runtime_directory);
return;
};
fabro_proc::sigterm(pid);
fabro_proc::sigterm(daemon.pid);
let poll = std::time::Duration::from_millis(50);
let timeout = test_server_stop_timeout();
let mut elapsed = std::time::Duration::ZERO;
while elapsed < timeout && fabro_proc::process_running(pid) {
while elapsed < timeout && fabro_proc::process_running(daemon.pid) {
std::thread::sleep(poll);
elapsed += poll;
}
if fabro_proc::process_running(pid) {
fabro_proc::sigkill(pid);
if fabro_proc::process_running(daemon.pid) {
fabro_proc::sigkill(daemon.pid);
}
let _ = std::fs::remove_file(&server.socket_path);
let _ = std::fs::remove_file(&record_path);
ServerDaemon::remove(&runtime_directory);
}
fn test_server_stop_timeout() -> std::time::Duration {

View file

@ -29,6 +29,7 @@ ipnet = { version = "2.11.0", features = ["serde"] }
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
strum.workspace = true
toml.workspace = true
ulid.workspace = true

View file

@ -160,7 +160,6 @@ impl fmt::Display for RunStatus {
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct InvalidTransition {
pub from: RunStatus,
@ -298,7 +297,6 @@ impl From<TerminalStatus> for RunStatus {
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockedReason {

View file

@ -333,12 +333,12 @@ impl RunSession {
.map(InterpString::as_source)
.filter(|value| !value.is_empty());
let provider_enum: Provider = provider
.as_deref()
.map(str::parse::<Provider>)
.transpose()
.map_err(|err| Error::Precondition(err.clone()))?
.unwrap_or_else(|| Provider::default_for_configured(&configured));
let provider_enum: Provider = match provider.as_deref() {
Some(value) => value
.parse::<Provider>()
.map_err(|_| Error::Precondition(format!("unknown provider: {value}")))?,
None => Provider::default_for_configured(&configured),
};
let fallback_chain = resolve_fallback_chain(provider_enum, &model, &resolved.model);
let mcp_servers = resolved

View file

@ -120,10 +120,13 @@ impl RunDump {
}
if let Some(prompt) = state.retro_prompt.as_ref() {
entries.push(RunDumpEntry::text("retro/prompt.md", prompt.clone()));
entries.push(RunDumpEntry::text("stages/retro/prompt.md", prompt.clone()));
}
if let Some(response) = state.retro_response.as_ref() {
entries.push(RunDumpEntry::text("retro/response.md", response.clone()));
entries.push(RunDumpEntry::text(
"stages/retro/response.md",
response.clone(),
));
}
Self { entries }
@ -399,12 +402,10 @@ fn replace_blob_refs_in_value(
}
fn artifact_dump_path(stage_id: &StageId, filename: &str) -> Result<PathBuf> {
let node_id_segment = validate_single_path_segment("node id", stage_id.node_id())?;
validate_single_path_segment("node id", stage_id.node_id())?;
let filename_path = validate_relative_path("artifact filename", filename)?;
Ok(PathBuf::from("artifacts")
.join("nodes")
.join(node_id_segment)
.join(format!("visit-{}", stage_id.visit()))
.join(stage_id.to_string())
.join(filename_path))
}
@ -542,8 +543,8 @@ mod tests {
assert!(paths.contains(&"run.json"));
assert!(paths.contains(&"graph.fabro"));
assert!(paths.contains(&"retro/prompt.md"));
assert!(paths.contains(&"retro/response.md"));
assert!(paths.contains(&"stages/retro/prompt.md"));
assert!(paths.contains(&"stages/retro/response.md"));
assert!(paths.contains(&"stages/build@2/prompt.md"));
assert!(paths.contains(&"stages/build@2/response.md"));
assert!(paths.contains(&"stages/build@2/status.json"));