Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bryan Helmkamp 2026-04-05 13:05:28 -04:00
commit f97468985e
No known key found for this signature in database
39 changed files with 529 additions and 1045 deletions

View file

@ -635,7 +635,7 @@ pub(crate) enum RunCommands {
Run(RunArgs),
/// Create a workflow run (allocate run dir, persist spec)
Create(RunArgs),
/// Start a created workflow run (spawn engine process)
/// Start a created workflow run on the server
Start {
/// Run ID prefix or workflow name
run: String,
@ -645,18 +645,12 @@ pub(crate) enum RunCommands {
/// Run ID prefix or workflow name
run: String,
},
/// Internal: run the engine process
#[command(name = "__detached", hide = true)]
Detached {
/// Internal: queue or resume a workflow run via the server
#[command(name = "__runner", hide = true)]
Runner {
/// Run ID
#[arg(long)]
run_id: fabro_types::RunId,
/// Run directory
#[arg(long)]
run_dir: PathBuf,
/// Launcher metadata path
#[arg(long)]
launcher_path: PathBuf,
/// Resume from checkpoint instead of fresh start
#[arg(long)]
resume: bool,
@ -683,7 +677,7 @@ impl RunCommands {
Self::Create(_) => "create",
Self::Start { .. } => "start",
Self::Attach { .. } => "attach",
Self::Detached { .. } => "__detached",
Self::Runner { .. } => "__runner",
Self::Diff(_) => "diff",
Self::Logs(_) => "logs",
Self::Resume(_) => "resume",

View file

@ -3,20 +3,17 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use fabro_store::RuntimeState;
use fabro_workflow::artifacts::{ArtifactEntry, scan_artifacts};
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use crate::args::{ArtifactCpArgs, GlobalArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, split_run_path};
use crate::user_config::load_user_settings_with_globals;
pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let (run_id, asset_path) = parse_source(&args.source);
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, run_id)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(run_id)?;
let runtime_state = RuntimeState::new(&run.path);
let entries = scan_artifacts(
&runtime_state.artifacts_dir(),

View file

@ -1,19 +1,16 @@
use anyhow::Result;
use fabro_store::RuntimeState;
use fabro_workflow::artifacts::scan_artifacts;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use crate::args::{ArtifactListArgs, GlobalArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::format_size;
use crate::user_config::load_user_settings_with_globals;
pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run_id)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let runtime_state = RuntimeState::new(&run.path);
let entries = scan_artifacts(
&runtime_state.artifacts_dir(),

View file

@ -5,12 +5,12 @@ use fabro_model::Catalog;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_workflow::outcome::StageStatus;
use fabro_workflow::pull_request::maybe_open_pull_request;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use fabro_workflow::run_lookup::runs_base;
use tracing::info;
use crate::args::{GlobalArgs, PrCreateArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
@ -30,12 +30,10 @@ async fn create_from(
github_app: Option<fabro_github::GitHubAppCredentials>,
globals: &GlobalArgs,
) -> Result<()> {
let storage_dir = base.parent().unwrap_or(base);
let client = server_client::connect_server(storage_dir).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, base, &args.run_id)?;
let lookup = ServerRunLookup::connect_from_runs_base(base).await?;
let run = lookup.resolve(&args.run_id)?;
let run_id = run.run_id();
let events = client.list_run_events(&run_id, None, None).await?;
let events = lookup.client().list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let state = run_store.state().await?;

View file

@ -9,6 +9,7 @@ use tracing::info;
use crate::args::{GlobalArgs, PrListArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
@ -28,9 +29,16 @@ pub(super) async fn list_command(
) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
list_from(&client, &summaries, &base, args, github_app, globals).await
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
list_from(
lookup.client(),
lookup.summaries(),
&base,
args,
github_app,
globals,
)
.await
}
async fn list_from(

View file

@ -9,10 +9,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fabro_types::PullRequestRecord;
use fabro_workflow::run_lookup::resolve_run_from_summaries;
use crate::args::{GlobalArgs, PrCommand, PrNamespace};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::github::build_github_app_credentials;
use crate::user_config::load_user_settings_with_globals;
@ -35,13 +34,11 @@ pub(crate) async fn load_pr_record(
base: &Path,
run_id: &str,
) -> Result<(PullRequestRecord, PathBuf)> {
let storage_dir = base.parent().unwrap_or(base);
let client = server_client::connect_server(storage_dir).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, base, run_id)?;
let lookup = ServerRunLookup::connect_from_runs_base(base).await?;
let run = lookup.resolve(run_id)?;
let run_id = run.run_id();
let run_dir = run.path;
let state = client.get_run_state(&run_id).await?;
let state = lookup.client().get_run_state(&run_id).await?;
let record = state.pull_request.with_context(|| {
format!("No pull request found in store. Create one first with: fabro pr create {run_id}")
})?;

View file

@ -21,10 +21,6 @@ use tokio::time::sleep;
use super::run_progress;
use crate::server_client;
#[cfg(test)]
const ATTACH_STARTUP_GRACE: Duration = Duration::from_millis(200);
#[cfg(not(test))]
const ATTACH_STARTUP_GRACE: Duration = Duration::from_secs(3);
const INTERVIEW_UNANSWERED_MESSAGE: &str =
"Interview ended without an answer. The run is still waiting for input; reattach to answer it.";
const JSON_INTERVIEW_MESSAGE: &str = "This run is waiting for human input, but --json is non-interactive. Reattach without --json to answer it.";
@ -42,7 +38,6 @@ pub(crate) async fn attach_run(
run_id: Option<&RunId>,
kill_on_detach: bool,
styles: &'static Styles,
engine_child: Option<std::process::Child>,
json_output: bool,
) -> Result<ExitCode> {
let inferred_storage_dir = infer_storage_dir(run_dir);
@ -64,7 +59,6 @@ pub(crate) async fn attach_run(
.collect::<Result<Vec<_>>>()?;
let initial_exit_code = events.iter().rev().find_map(event_exit_code);
return attach_run_server(
run_dir,
&client,
run_id,
verbose,
@ -73,7 +67,6 @@ pub(crate) async fn attach_run(
initial_exit_code,
kill_on_detach,
styles,
engine_child,
json_output,
)
.await;
@ -85,7 +78,6 @@ pub(crate) async fn attach_run(
}
async fn attach_run_server(
run_dir: &Path,
client: &server_client::ServerStoreClient,
run_id: &RunId,
verbose: bool,
@ -94,11 +86,8 @@ async fn attach_run_server(
initial_exit_code: Option<ExitCode>,
kill_on_detach: bool,
styles: &'static Styles,
engine_child: Option<std::process::Child>,
json_output: bool,
) -> Result<ExitCode> {
let mut engine_guard = engine_child.map(EngineChildGuard::new);
let is_tty = std::io::stderr().is_terminal();
let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose);
@ -117,30 +106,18 @@ async fn attach_run_server(
}
if json_output && !client.list_run_questions(run_id).await?.is_empty() {
defuse_engine_child(&mut engine_guard);
eprintln!("{JSON_INTERVIEW_MESSAGE}");
return Ok(ExitCode::from(1));
}
let mut next_seq = if last_seq == 0 { 1 } else { last_seq + 1 };
let mut cached_pid: Option<u32> = None;
let attach_started = Instant::now();
let mut terminal_exit_code = initial_exit_code;
let mut terminal_event_seen_at = initial_exit_code.map(|_| Instant::now());
loop {
let server_owned = engine_guard.is_none() && read_launcher_pid(run_dir).is_none();
if cancelled.load(Ordering::Relaxed) {
if kill_on_detach {
if let Some(guard) = engine_guard.as_mut() {
if let Some(child) = guard.inner() {
let _ = child.kill();
}
} else if server_owned {
let _ = client.cancel_run(run_id).await;
} else {
kill_engine(run_dir);
}
let _ = client.cancel_run(run_id).await;
// Wait briefly for a terminal status or conclusion
for _ in 0..20 {
if client
@ -159,9 +136,6 @@ async fn attach_run_server(
sleep(Duration::from_millis(100)).await;
}
} else {
if let Some(guard) = engine_guard.as_mut() {
guard.defuse();
}
eprintln!("Detached from run (engine continues in background)");
}
break;
@ -197,7 +171,6 @@ async fn attach_run_server(
// Check for server-backed interview request
if let Some(question) = client.list_run_questions(run_id).await?.into_iter().next() {
if json_output {
defuse_engine_child(&mut engine_guard);
eprintln!("{JSON_INTERVIEW_MESSAGE}");
return Ok(ExitCode::from(1));
}
@ -212,9 +185,6 @@ async fn attach_run_server(
show_progress(&mut progress_ui, json_output);
if answer_requires_reattach(&answer) {
if let Some(guard) = engine_guard.as_mut() {
guard.defuse();
}
eprintln!("{INTERVIEW_UNANSWERED_MESSAGE}");
return Ok(ExitCode::from(1));
}
@ -230,64 +200,10 @@ async fn attach_run_server(
.and_then(|state| state.status.map(|record| record.status))
.filter(|status| status.is_terminal());
let child_alive_via_handle = engine_guard.as_mut().and_then(|guard| {
guard.inner().map(|child| match child.try_wait() {
Ok(None) => true, // still running
Ok(Some(_)) | Err(_) => false, // exited or error
})
});
if let Some(child_alive) = child_alive_via_handle {
if !child_alive && !saw_event {
flush_remaining_server_events(
client,
run_id,
next_seq,
&mut progress_ui,
json_output,
)
if terminal_status.is_some() && !saw_event {
flush_remaining_server_events(client, run_id, next_seq, &mut progress_ui, json_output)
.await?;
break;
}
} else {
if terminal_status.is_some() && !saw_event {
flush_remaining_server_events(
client,
run_id,
next_seq,
&mut progress_ui,
json_output,
)
.await?;
break;
}
let engine_alive = if server_owned {
true
} else {
match cached_pid {
Some(pid) => process_alive(pid),
None => {
if let Some(pid) = read_launcher_pid(run_dir) {
cached_pid = Some(pid);
process_alive(pid)
} else {
attach_started.elapsed() < ATTACH_STARTUP_GRACE
}
}
}
};
if !engine_alive {
flush_remaining_server_events(
client,
run_id,
next_seq,
&mut progress_ui,
json_output,
)
.await?;
break;
}
break;
}
if !saw_event {
@ -450,10 +366,6 @@ fn restore_empty_run_properties(value: &mut serde_json::Value) {
}
}
fn read_launcher_pid(run_dir: &Path) -> Option<u32> {
super::launcher::active_launcher_record_for_run(run_dir).map(|record| record.pid)
}
fn infer_storage_dir(run_dir: &Path) -> Option<PathBuf> {
let runs_dir = run_dir.parent()?;
let storage_dir = runs_dir.parent()?;
@ -461,124 +373,17 @@ fn infer_storage_dir(run_dir: &Path) -> Option<PathBuf> {
}
fn infer_run_id(run_dir: &Path) -> Option<RunId> {
super::launcher::launcher_record_for_run(run_dir)
.map(|record| record.run_id)
.or_else(|| {
std::fs::read_to_string(run_dir.join("id.txt"))
.ok()
.map(|run_id| run_id.trim().to_string())
.filter(|run_id| !run_id.is_empty())
.and_then(|run_id| run_id.parse().ok())
})
}
#[cfg(test)]
struct InterviewClaimGuard {
claim_path: PathBuf,
}
#[cfg(test)]
impl InterviewClaimGuard {
fn acquire(claim_path: &Path) -> Option<Self> {
if try_claim_interview_request(claim_path) {
Some(Self {
claim_path: claim_path.to_path_buf(),
})
} else {
None
}
}
}
#[cfg(test)]
impl Drop for InterviewClaimGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.claim_path);
}
}
struct EngineChildGuard {
child: Option<std::process::Child>,
}
impl EngineChildGuard {
fn new(child: std::process::Child) -> Self {
Self { child: Some(child) }
}
fn inner(&mut self) -> Option<&mut std::process::Child> {
self.child.as_mut()
}
fn defuse(&mut self) {
self.child.take();
}
}
fn defuse_engine_child(engine_guard: &mut Option<EngineChildGuard>) {
if let Some(guard) = engine_guard.as_mut() {
guard.defuse();
}
}
impl Drop for EngineChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[cfg(test)]
fn try_claim_interview_request(claim_path: &Path) -> bool {
if let Some(parent) = claim_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return false;
}
}
if let Ok(existing) = std::fs::read_to_string(claim_path) {
if let Ok(pid) = existing.trim().parse::<u32>() {
if process_alive(pid) {
return pid == std::process::id();
}
}
let _ = std::fs::remove_file(claim_path);
}
match std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(claim_path)
{
Ok(mut file) => {
let _ = writeln!(file, "{}", std::process::id());
true
}
Err(_) => false,
}
std::fs::read_to_string(run_dir.join("id.txt"))
.ok()
.map(|run_id| run_id.trim().to_string())
.filter(|run_id| !run_id.is_empty())
.and_then(|run_id| run_id.parse().ok())
}
fn answer_requires_reattach(answer: &fabro_interview::Answer) -> bool {
matches!(answer.value, AnswerValue::Aborted | AnswerValue::Skipped)
}
#[cfg(test)]
fn write_interview_response_atomically(
response_path: &Path,
answer: &fabro_interview::Answer,
) -> Result<()> {
let response_json = serde_json::to_string_pretty(answer)?;
if let Some(parent) = response_path.parent() {
std::fs::create_dir_all(parent)?;
}
let temp_path = response_path.with_extension("json.tmp");
std::fs::write(&temp_path, response_json)?;
std::fs::rename(temp_path, response_path)?;
Ok(())
}
async fn determine_exit_code_with_server(
client: &server_client::ServerStoreClient,
run_id: &RunId,
@ -614,16 +419,6 @@ async fn determine_exit_code_with_server(
}
}
fn kill_engine(run_dir: &Path) {
if let Some(pid) = read_launcher_pid(run_dir) {
fabro_proc::sigterm(pid);
}
}
fn process_alive(pid: u32) -> bool {
fabro_proc::process_alive(pid)
}
fn event_exit_code(event: &EventEnvelope) -> Option<ExitCode> {
let run_event = RunEvent::try_from(&event.payload).ok()?;
match run_event.body {
@ -642,8 +437,6 @@ fn event_exit_code(event: &EventEnvelope) -> Option<ExitCode> {
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::run::launcher;
use chrono::Utc;
use fabro_interview::{Answer, AnswerValue};
use fabro_util::terminal::Styles;
@ -655,17 +448,9 @@ mod tests {
async fn attach_errors_without_store_context() {
let dir = tempfile::tempdir().unwrap();
let err = attach_run(
dir.path(),
None,
None,
false,
no_color_styles(),
None,
false,
)
.await
.unwrap_err();
let err = attach_run(dir.path(), None, None, false, no_color_styles(), false)
.await
.unwrap_err();
assert!(
err.to_string()
@ -690,55 +475,20 @@ mod tests {
}
#[test]
fn infer_run_id_uses_launcher_record_without_run_json() {
fn infer_run_id_reads_id_txt() {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
let run_dir = storage_dir.join("runs").join("20260401-test");
std::fs::create_dir_all(&run_dir).unwrap();
launcher::write_launcher_record(
&launcher::launcher_record_path(&storage_dir, &fabro_types::fixtures::RUN_1),
&launcher::LauncherRecord {
run_id: fabro_types::fixtures::RUN_1,
run_dir: run_dir.clone(),
pid: u32::MAX,
resume: false,
log_path: dir.path().join("launcher.log"),
started_at: Utc::now(),
},
std::fs::write(
run_dir.join("id.txt"),
format!("{}\n", fabro_types::fixtures::RUN_1),
)
.unwrap();
assert_eq!(infer_run_id(&run_dir), Some(fabro_types::fixtures::RUN_1));
}
#[test]
fn try_claim_interview_request_reclaims_stale_claim() {
let dir = tempfile::tempdir().unwrap();
let claim_path = dir.path().join("runtime").join("interview_request.claim");
std::fs::create_dir_all(claim_path.parent().unwrap()).unwrap();
std::fs::write(&claim_path, "999999\n").unwrap();
assert!(try_claim_interview_request(&claim_path));
assert_eq!(
std::fs::read_to_string(claim_path).unwrap(),
format!("{}\n", std::process::id())
);
}
#[test]
fn interview_claim_guard_releases_claim_on_drop() {
let dir = tempfile::tempdir().unwrap();
let claim_path = dir.path().join("runtime").join("interview_request.claim");
{
let _guard = InterviewClaimGuard::acquire(&claim_path).unwrap();
assert!(claim_path.exists());
}
assert!(!claim_path.exists());
}
#[test]
fn answer_requires_reattach_for_aborted_and_skipped_answers() {
let aborted = Answer {
@ -757,65 +507,4 @@ mod tests {
assert!(answer_requires_reattach(&skipped));
assert!(!answer_requires_reattach(&answered));
}
#[test]
fn engine_child_guard_kills_on_drop() {
let child = std::process::Command::new("sleep")
.arg("60")
.spawn()
.unwrap();
let pid = child.id();
{
let _guard = EngineChildGuard::new(child);
}
// Process should be dead after guard is dropped
assert!(
!process_alive(pid),
"process should be dead after guard drop"
);
}
#[test]
fn engine_child_guard_defuse_keeps_alive() {
let child = std::process::Command::new("sleep")
.arg("60")
.spawn()
.unwrap();
let pid = child.id();
{
let mut guard = EngineChildGuard::new(child);
guard.defuse();
}
// Process should still be alive after defused guard is dropped
assert!(
process_alive(pid),
"process should still be alive after defused guard drop"
);
// Clean up
#[cfg(unix)]
fabro_proc::sigkill(pid);
}
#[test]
fn write_interview_response_atomically_persists_answer() {
let dir = tempfile::tempdir().unwrap();
let response_path = dir.path().join("interview_response.json");
let answer = Answer {
value: AnswerValue::Text("ship it".to_string()),
selected_option: None,
text: Some("ship it".to_string()),
};
write_interview_response_atomically(&response_path, &answer).unwrap();
let saved: Answer =
serde_json::from_str(&std::fs::read_to_string(&response_path).unwrap()).unwrap();
assert_eq!(saved.text.as_deref(), Some("ship it"));
assert!(!response_path.with_extension("json.tmp").exists());
}
}

View file

@ -36,7 +36,6 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<(
Some(&run_id),
true,
styles,
None,
globals.json,
)
.await?;

View file

@ -3,12 +3,11 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use fabro_agent::sandbox::Sandbox;
use fabro_sandbox::reconnect::reconnect;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use tokio::fs;
use tracing::{debug, info};
use crate::args::{CpArgs, GlobalArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, split_run_path};
use crate::user_config::load_user_settings_with_globals;
@ -28,7 +27,6 @@ enum CopyDirection {
pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> {
let direction = parse_direction(&args.src, &args.dst)?;
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
match direction {
CopyDirection::Download {
@ -36,7 +34,7 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()>
remote_path,
local_path,
} => {
let sandbox = load_sandbox(&cli_settings.storage_dir(), &base, &run_prefix).await?;
let sandbox = load_sandbox(&cli_settings.storage_dir(), &run_prefix).await?;
let file_count = if args.recursive {
Some(download_recursive(&*sandbox, &remote_path, &local_path).await?)
@ -69,7 +67,7 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()>
run_prefix,
remote_path,
} => {
let sandbox = load_sandbox(&cli_settings.storage_dir(), &base, &run_prefix).await?;
let sandbox = load_sandbox(&cli_settings.storage_dir(), &run_prefix).await?;
let file_count = if args.recursive {
Some(upload_recursive(&*sandbox, &local_path, &remote_path).await?)
@ -123,15 +121,11 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
}
}
async fn load_sandbox(
storage_dir: &Path,
base: &Path,
run_prefix: &str,
) -> Result<Box<dyn Sandbox>> {
let client = server_client::connect_server(storage_dir).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, base, run_prefix)?;
let record = client
async fn load_sandbox(storage_dir: &Path, run_prefix: &str) -> Result<Box<dyn Sandbox>> {
let lookup = ServerRunLookup::connect(storage_dir).await?;
let run = lookup.resolve(run_prefix)?;
let record = lookup
.client()
.get_run_state(&run.run_id())
.await?
.sandbox

View file

@ -3,25 +3,22 @@ use std::path::Path;
use anyhow::{Context, Result, bail};
use fabro_sandbox::reconnect::reconnect;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use fabro_workflow::sandbox_git::GIT_REMOTE;
use tracing::{debug, info};
use crate::args::{DiffArgs, GlobalArgs};
use crate::server_client;
use crate::server_client::RunProjection;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let state = client.get_run_state(&run_id).await?;
let state = lookup.client().get_run_state(&run_id).await?;
let patch = resolve_diff(&run.path, &state, &args).await?;

View file

@ -3,25 +3,22 @@ use anyhow::Result;
use fabro_checkpoint::git::Store;
use fabro_util::terminal::Styles;
use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork};
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use git2::Repository;
use crate::args::{ForkArgs, GlobalArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_user_settings_with_globals(globals)?;
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let base = runs_base(&cli_settings.storage_dir());
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run_id)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let run_id = run.run_id();
let store = Store::new(repo);
let events = client.list_run_events(&run_id, None, None).await?;
let events = lookup.client().list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;

View file

@ -1,217 +0,0 @@
use std::path::{Path, PathBuf};
#[cfg(test)]
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use fabro_types::RunId;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use crate::commands::run::short_run_id;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LauncherRecord {
pub run_id: RunId,
pub run_dir: PathBuf,
pub pid: u32,
pub resume: bool,
pub log_path: PathBuf,
pub started_at: DateTime<Utc>,
}
pub(crate) fn launcher_dir(storage_dir: &Path) -> PathBuf {
storage_dir.join("launchers")
}
pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &RunId) -> PathBuf {
launcher_dir(storage_dir).join(format!("{run_id}.json"))
}
#[cfg(test)]
pub(crate) fn write_launcher_record(path: &Path, record: &LauncherRecord) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(record)?)
.with_context(|| format!("Failed to write launcher metadata to {}", path.display()))
}
pub(crate) fn read_launcher_record(path: &Path) -> Option<LauncherRecord> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub(crate) fn remove_launcher_record(path: &Path) {
let _ = std::fs::remove_file(path);
}
pub(crate) fn active_launcher_record_for_run(run_dir: &Path) -> Option<LauncherRecord> {
let launcher = launcher_record_for_run(run_dir)?;
let storage_dir = run_dir.parent()?.parent()?;
let path = launcher_record_path(storage_dir, &launcher.run_id);
if launcher_record_is_running(&launcher) {
Some(launcher)
} else {
remove_launcher_record(&path);
None
}
}
pub(crate) fn launcher_record_for_run(run_dir: &Path) -> Option<LauncherRecord> {
let storage_dir = run_dir.parent()?.parent()?;
let launchers_dir = launcher_dir(storage_dir);
let entries = std::fs::read_dir(&launchers_dir).ok()?;
for entry in entries.filter_map(std::result::Result::ok) {
let path = entry.path();
let Some(launcher) = read_launcher_record(&path) else {
continue;
};
if launcher.run_dir == run_dir {
return Some(launcher);
}
}
None
}
#[cfg(test)]
pub(crate) fn active_launcher_record(storage_dir: &Path, run_id: &RunId) -> Option<LauncherRecord> {
let path = launcher_record_path(storage_dir, run_id);
let launcher = read_launcher_record(&path)?;
if launcher_record_is_running(&launcher) {
Some(launcher)
} else {
remove_launcher_record(&path);
None
}
}
pub(crate) fn launcher_record_is_running(record: &LauncherRecord) -> bool {
fabro_proc::process_alive(record.pid) && launcher_process_matches(record)
}
#[cfg(unix)]
fn launcher_process_matches(record: &LauncherRecord) -> 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_matches_launcher(record, &command)
}
#[cfg(unix)]
fn command_matches_launcher(record: &LauncherRecord, command: &str) -> bool {
let run_dir = record.run_dir.to_string_lossy();
let old_match = command.contains("__detached") && command.contains(run_dir.as_ref());
let run_id = record.run_id.to_string();
let new_match = command.contains(&format!("fabro: {}", super::short_run_id(&run_id)));
old_match || new_match
}
#[cfg(not(unix))]
fn launcher_process_matches(_record: &LauncherRecord) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use fabro_types::fixtures;
#[test]
fn active_launcher_record_for_run_removes_stale_record() {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
let run_dir = storage_dir.join("runs").join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let launcher_path = launcher_record_path(&storage_dir, &fixtures::RUN_1);
write_launcher_record(
&launcher_path,
&LauncherRecord {
run_id: fixtures::RUN_1,
run_dir: run_dir.clone(),
pid: u32::MAX,
resume: false,
log_path: dir.path().join("launcher.log"),
started_at: Utc::now(),
},
)
.unwrap();
assert!(active_launcher_record_for_run(&run_dir).is_none());
assert!(active_launcher_record(&storage_dir, &fixtures::RUN_1).is_none());
assert!(!launcher_path.exists());
}
#[test]
fn active_launcher_record_for_run_falls_back_without_run_json() {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
let run_dir = storage_dir.join("runs").join("20260401-test");
std::fs::create_dir_all(&run_dir).unwrap();
let launcher_path = launcher_record_path(&storage_dir, &fixtures::RUN_1);
write_launcher_record(
&launcher_path,
&LauncherRecord {
run_id: fixtures::RUN_1,
run_dir: run_dir.clone(),
pid: u32::MAX,
resume: false,
log_path: dir.path().join("launcher.log"),
started_at: Utc::now(),
},
)
.unwrap();
assert!(active_launcher_record_for_run(&run_dir).is_none());
assert!(!launcher_path.exists());
}
#[cfg(unix)]
#[test]
fn command_matches_launcher_accepts_old_detached_format() {
let dir = tempfile::tempdir().unwrap();
let record = LauncherRecord {
run_id: fixtures::RUN_2,
run_dir: dir.path().join("run"),
pid: 42,
resume: false,
log_path: dir.path().join("launcher.log"),
started_at: Utc::now(),
};
let command = format!(
"/usr/local/bin/fabro __detached --run-dir {} --launcher-path /tmp/launcher.json",
record.run_dir.display()
);
assert!(command_matches_launcher(&record, &command));
}
#[cfg(unix)]
#[test]
fn command_matches_launcher_accepts_new_title_format() {
let dir = tempfile::tempdir().unwrap();
let record = LauncherRecord {
run_id: fixtures::RUN_3,
run_dir: dir.path().join("run"),
pid: 42,
resume: false,
log_path: dir.path().join("launcher.log"),
started_at: Utc::now(),
};
assert!(command_matches_launcher(
&record,
&format!("fabro: {} plan", short_run_id(&record.run_id.to_string()))
));
}
}

View file

@ -7,20 +7,19 @@ use chrono::{DateTime, Utc};
use fabro_util::json::normalize_json_value;
use fabro_util::redact::redact_jsonl_line;
use fabro_util::terminal::Styles;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use tokio::time;
use tracing::{debug, info};
use crate::args::{GlobalArgs, LogsArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::user_config::load_user_settings_with_globals;
pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let client = lookup.client();
let run_id = run.run_id();
info!(run_id = %run_id, "Showing logs");
@ -64,7 +63,7 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs)
return Ok(());
}
follow_store_logs(
&client,
client,
&run_id,
if last_seq == 0 { 1 } else { last_seq + 1 },
pretty,

View file

@ -1,9 +1,8 @@
use anyhow::Result;
use fabro_util::terminal::Styles;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use crate::args::{GlobalArgs, RunArgs, RunCommands};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals};
@ -11,10 +10,8 @@ pub(crate) mod attach;
pub(crate) mod command;
pub(crate) mod cp;
pub(crate) mod create;
pub(crate) mod detached;
pub(crate) mod diff;
pub(crate) mod fork;
pub(crate) mod launcher;
pub(crate) mod logs;
pub(crate) mod output;
pub(crate) mod overrides;
@ -22,14 +19,11 @@ pub(crate) mod preview;
pub(crate) mod resume;
pub(crate) mod rewind;
pub(crate) mod run_progress;
pub(crate) mod runner;
pub(crate) mod ssh;
pub(crate) mod start;
pub(crate) mod wait;
pub(super) fn short_run_id(id: &str) -> &str {
if id.len() > 12 { &id[..12] } else { id }
}
fn apply_json_defaults(args: &mut RunArgs, globals: &GlobalArgs) {
if globals.json {
args.auto_approve = true;
@ -56,10 +50,8 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
}
RunCommands::Start { run } => {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run_info = resolve_run_from_summaries(&summaries, &base, &run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run_info = lookup.resolve(&run)?;
let run_id = run_info.run_id();
start::start_run(&run_id, &cli_settings.storage_dir(), false).await?;
if globals.json {
@ -70,10 +62,8 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
RunCommands::Attach { run } => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run_info = resolve_run_from_summaries(&summaries, &base, &run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run_info = lookup.resolve(&run)?;
let run_id = run_info.run_id();
let exit_code = attach::attach_run(
&run_info.path,
@ -81,7 +71,6 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
Some(&run_id),
false,
styles,
None,
globals.json,
)
.await?;
@ -90,20 +79,8 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
}
Ok(())
}
RunCommands::Detached {
run_id,
run_dir,
launcher_path,
resume,
} => {
detached::execute(
run_id,
run_dir,
globals.storage_dir.clone(),
launcher_path,
resume,
)
.await
RunCommands::Runner { run_id, resume } => {
runner::execute(run_id, globals.storage_dir.clone(), resume).await
}
RunCommands::Diff(args) => diff::run(args, globals).await,
RunCommands::Logs(args) => {

View file

@ -1,20 +1,18 @@
use anyhow::{Context, Result};
use fabro_sandbox::daytona::DaytonaSandbox;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use tracing::info;
use crate::args::{GlobalArgs, PreviewArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, validate_daytona_provider};
use crate::user_config::load_user_settings_with_globals;
pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let record = client
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let record = lookup
.client()
.get_run_state(&run.run_id())
.await?
.sandbox

View file

@ -1,15 +1,14 @@
use fabro_util::terminal::Styles;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use crate::args::{GlobalArgs, ResumeArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
/// Resume an interrupted workflow run.
///
/// Looks up the run by ID prefix, validates a checkpoint exists, cleans stale
/// artifacts from the previous execution, then spawns an engine subprocess
/// artifacts from the previous execution, then asks the server to resume it
/// (identical to `fabro run`'s create→start→attach flow).
pub(crate) async fn resume_command(
args: ResumeArgs,
@ -17,10 +16,8 @@ pub(crate) async fn resume_command(
globals: &GlobalArgs,
) -> anyhow::Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let run_dir = run.path;
@ -39,7 +36,6 @@ pub(crate) async fn resume_command(
Some(&run_id),
true,
styles,
None,
globals.json,
)
.await?;

View file

@ -10,14 +10,13 @@ use fabro_workflow::git::MetadataStore;
use fabro_workflow::operations::{
RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind,
};
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use git2::Repository;
use serde::Serialize;
use crate::args::{GlobalArgs, RewindArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_client;
use crate::server_client::ServerStoreClient;
use crate::server_runs::ServerRunLookup;
use crate::shared::{color_if, print_json_pretty};
use crate::user_config::load_user_settings_with_globals;
@ -32,13 +31,11 @@ pub(crate) struct TimelineEntryJson {
pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_user_settings_with_globals(globals)?;
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let base = runs_base(&cli_settings.storage_dir());
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run_id)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run_id)?;
let run_id = run.run_id();
let store = Store::new(repo);
let events = client.list_run_events(&run_id, None, None).await?;
let events = lookup.client().list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
@ -63,7 +60,7 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs
},
)?;
let entry = timeline.resolve(&target)?;
reset_rewound_run_state(&client, &store, &run_id, &run.path, entry).await?;
reset_rewound_run_state(lookup.client(), &store, &run_id, &run.path, entry).await?;
let run_id_string = run_id.to_string();

View file

@ -10,17 +10,11 @@ use crate::user_config::load_user_settings;
pub(crate) async fn execute(
run_id: RunId,
_run_dir: PathBuf,
storage_dir: Option<PathBuf>,
launcher_path: PathBuf,
resume: bool,
) -> Result<()> {
let _ = fabro_proc::title_init();
let _launcher_guard = scopeguard::guard(launcher_path.clone(), |path| {
super::launcher::remove_launcher_record(&path);
});
let storage_dir = match storage_dir {
Some(storage_dir) => storage_dir,
None => load_user_settings()?.storage_dir(),

View file

@ -1,10 +1,9 @@
use anyhow::{Context, Result, bail};
use fabro_sandbox::daytona::DaytonaSandbox;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use tracing::info;
use crate::args::{GlobalArgs, SshArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{print_json_pretty, validate_daytona_provider};
use crate::user_config::load_user_settings_with_globals;
@ -14,12 +13,11 @@ pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> {
}
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let record = client
let record = lookup
.client()
.get_run_state(&run_id)
.await?
.sandbox

View file

@ -4,12 +4,11 @@ use anyhow::{Result, bail};
use fabro_types::RunId;
use fabro_util::terminal::Styles;
use fabro_workflow::records::Conclusion;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use fabro_workflow::run_status::RunStatus;
use tracing::info;
use crate::args::{GlobalArgs, WaitArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::format_duration_ms;
use crate::user_config::load_user_settings_with_globals;
@ -20,10 +19,9 @@ const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3
pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run_info = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run_info = lookup.resolve(&args.run)?;
let client = lookup.client();
let run_id = run_info.run_id();
info!(run_id = %run_id, "Waiting for run to complete");

View file

@ -4,12 +4,11 @@ use anyhow::Result;
use fabro_types::RunId;
use serde::Serialize;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use fabro_workflow::run_status::RunStatus;
use crate::args::{GlobalArgs, InspectArgs};
use crate::server_client;
use crate::server_client::RunProjection;
use crate::server_runs::ServerRunLookup;
use crate::user_config::load_user_settings_with_globals;
#[derive(Debug, Serialize)]
@ -26,12 +25,10 @@ pub(crate) struct InspectOutput {
pub(crate) async fn run(args: &InspectArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let state = client.get_run_state(&run_id).await?;
let state = lookup.client().get_run_state(&run_id).await?;
let output = inspect_run_state(&run_id, &run.path, run.status(), state);
let json = serde_json::to_string_pretty(&[output])?;
println!("{json}");

View file

@ -11,7 +11,7 @@ use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs
use fabro_workflow::run_status::RunStatus;
use crate::args::{GlobalArgs, RunsListArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{color_if, format_duration_ms, tilde_path};
use crate::user_config::load_user_settings_with_globals;
@ -25,9 +25,8 @@ pub(crate) async fn list_command(
) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let runs = scan_runs_with_summaries(&summaries, &base)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let runs = scan_runs_with_summaries(lookup.summaries(), &base)?;
let label_filters = parse_label_filters(&args.filter.label);
let filtered = filter_runs(
&runs,

View file

@ -4,12 +4,13 @@ use anyhow::{Context, Result, bail};
use fabro_sandbox::reconnect::reconnect as reconnect_sandbox;
use fabro_workflow::event::{Event, to_run_event};
use fabro_workflow::run_lookup::RunInfo;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
use fabro_workflow::run_lookup::resolve_run_from_summaries;
use tracing::warn;
use crate::args::{GlobalArgs, RunsRemoveArgs};
use crate::server_client;
use crate::server_client::RunProjection;
use crate::server_runs::ServerRunLookup;
use crate::shared::print_json_pretty;
use crate::user_config::load_user_settings_with_globals;
@ -17,10 +18,15 @@ use super::short_run_id;
pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
remove_from(args, &client, &summaries, &base, globals).await
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
remove_from(
args,
lookup.client(),
lookup.summaries(),
lookup.runs_base(),
globals,
)
.await
}
async fn remove_from(

View file

@ -3,7 +3,6 @@ use anyhow::{Context, Result};
use fabro_store::StageId;
use fabro_store::{RunProjection, SlateRunStore};
use fabro_workflow::run_dump::RunDump;
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
#[cfg(test)]
use serde::de::DeserializeOwned;
use std::io::ErrorKind;
@ -11,18 +10,16 @@ use std::path::Path;
use crate::args::{GlobalArgs, StoreDumpArgs};
use crate::commands::store::rebuild::rebuild_run_store;
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{absolute_or_current, print_json_pretty};
use crate::user_config::load_user_settings_with_globals;
pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let events = client.list_run_events(&run_id, None, None).await?;
let events = lookup.client().list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let file_count = export_run(&run_store, &args.output).await?;

View file

@ -10,7 +10,7 @@ use fabro_workflow::run_lookup::{logs_base, runs_base, scan_runs_with_summaries}
use fabro_workflow::run_status::RunStatus;
use crate::args::{DfArgs, GlobalArgs};
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{format_size, print_json_pretty};
use crate::user_config::load_user_settings_with_globals;
@ -49,11 +49,10 @@ pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()
let data_dir = cli_settings.storage_dir();
let runs_base_dir = runs_base(&data_dir);
let logs_base_dir = logs_base(&data_dir);
let client = server_client::connect_server(&data_dir).await?;
let summaries = client.list_store_runs().await?;
let lookup = ServerRunLookup::connect(&data_dir).await?;
df_from(
args,
&summaries,
lookup.summaries(),
&data_dir,
&runs_base_dir,
&logs_base_dir,

View file

@ -10,6 +10,7 @@ use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs
use crate::args::{GlobalArgs, RunsPruneArgs};
use crate::commands::runs::rm::remove_run_with_cleanup;
use crate::server_client;
use crate::server_runs::ServerRunLookup;
use crate::shared::{format_size, print_json_pretty};
use crate::user_config::load_user_settings_with_globals;
@ -24,9 +25,8 @@ struct PruneRunRow {
pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
let summaries = client.list_store_runs().await?;
prune_from(args, &client, &summaries, &base, globals).await
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
prune_from(args, lookup.client(), lookup.summaries(), &base, globals).await
}
pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {

View file

@ -4,6 +4,7 @@ mod args;
mod commands;
mod logging;
mod server_client;
mod server_runs;
mod shared;
#[cfg(feature = "sleep_inhibitor")]
mod sleep_inhibitor;
@ -421,31 +422,17 @@ mod tests {
}
#[test]
fn parse_detached_command() {
fn parse_runner_command() {
let cli = Cli::try_parse_from([
"fabro",
"__detached",
"__runner",
"--run-id",
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"--run-dir",
"/tmp/fabro/runs/01ABC",
"--launcher-path",
"/tmp/fabro/launchers/01ABC.json",
])
.expect("should parse");
match *cli.command {
Commands::RunCmd(RunCommands::Detached {
run_id,
run_dir,
launcher_path,
resume,
}) => {
Commands::RunCmd(RunCommands::Runner { run_id, resume }) => {
assert_eq!(run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC"));
assert_eq!(
launcher_path,
std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json")
);
assert!(!resume);
}
_ => panic!("unexpected command variant"),
@ -453,32 +440,18 @@ mod tests {
}
#[test]
fn parse_detached_with_resume() {
fn parse_runner_with_resume() {
let cli = Cli::try_parse_from([
"fabro",
"__detached",
"__runner",
"--run-id",
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"--run-dir",
"/tmp/fabro/runs/01ABC",
"--launcher-path",
"/tmp/fabro/launchers/01ABC.json",
"--resume",
])
.expect("should parse");
match *cli.command {
Commands::RunCmd(RunCommands::Detached {
run_id,
run_dir,
launcher_path,
resume,
}) => {
Commands::RunCmd(RunCommands::Runner { run_id, resume }) => {
assert_eq!(run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC"));
assert_eq!(
launcher_path,
std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json")
);
assert!(resume);
}
_ => panic!("unexpected command variant"),

View file

@ -0,0 +1,46 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_store::RunSummary;
use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, runs_base};
use crate::server_client::{self, ServerStoreClient};
pub(crate) struct ServerRunLookup {
client: ServerStoreClient,
runs_base: PathBuf,
summaries: Vec<RunSummary>,
}
impl ServerRunLookup {
pub(crate) async fn connect(storage_dir: &Path) -> Result<Self> {
Self::connect_from_runs_base(&runs_base(storage_dir)).await
}
pub(crate) async fn connect_from_runs_base(runs_base: &Path) -> Result<Self> {
let storage_dir = runs_base.parent().unwrap_or(runs_base);
let client = server_client::connect_server(storage_dir).await?;
let summaries = client.list_store_runs().await?;
Ok(Self {
client,
runs_base: runs_base.to_path_buf(),
summaries,
})
}
pub(crate) fn client(&self) -> &ServerStoreClient {
&self.client
}
pub(crate) fn runs_base(&self) -> &Path {
&self.runs_base
}
pub(crate) fn summaries(&self) -> &[RunSummary] {
&self.summaries
}
pub(crate) fn resolve(&self, selector: &str) -> Result<RunInfo> {
resolve_run_from_summaries(&self.summaries, &self.runs_base, selector)
}
}

View file

@ -1,6 +1,6 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{read_text, setup_artifact_run, setup_completed_fast_dry_run, text_tree};
use super::support::setup_completed_fast_dry_run;
#[test]
fn help() {
@ -51,101 +51,3 @@ fn artifact_cp_empty_run_reports_no_artifacts() {
error: No artifacts found for this run
");
}
#[test]
fn artifact_cp_specific_path_copies_single_asset() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-one");
let mut cmd = context.command();
cmd.args([
"artifact",
"cp",
&format!("{}:assets/shared/report.txt", setup.run.run_id),
dest.to_str().unwrap(),
"--node",
"create_assets",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Copied assets/shared/report.txt to [TEMP_DIR]/artifact-one/report.txt
----- stderr -----
");
assert_eq!(read_text(&dest.join("report.txt")), "one");
}
#[test]
fn artifact_cp_ambiguous_path_requires_node_or_retry() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-one");
let mut cmd = context.command();
cmd.args([
"artifact",
"cp",
&format!("{}:assets/retry/report.txt", setup.run.run_id),
dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
");
}
#[test]
fn artifact_cp_tree_preserves_structure() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-tree");
let mut cmd = context.command();
cmd.args([
"artifact",
"cp",
&setup.run.run_id,
dest.to_str().unwrap(),
"--tree",
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Copied 6 artifact(s) to [TEMP_DIR]/artifact-tree
----- stderr -----
");
insta::assert_snapshot!(
text_tree(&dest).join("\n"),
@r"
create_assets/retry_1/assets/node_a/summary.txt = alpha
create_assets/retry_1/assets/shared/report.txt = one
create_colliding/retry_1/assets/other/summary.txt = beta
create_colliding/retry_1/assets/retry/report.txt = second
retry_assets/retry_1/assets/retry/report.txt = first
retry_assets/retry_2/assets/retry/report.txt = second
"
);
}
#[test]
fn artifact_cp_flat_mode_rejects_filename_collisions() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-flat");
let mut cmd = context.command();
cmd.args(["artifact", "cp", &setup.run.run_id, dest.to_str().unwrap()]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter.
");
}

View file

@ -1,6 +1,6 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{setup_artifact_run, setup_completed_fast_dry_run};
use super::support::setup_completed_fast_dry_run;
#[test]
fn help() {
@ -48,105 +48,3 @@ fn artifact_list_empty_run_reports_no_artifacts() {
----- stderr -----
");
}
#[test]
fn artifact_list_json_outputs_entries() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let mut filters = context.filters();
filters.push((
r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(),
"[RUN_DIR]".to_string(),
));
let mut cmd = context.command();
cmd.args(["artifact", "list", &setup.run.run_id, "--json"]);
fabro_snapshot!(filters, cmd, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/node_a/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/node_a/summary.txt",
"size": 5
},
{
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/shared/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/shared/report.txt",
"size": 3
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/other/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/other/summary.txt",
"size": 4
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/retry/report.txt",
"size": 6
},
{
"node_slug": "retry_assets",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_1/assets/retry/report.txt",
"size": 5
},
{
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
}
#[test]
fn artifact_list_filters_by_node_and_retry() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let mut filters = context.filters();
filters.push((
r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(),
"[RUN_DIR]".to_string(),
));
let mut cmd = context.command();
cmd.args([
"artifact",
"list",
&setup.run.run_id,
"--node",
"retry_assets",
"--retry",
"2",
"--json",
]);
fabro_snapshot!(filters, cmd, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
}

View file

@ -14,7 +14,7 @@ fn help() {
Commands:
run Launch a workflow run
create Create a workflow run (allocate run dir, persist spec)
start Start a created workflow run (spawn engine process)
start Start a created workflow run on the server
attach Attach to a running or finished workflow run
logs View the event log of a workflow run
resume Resume an interrupted workflow run

View file

@ -5,7 +5,6 @@ mod attach;
mod completion;
mod config;
mod create;
mod detached;
mod diff;
mod discord;
mod docs;
@ -41,6 +40,7 @@ mod resume;
mod rewind;
mod rm;
mod run;
mod runner;
mod sandbox_cp;
mod sandbox_preview;
mod sandbox_ssh;

View file

@ -2,6 +2,8 @@ use fabro_test::{fabro_snapshot, test_context};
use super::support::{git_stdout, output_stderr, setup_git_backed_changed_run};
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[test]
fn help() {
let context = test_context!();
@ -115,3 +117,44 @@ fn resume_rewound_run_succeeds() {
);
assert_ne!(resumed_head.trim(), rewound_head.trim());
}
#[test]
fn resume_detached_does_not_create_launcher_record() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@1", "--no-push"])
.assert()
.success();
let mut resume_cmd = context.command();
resume_cmd.current_dir(&setup.repo_dir);
resume_cmd.env("OPENAI_API_KEY", "test");
resume_cmd.args(["resume", "--detach", &setup.run.run_id]);
let resume_output = resume_cmd.output().expect("resume should execute");
assert!(
resume_output.status.success(),
"resume should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&resume_output.stdout),
output_stderr(&resume_output)
);
assert!(
!context
.storage_dir
.join("launchers")
.join(format!("{}.json", setup.run.run_id))
.exists(),
"server-owned resume should not create a launcher record"
);
context
.command()
.args(["wait", &setup.run.run_id])
.timeout(SHARED_DAEMON_TIMEOUT)
.assert()
.success();
}

View file

@ -1,8 +1,15 @@
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::StatusReason;
use serde_json::Value;
use super::support::{
only_run, output_stderr, run_count_for_test_case, run_state, wait_for_status,
write_gated_workflow,
};
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[test]
fn help() {
let context = test_context!();
@ -964,3 +971,69 @@ fn detach_creates_run_dir_with_detach_log() {
"#
);
}
#[test]
fn ctrl_c_cancels_active_run_via_server() {
let context = test_context!();
let _gate = write_gated_workflow(&context.temp_dir.join("slow.fabro"), "slow", "Run slowly");
let mut run_cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
run_cmd.current_dir(&context.temp_dir);
run_cmd.env("NO_COLOR", "1");
run_cmd.env("HOME", &context.home_dir);
run_cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
run_cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
run_cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64");
run_cmd.env("OPENAI_API_KEY", "test");
run_cmd.args([
"run",
"--label",
&context.test_run_label(),
"--label",
&context.test_case_label(),
"--provider",
"openai",
"--sandbox",
"local",
"--no-retro",
"slow.fabro",
]);
let child = run_cmd.spawn().expect("run should spawn");
let deadline = std::time::Instant::now() + SHARED_DAEMON_TIMEOUT;
while run_count_for_test_case(&context) == 0 {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for run directory"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
let run = only_run(&context);
wait_for_status(&run.run_dir, &["running"]);
let kill_status = std::process::Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.expect("kill should execute");
assert!(kill_status.success(), "kill -INT should succeed");
let output = child
.wait_with_output()
.expect("run should exit after SIGINT");
assert!(
!output.status.success(),
"run should exit non-zero after cancellation\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
output_stderr(&output)
);
let final_status = wait_for_status(&run.run_dir, &["failed"]);
assert_eq!(final_status, "failed");
assert_eq!(
run_state(&run.run_dir)
.status
.and_then(|record| record.reason),
Some(StatusReason::Cancelled)
);
}

View file

@ -9,41 +9,32 @@ const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_sec
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["__detached", "--help"]);
cmd.args(["__runner", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Internal: run the engine process
Internal: queue or resume a workflow run via the server
Usage: fabro __detached [OPTIONS] --run-id <RUN_ID> --run-dir <RUN_DIR> --launcher-path <LAUNCHER_PATH>
Usage: fabro __runner [OPTIONS] --run-id <RUN_ID>
Options:
--json Output as JSON [env: FABRO_JSON=]
--run-id <RUN_ID> Run ID
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--run-dir <RUN_DIR> Run directory
--launcher-path <LAUNCHER_PATH> Launcher metadata path
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--resume Resume from checkpoint instead of fresh start
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--server-url <SERVER_URL> Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=]
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--run-id <RUN_ID> Run ID
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--resume Resume from checkpoint instead of fresh start
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
--storage-dir <STORAGE_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--server-url <SERVER_URL> Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=]
-h, --help Print help
----- stderr -----
");
}
fn launcher_path(context: &fabro_test::TestContext, run_id: &str) -> std::path::PathBuf {
context
.storage_dir
.join("launchers")
.join(format!("{run_id}.json"))
}
#[test]
fn detached_uses_cached_graph_after_source_deleted() {
fn runner_uses_cached_graph_after_source_deleted() {
let context = test_context!();
let run_id = unique_run_id();
let workflow_path = context.temp_dir.join("workflow.fabro");
@ -77,15 +68,7 @@ digraph CachedGraph {
context
.command()
.args([
"__detached",
"--run-id",
run_id.as_str(),
"--run-dir",
run_dir.to_str().unwrap(),
"--launcher-path",
launcher_path(&context, &run_id).to_str().unwrap(),
])
.args(["__runner", "--run-id", run_id.as_str()])
.timeout(SHARED_DAEMON_TIMEOUT)
.assert()
.success();
@ -110,7 +93,7 @@ digraph CachedGraph {
}
#[test]
fn detached_uses_snapshotted_app_id_for_github_credentials() {
fn runner_uses_snapshotted_app_id_for_github_credentials() {
let context = test_context!();
let run_id = unique_run_id();
let workflow_path = context.temp_dir.join("workflow.fabro");
@ -167,15 +150,7 @@ digraph GitHubApp {
let mut cmd = context.command();
cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%");
cmd.args([
"__detached",
"--run-id",
run_id.as_str(),
"--run-dir",
run_dir.to_str().unwrap(),
"--launcher-path",
launcher_path(&context, &run_id).to_str().unwrap(),
]);
cmd.args(["__runner", "--run-id", run_id.as_str()]);
cmd.timeout(SHARED_DAEMON_TIMEOUT);
fabro_snapshot!(context.filters(), cmd, @"
success: true
@ -186,7 +161,7 @@ digraph GitHubApp {
}
#[test]
fn detached_runs_without_run_json_when_run_id_is_explicit() {
fn runner_runs_without_run_json_when_run_id_is_explicit() {
let context = test_context!();
let run_id = unique_run_id();
let workflow_path = context.temp_dir.join("workflow.fabro");
@ -218,15 +193,7 @@ digraph DetachedStoreOnly {
let run_dir = context.find_run_dir(&run_id);
context
.command()
.args([
"__detached",
"--run-id",
run_id.as_str(),
"--run-dir",
run_dir.to_str().unwrap(),
"--launcher-path",
launcher_path(&context, &run_id).to_str().unwrap(),
])
.args(["__runner", "--run-id", run_id.as_str()])
.timeout(SHARED_DAEMON_TIMEOUT)
.assert()
.success();
@ -251,7 +218,7 @@ digraph DetachedStoreOnly {
}
#[test]
fn detached_resume_rejects_completed_run_without_mutating_it() {
fn runner_resume_rejects_completed_run_without_mutating_it() {
let context = test_context!();
context.write_temp(
"workflow.fabro",
@ -301,7 +268,6 @@ digraph Test {
"conclusion_timestamp": before[0]["conclusion"]["timestamp"],
"conclusion_status": before[0]["conclusion"]["status"],
});
let run_dir = before_summary["run_dir"].as_str().unwrap().to_string();
fabro_json_snapshot!(context, &before_summary, @r#"
{
"run_dir": "[RUN_DIR]",
@ -312,16 +278,7 @@ digraph Test {
"#);
let mut cmd = context.command();
cmd.args([
"__detached",
"--run-id",
&run_id,
"--run-dir",
&run_dir,
"--launcher-path",
launcher_path(&context, &run_id).to_str().unwrap(),
"--resume",
]);
cmd.args(["__runner", "--run-id", &run_id, "--resume"]);
cmd.timeout(SHARED_DAEMON_TIMEOUT);
fabro_snapshot!(context.filters(), cmd, @"
success: true

View file

@ -15,7 +15,7 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Start a created workflow run (spawn engine process)
Start a created workflow run on the server
Usage: fabro start [OPTIONS] <RUN>

View file

@ -0,0 +1,181 @@
use std::time::Duration;
use fabro_test::{fabro_snapshot, test_context};
use crate::cmd::support::{read_text, setup_artifact_run, text_tree};
fn artifact_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> {
let mut filters = context.filters();
filters.push((
r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(),
"[RUN_DIR]".to_string(),
));
filters
}
#[test]
fn artifact_commands_share_populated_run_fixture() {
let context = test_context!();
let setup = setup_artifact_run(&context);
let filters = artifact_filters(&context);
let mut list_json = context.command();
list_json.args(["artifact", "list", &setup.run.run_id, "--json"]);
fabro_snapshot!(filters.clone(), list_json, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/node_a/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/node_a/summary.txt",
"size": 5
},
{
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/shared/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/shared/report.txt",
"size": 3
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/other/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/other/summary.txt",
"size": 4
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/retry/report.txt",
"size": 6
},
{
"node_slug": "retry_assets",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_1/assets/retry/report.txt",
"size": 5
},
{
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
let mut list_filtered = context.command();
list_filtered.args([
"artifact",
"list",
&setup.run.run_id,
"--node",
"retry_assets",
"--retry",
"2",
"--json",
]);
fabro_snapshot!(filters.clone(), list_filtered, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]
----- stderr -----
"#);
let single_dest = context.temp_dir.join("artifact-one");
let mut cp_single = context.command();
cp_single.args([
"artifact",
"cp",
&format!("{}:assets/shared/report.txt", setup.run.run_id),
single_dest.to_str().unwrap(),
"--node",
"create_assets",
]);
fabro_snapshot!(context.filters(), cp_single, @"
success: true
exit_code: 0
----- stdout -----
Copied assets/shared/report.txt to [TEMP_DIR]/artifact-one/report.txt
----- stderr -----
");
assert_eq!(read_text(&single_dest.join("report.txt")), "one");
let tree_dest = context.temp_dir.join("artifact-tree");
let mut cp_tree = context.command();
cp_tree.args([
"artifact",
"cp",
&setup.run.run_id,
tree_dest.to_str().unwrap(),
"--tree",
]);
cp_tree.timeout(Duration::from_secs(30));
fabro_snapshot!(context.filters(), cp_tree, @"
success: true
exit_code: 0
----- stdout -----
Copied 6 artifact(s) to [TEMP_DIR]/artifact-tree
----- stderr -----
");
insta::assert_snapshot!(
text_tree(&tree_dest).join("\n"),
@r"
create_assets/retry_1/assets/node_a/summary.txt = alpha
create_assets/retry_1/assets/shared/report.txt = one
create_colliding/retry_1/assets/other/summary.txt = beta
create_colliding/retry_1/assets/retry/report.txt = second
retry_assets/retry_1/assets/retry/report.txt = first
retry_assets/retry_2/assets/retry/report.txt = second
"
);
let ambiguous_dest = context.temp_dir.join("artifact-ambiguous");
let mut cp_ambiguous = context.command();
cp_ambiguous.args([
"artifact",
"cp",
&format!("{}:assets/retry/report.txt", setup.run.run_id),
ambiguous_dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cp_ambiguous, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
");
let flat_dest = context.temp_dir.join("artifact-flat");
let mut cp_flat = context.command();
cp_flat.args([
"artifact",
"cp",
&setup.run.run_id,
flat_dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cp_flat, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Filename collision: 'summary.txt' exists in both create_assets:retry_1 and create_colliding:retry_1. Use --tree to preserve directory structure, or --node and/or --retry to filter.
");
}

View file

@ -1,3 +1,4 @@
mod artifacts;
mod exec;
mod lifecycle;
mod recovery;

View file

@ -147,7 +147,7 @@ enum RunExecutionMode {
}
enum ExecutionResult {
Completed(Result<operations::Started, FabroError>),
Completed(Box<Result<operations::Started, FabroError>>),
CancelledBySignal,
}
@ -1001,15 +1001,15 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
let cancelled_during_setup = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
if managed_run.status != RunStatus::Starting {
if managed_run.status == RunStatus::Starting {
managed_run.status = RunStatus::Running;
managed_run.interviewer = Some(Arc::clone(&interviewer));
false
} else {
// Was cancelled during setup
clear_live_run_state(managed_run);
state.scheduler_notify.notify_one();
true
} else {
managed_run.status = RunStatus::Running;
managed_run.interviewer = Some(Arc::clone(&interviewer));
false
}
} else {
false
@ -1085,14 +1085,14 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
};
let result = tokio::select! {
result = execution => ExecutionResult::Completed(result),
result = execution => ExecutionResult::Completed(Box::new(result)),
_ = cancel_rx => {
cancel_token.store(true, Ordering::SeqCst);
ExecutionResult::CancelledBySignal
}
};
if matches!(result, ExecutionResult::CancelledBySignal) {
if matches!(&result, ExecutionResult::CancelledBySignal) {
if let Err(err) = persist_cancelled_run_status(state.as_ref(), run_id).await {
error!(run_id = %run_id, error = %err, "Failed to persist cancelled run status");
}
@ -1139,11 +1139,22 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
match &result {
ExecutionResult::Completed(Ok(started)) => match &started.finalized.outcome {
Ok(_) => {
info!(run_id = %run_id, "Run completed");
managed_run.status = RunStatus::Completed;
}
ExecutionResult::Completed(result) => match result.as_ref() {
Ok(started) => match &started.finalized.outcome {
Ok(_) => {
info!(run_id = %run_id, "Run completed");
managed_run.status = RunStatus::Completed;
}
Err(FabroError::Cancelled) => {
info!(run_id = %run_id, "Run cancelled");
managed_run.status = RunStatus::Cancelled;
}
Err(e) => {
error!(run_id = %run_id, error = %e, "Run failed");
managed_run.status = RunStatus::Failed;
managed_run.error = Some(e.to_string());
}
},
Err(FabroError::Cancelled) => {
info!(run_id = %run_id, "Run cancelled");
managed_run.status = RunStatus::Cancelled;
@ -1154,16 +1165,10 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
managed_run.error = Some(e.to_string());
}
},
ExecutionResult::Completed(Err(FabroError::Cancelled))
| ExecutionResult::CancelledBySignal => {
ExecutionResult::CancelledBySignal => {
info!(run_id = %run_id, "Run cancelled");
managed_run.status = RunStatus::Cancelled;
}
ExecutionResult::Completed(Err(e)) => {
error!(run_id = %run_id, error = %e, "Run failed");
managed_run.status = RunStatus::Failed;
managed_run.error = Some(e.to_string());
}
}
managed_run.checkpoint = checkpoint;
managed_run.run_dir = Some(run_dir);