mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
commit
e34affcf94
58 changed files with 643 additions and 948 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
27
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ AI coding agents are powerful but unpredictable. You either babysit every step o
|
|||
[](https://github.com/fabro-sh/fabro/actions/workflows/rust.yml)
|
||||
[](LICENSE.md)
|
||||
[](https://docs.fabro.sh)
|
||||

|
||||
|
||||
```bash
|
||||
# With Claude Code
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_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(¤t_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,
|
||||
|
|
|
|||
|
|
@ -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(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_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(¤t_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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(¤t_record);
|
||||
let legacy_record = fabro_home.join("server.json");
|
||||
std::fs::rename(¤t_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(¤t_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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
193
lib/crates/fabro-config/src/daemon.rs
Normal file
193
lib/crates/fabro-config/src/daemon.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -19,4 +19,5 @@ fabro-types = { path = "../fabro-types" }
|
|||
nom = "7"
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
strum.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ workspace = true
|
|||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
insta.workspace = true
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod bind;
|
||||
mod canonical_origin;
|
||||
pub mod csp;
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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}")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue