mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
f97468985e
39 changed files with 529 additions and 1045 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<(
|
|||
Some(&run_id),
|
||||
true,
|
||||
styles,
|
||||
None,
|
||||
globals.json,
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
46
lib/crates/fabro-cli/src/server_runs.rs
Normal file
46
lib/crates/fabro-cli/src/server_runs.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
"#);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
181
lib/crates/fabro-cli/tests/it/scenario/artifacts.rs
Normal file
181
lib/crates/fabro-cli/tests/it/scenario/artifacts.rs
Normal 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.
|
||||
");
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod artifacts;
|
||||
mod exec;
|
||||
mod lifecycle;
|
||||
mod recovery;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue