Use typed RunId across workflows

This commit is contained in:
Bryan Helmkamp 2026-03-30 09:20:11 -04:00
parent c6ff6c95a2
commit e304dcca37
No known key found for this signature in database
70 changed files with 1414 additions and 744 deletions

View file

@ -34,6 +34,22 @@ filter = """
"""
test-group = 'medium'
[[profile.default.overrides]]
filter = "package(fabro-hooks)"
slow-timeout = { period = "5s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(fabro-agent)"
slow-timeout = { period = "5s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(fabro-llm)"
slow-timeout = { period = "5s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(fabro-server)"
slow-timeout = { period = "5s", terminate-after = 3 }
[profile.e2e]
# E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s
slow-timeout = { period = "10s", terminate-after = 3 }

1
Cargo.lock generated
View file

@ -1948,6 +1948,7 @@ dependencies = [
"fabro-macros",
"serde",
"serde_json",
"ulid",
]
[[package]]

View file

@ -49,7 +49,7 @@ async fn list_from(
let pr_path = run.path.join("pull_request.json");
if let Ok(content) = std::fs::read_to_string(&pr_path) {
if let Ok(record) = serde_json::from_str::<PullRequestRecord>(&content) {
entries.push((run.run_id.clone(), record));
entries.push((run.run_id.to_string(), record));
}
}
}

View file

@ -7,6 +7,7 @@ use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use fabro_config::FabroSettingsExt;
use fabro_types::RunId;
use futures::StreamExt;
use fabro_interview::{AnswerValue, ConsoleInterviewer};
@ -33,7 +34,7 @@ const INTERVIEW_UNANSWERED_MESSAGE: &str =
/// Returns exit code 0 for success/partial_success, 1 otherwise.
pub(crate) async fn attach_run(
run_dir: &Path,
run_id: Option<&str>,
run_id: Option<&RunId>,
kill_on_detach: bool,
styles: &'static Styles,
engine_child: Option<std::process::Child>,
@ -43,7 +44,7 @@ pub(crate) async fn attach_run(
run_record
.as_ref()
.map(|record| record.settings.storage_dir()),
run_id.or_else(|| run_record.as_ref().map(|record| record.run_id.as_str())),
run_id.or_else(|| run_record.as_ref().map(|record| &record.run_id)),
) {
match store::open_run_reader(&storage_dir, run_id).await {
Ok(Some(run_store)) => match run_store.list_events().await {
@ -65,7 +66,7 @@ pub(crate) async fn attach_run(
}
Err(err) => {
tracing::warn!(
run_id,
run_id = %run_id,
error = %err,
"Failed to list events from store; falling back to filesystem attach"
);
@ -74,7 +75,7 @@ pub(crate) async fn attach_run(
Ok(None) => {}
Err(err) => {
tracing::warn!(
run_id,
run_id = %run_id,
error = %err,
"Failed to open store reader; falling back to filesystem attach"
);

View file

@ -2,6 +2,7 @@ use std::path::PathBuf;
use crate::args::RunArgs;
use fabro_config::{ConfigLayer, FabroSettings};
use fabro_types::RunId;
use fabro_util::terminal::Styles;
use fabro_workflows::error::FabroError;
use fabro_workflows::operations::{CreateRunInput, WorkflowInput, create};
@ -16,7 +17,7 @@ pub(crate) fn create_run(
cli_defaults: ConfigLayer,
styles: &Styles,
quiet: bool,
) -> anyhow::Result<(String, PathBuf)> {
) -> anyhow::Result<(RunId, PathBuf)> {
let workflow_path = args
.workflow
.as_ref()
@ -28,13 +29,20 @@ pub(crate) fn create_run(
.combine(cli_defaults)
.resolve()?;
let run_id = args
.run_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.map_err(|err| anyhow::anyhow!("invalid run ID: {err}"))?;
let created = match create(CreateRunInput {
workflow: WorkflowInput::Path(workflow_path.clone()),
settings,
cwd,
workflow_slug: None,
run_dir: None,
run_id: args.run_id.clone(),
run_id,
base_branch: None,
host_repo_path: None,
}) {

View file

@ -22,7 +22,8 @@ pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bo
let run_record = RunRecord::load(&run_dir)?;
let on_node: fabro_workflows::OnNodeCallback = Some({
let short_id = super::short_run_id(&run_record.run_id).to_string();
let run_id = run_record.run_id.to_string();
let short_id = super::short_run_id(&run_id).to_string();
fabro_proctitle::set(&format!("fabro: {short_id}"));
Arc::new(move |node_id: &str| {
fabro_proctitle::set(&format!("fabro: {short_id} {node_id}"));

View file

@ -36,20 +36,23 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs)
let new_run_id = fork(
&store,
&ForkRunInput {
source_run_id: run_id.clone(),
source_run_id: run_id,
target,
push: !args.no_push,
},
)?;
let run_id_string = run_id.to_string();
let new_run_id_string = new_run_id.to_string();
eprintln!(
"\nForked run {} -> {}",
&run_id[..8.min(run_id.len())],
&new_run_id[..8.min(new_run_id.len())]
&run_id_string[..8.min(run_id_string.len())],
&new_run_id_string[..8.min(new_run_id_string.len())]
);
eprintln!(
"To resume: fabro resume {}",
&new_run_id[..8.min(new_run_id.len())]
&new_run_id_string[..8.min(new_run_id_string.len())]
);
Ok(())

View file

@ -3,12 +3,13 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use fabro_config::FabroSettingsExt;
use fabro_types::RunId;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LauncherRecord {
pub run_id: String,
pub run_id: RunId,
pub run_dir: PathBuf,
pub pid: u32,
pub resume: bool,
@ -20,11 +21,11 @@ pub(crate) fn launcher_dir(storage_dir: &Path) -> PathBuf {
storage_dir.join("launchers")
}
pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &str) -> PathBuf {
pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &RunId) -> PathBuf {
launcher_dir(storage_dir).join(format!("{run_id}.json"))
}
pub(crate) fn launcher_log_path(storage_dir: &Path, run_id: &str) -> PathBuf {
pub(crate) fn launcher_log_path(storage_dir: &Path, run_id: &RunId) -> PathBuf {
launcher_dir(storage_dir).join(format!("{run_id}.log"))
}
@ -93,7 +94,8 @@ fn launcher_process_matches(record: &LauncherRecord) -> bool {
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 new_match = command.contains(&format!("fabro: {}", super::short_run_id(&record.run_id)));
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
}
@ -108,6 +110,7 @@ mod tests {
use chrono::Utc;
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_types::fixtures;
use fabro_workflows::records::RunRecord;
#[test]
@ -118,7 +121,7 @@ mod tests {
std::fs::create_dir_all(&run_dir).unwrap();
RunRecord {
run_id: "run-test".to_string(),
run_id: fixtures::RUN_1,
created_at: Utc::now(),
settings: FabroSettings {
storage_dir: Some(storage_dir.clone()),
@ -134,11 +137,11 @@ mod tests {
.save(&run_dir)
.unwrap();
let launcher_path = launcher_record_path(&storage_dir, "run-test");
let launcher_path = launcher_record_path(&storage_dir, &fixtures::RUN_1);
write_launcher_record(
&launcher_path,
&LauncherRecord {
run_id: "run-test".to_string(),
run_id: fixtures::RUN_1,
run_dir: run_dir.clone(),
pid: u32::MAX,
resume: false,
@ -157,7 +160,7 @@ mod tests {
fn command_matches_launcher_accepts_old_detached_format() {
let dir = tempfile::tempdir().unwrap();
let record = LauncherRecord {
run_id: "01JGABCDEF12ZYXW".to_string(),
run_id: fixtures::RUN_2,
run_dir: dir.path().join("run"),
pid: 42,
resume: false,
@ -178,7 +181,7 @@ mod tests {
fn command_matches_launcher_accepts_new_title_format() {
let dir = tempfile::tempdir().unwrap();
let record = LauncherRecord {
run_id: "01JGABCDEF12ZYXW".to_string(),
run_id: fixtures::RUN_3,
run_dir: dir.path().join("run"),
pid: 42,
resume: false,
@ -188,7 +191,10 @@ mod tests {
assert!(command_matches_launcher(
&record,
"fabro: 01JGABCDEF12 plan"
&format!(
"fabro: {} plan",
crate::commands::run::short_run_id(&record.run_id.to_string())
)
));
}
}

View file

@ -69,7 +69,8 @@ pub(crate) fn print_diagnostics_from_error(
print_diagnostics(diagnostics, styles);
}
pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
pub(crate) fn print_run_summary(run_dir: &Path, run_id: impl std::fmt::Display, styles: &Styles) {
let run_id = run_id.to_string();
let conclusion_path = run_dir.join("conclusion.json");
let Ok(conclusion) = Conclusion::load(&conclusion_path) else {
return;
@ -85,7 +86,7 @@ pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
print_run_conclusion(
&conclusion,
run_id,
&run_id,
run_dir,
None,
pr_url.as_deref(),
@ -97,12 +98,13 @@ pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
pub(crate) fn print_run_conclusion(
conclusion: &Conclusion,
run_id: &str,
run_id: impl std::fmt::Display,
run_dir: &Path,
pushed_branch: Option<&str>,
pr_url: Option<&str>,
styles: &Styles,
) {
let run_id = run_id.to_string();
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));

View file

@ -37,15 +37,17 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs
rewind(
&store,
&RewindInput {
run_id: run_id.clone(),
run_id,
target,
push: !args.no_push,
},
)?;
let run_id_string = run_id.to_string();
eprintln!(
"\nTo resume: fabro resume {}",
&run_id[..8.min(run_id.len())]
&run_id_string[..8.min(run_id_string.len())]
);
Ok(())

View file

@ -2,6 +2,7 @@ use std::io::Write;
use anyhow::{Result, bail};
use fabro_config::FabroSettingsExt;
use fabro_types::RunId;
use fabro_util::terminal::Styles;
use fabro_workflows::records::{Conclusion, ConclusionExt};
use fabro_workflows::run_lookup::{resolve_run_combined, runs_base};
@ -92,7 +93,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs)
fn build_json_output(
status: RunStatus,
run_id: &str,
run_id: &RunId,
conclusion: Option<&Conclusion>,
) -> serde_json::Value {
let mut value = serde_json::json!({
@ -110,7 +111,7 @@ fn build_json_output(
fn print_human_output(
status: RunStatus,
run_id: &str,
run_id: &RunId,
conclusion: Option<&Conclusion>,
styles: &Styles,
) {
@ -145,6 +146,7 @@ fn print_human_output(
#[cfg(test)]
mod tests {
use super::*;
use fabro_types::fixtures;
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::records::Conclusion;
@ -154,6 +156,7 @@ mod tests {
#[test]
fn json_output_succeeded_with_conclusion() {
let run_id = fixtures::RUN_1;
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Success,
@ -170,8 +173,8 @@ mod tests {
total_reasoning_tokens: 0,
has_pricing: false,
};
let json = build_json_output(RunStatus::Succeeded, "ABC123", Some(&conclusion));
assert_eq!(json["run_id"], "ABC123");
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "succeeded");
assert_eq!(json["duration_ms"], 12345);
assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON);
@ -179,8 +182,9 @@ mod tests {
#[test]
fn json_output_failed_without_conclusion() {
let json = build_json_output(RunStatus::Failed, "DEF456", None);
assert_eq!(json["run_id"], "DEF456");
let run_id = fixtures::RUN_2;
let json = build_json_output(RunStatus::Failed, &run_id, None);
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "failed");
assert!(json.get("duration_ms").is_none());
assert!(json.get("total_cost").is_none());
@ -188,12 +192,13 @@ mod tests {
#[test]
fn json_output_dead_status() {
let json = build_json_output(RunStatus::Dead, "GHI789", None);
let json = build_json_output(RunStatus::Dead, &fixtures::RUN_3, None);
assert_eq!(json["status"], "dead");
}
#[test]
fn json_output_no_cost_when_none() {
let run_id = fixtures::RUN_4;
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Fail,
@ -210,7 +215,7 @@ mod tests {
total_reasoning_tokens: 0,
has_pricing: false,
};
let json = build_json_output(RunStatus::Failed, "JKL012", Some(&conclusion));
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
assert!(json.get("total_cost").is_none());
assert_eq!(json["duration_ms"], 500);
}
@ -218,6 +223,7 @@ mod tests {
#[test]
fn human_output_succeeded() {
let styles = no_color_styles();
let run_id = fixtures::RUN_5;
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageStatus::Success,
@ -235,13 +241,13 @@ mod tests {
has_pricing: false,
};
// Just verify no panic; actual stderr output is hard to capture
print_human_output(RunStatus::Succeeded, "ABC123", Some(&conclusion), &styles);
print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles);
}
#[test]
fn human_output_failed_no_conclusion() {
let styles = no_color_styles();
print_human_output(RunStatus::Failed, "DEF456", None, &styles);
print_human_output(RunStatus::Failed, &fixtures::RUN_6, None, &styles);
}
#[test]

View file

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use fabro_sandbox::SandboxRecordExt;
use fabro_types::RunId;
use fabro_workflows::records::{CheckpointExt, ConclusionExt, RunRecordExt, StartRecordExt};
use serde::Serialize;
@ -43,7 +44,7 @@ pub(crate) async fn run(args: &InspectArgs, globals: &GlobalArgs) -> Result<()>
}
async fn inspect_run_store(
run_id: &str,
run_id: &RunId,
run_dir: &Path,
status: RunStatus,
run_store: &dyn fabro_store::RunStore,
@ -74,7 +75,7 @@ async fn inspect_run_store(
}
}
fn inspect_run_dir(run_id: &str, run_dir: &Path, status: RunStatus) -> InspectOutput {
fn inspect_run_dir(run_id: &RunId, run_dir: &Path, status: RunStatus) -> InspectOutput {
let run_record = RunRecord::load(run_dir)
.ok()
.and_then(|v| serde_json::to_value(v).ok());

View file

@ -95,9 +95,10 @@ pub(crate) async fn list_command(
.host_repo_path
.as_deref()
.map_or_else(|| "-".to_string(), |p| tilde_path(Path::new(p)));
let run_id = run.run_id.to_string();
vec![
short_run_id(&run.run_id)
short_run_id(&run_id)
.cell()
.foreground_color(color_if(use_color, Color::Ansi256(8))),
run.workflow_name.clone().cell(),

View file

@ -37,9 +37,10 @@ async fn remove_from(args: &RunsRemoveArgs, store: &dyn Store, base: &Path) -> R
};
if run.status.is_active() && !args.force {
let run_id = run.run_id.to_string();
eprintln!(
"cannot remove active run {} (status: {}, use -f to force)",
short_run_id(&run.run_id),
short_run_id(&run_id),
run.status
);
had_errors = true;
@ -82,7 +83,8 @@ async fn remove_from(args: &RunsRemoveArgs, store: &dyn Store, base: &Path) -> R
.delete_run(&run.run_id)
.await
.with_context(|| format!("failed to delete store state for {}", run.run_id))?;
eprintln!("{}", short_run_id(&run.run_id));
let run_id = run.run_id.to_string();
eprintln!("{}", short_run_id(&run_id));
}
if had_errors {

View file

@ -350,8 +350,8 @@ mod tests {
use fabro_store::{EventEnvelope, EventPayload, InMemoryStore, Store as _};
use fabro_types::{
AggregateStats, AttrValue, Checkpoint, Conclusion, FabroSettings, Graph, NodeStatusRecord,
Retro, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus, StartRecord,
StatusReason,
Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus,
StartRecord, StatusReason, fixtures,
};
fn dt(rfc3339: &str) -> DateTime<Utc> {
@ -360,14 +360,18 @@ mod tests {
.with_timezone(&Utc)
}
fn sample_run_record(run_id: &str, created_at: DateTime<Utc>) -> RunRecord {
fn test_run_id() -> RunId {
fixtures::RUN_1
}
fn sample_run_record(run_id: RunId, created_at: DateTime<Utc>) -> RunRecord {
let mut graph = Graph::new("night-sky");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("map the constellations".to_string()),
);
RunRecord {
run_id: run_id.to_string(),
run_id,
created_at,
settings: FabroSettings::default(),
graph,
@ -379,11 +383,11 @@ mod tests {
}
}
fn sample_start_record(run_id: &str, created_at: DateTime<Utc>) -> StartRecord {
fn sample_start_record(run_id: RunId, created_at: DateTime<Utc>) -> StartRecord {
StartRecord {
run_id: run_id.to_string(),
run_id,
start_time: created_at + chrono::Duration::seconds(5),
run_branch: Some("fabro/run/demo".to_string()),
run_branch: Some(format!("fabro/run/{run_id}")),
base_sha: Some("abc123".to_string()),
}
}
@ -434,9 +438,9 @@ mod tests {
}
}
fn sample_retro(run_id: &str) -> Retro {
fn sample_retro(run_id: RunId) -> Retro {
Retro {
run_id: run_id.to_string(),
run_id,
workflow_name: "night-sky".to_string(),
goal: "map the constellations".to_string(),
timestamp: dt("2026-03-27T12:20:00Z"),
@ -478,14 +482,14 @@ mod tests {
}
}
fn event_payload(run_id: &str, ts: &str, event: &str) -> EventPayload {
fn event_payload(run_id: RunId, ts: &str, event: &str) -> EventPayload {
EventPayload::new(
serde_json::json!({
"ts": ts,
"run_id": run_id,
"run_id": run_id.to_string(),
"event": event
}),
run_id,
&run_id,
)
.unwrap()
}
@ -498,12 +502,13 @@ mod tests {
async fn export_run_writes_expected_directory_tree() {
let store = InMemoryStore::default();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run_id = test_run_id();
let run = store.create_run(&run_id, created_at, None).await.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
run.put_run(&sample_run_record(run_id, created_at))
.await
.unwrap();
run.put_start(&sample_start_record("run-1", created_at))
run.put_start(&sample_start_record(run_id, created_at))
.await
.unwrap();
run.put_status(&sample_status()).await.unwrap();
@ -514,7 +519,7 @@ mod tests {
.await
.unwrap();
run.put_conclusion(&sample_conclusion()).await.unwrap();
run.put_retro(&sample_retro("run-1")).await.unwrap();
run.put_retro(&sample_retro(run_id)).await.unwrap();
run.put_graph("digraph night_sky {}").await.unwrap();
run.put_sandbox(&sample_sandbox()).await.unwrap();
@ -532,14 +537,14 @@ mod tests {
run.put_retro_prompt("How did it go?").await.unwrap();
run.put_retro_response("Smooth enough").await.unwrap();
run.append_event(&event_payload(
"run-1",
run_id,
"2026-03-27T12:00:00.000Z",
"WorkflowRunStarted",
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
run_id,
"2026-03-27T12:00:01.000Z",
"StageCompleted",
))
@ -568,10 +573,10 @@ mod tests {
assert_eq!(file_count, 22);
let exported_run: RunRecord = read_json(&output.path().join("run.json"));
assert_eq!(exported_run.run_id, "run-1");
assert_eq!(exported_run.run_id, run_id);
let exported_start: StartRecord = read_json(&output.path().join("start.json"));
assert_eq!(exported_start.run_id, "run-1");
assert_eq!(exported_start.run_id, run_id);
let exported_status: RunStatusRecord = read_json(&output.path().join("status.json"));
assert_eq!(exported_status.status, RunStatus::Running);
@ -658,9 +663,10 @@ mod tests {
async fn export_run_rejects_path_traversal_and_leaves_no_partial_output() {
let store = InMemoryStore::default();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run_id = test_run_id();
let run = store.create_run(&run_id, created_at, None).await.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
run.put_run(&sample_run_record(run_id, created_at))
.await
.unwrap();
run.put_asset(

View file

@ -61,7 +61,7 @@ async fn df_from(
}
if args.verbose {
run_details.push(RunSizeInfo {
run_id: run.run_id.clone(),
run_id: run.run_id.to_string(),
workflow_name: run.workflow_name.clone(),
status: run.status,
start_time_dt: run.start_time_dt,

View file

@ -4,6 +4,7 @@ use std::time::Duration;
use anyhow::Result;
use fabro_store::{RunStore, SlateStore, Store};
use fabro_types::RunId;
use object_store::local::LocalFileSystem;
pub(crate) fn build_store(storage_dir: &Path) -> Result<Arc<SlateStore>> {
@ -19,7 +20,7 @@ pub(crate) fn build_store(storage_dir: &Path) -> Result<Arc<SlateStore>> {
pub(crate) async fn open_run_reader(
storage_dir: &Path,
run_id: &str,
run_id: &RunId,
) -> Result<Option<Arc<dyn RunStore>>> {
build_store(storage_dir)?
.open_run_reader(run_id)

View file

@ -12,7 +12,7 @@ use fabro_config::user::ExecutionMode;
use fabro_git_storage::branchstore::BranchStore;
use fabro_git_storage::gitobj::Store as GitStore;
use fabro_store::{NodeVisitRef, RuntimeState, SlateStore, Store as _};
use fabro_types::{Checkpoint, Graph, RunRecord, StartRecord};
use fabro_types::{Checkpoint, Graph, RunId, RunRecord, StartRecord, fixtures};
use git2::{Repository, Signature};
use object_store::local::LocalFileSystem;
use predicates::prelude::*;
@ -302,7 +302,7 @@ fn checkpoint_record(
}
}
async fn seed_durable_run(storage_dir: &Path, repo_dir: &Path, run_id: &str) {
async fn seed_durable_run(storage_dir: &Path, repo_dir: &Path, run_id: RunId) {
let store_path = storage_dir.join("store");
std::fs::create_dir_all(&store_path).unwrap();
let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_path).unwrap());
@ -311,10 +311,10 @@ async fn seed_durable_run(storage_dir: &Path, repo_dir: &Path, run_id: &str) {
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
.single()
.unwrap();
let run_store = store.create_run(run_id, created_at, None).await.unwrap();
let run_store = store.create_run(&run_id, created_at, None).await.unwrap();
let run_record = RunRecord {
run_id: run_id.to_string(),
run_id,
created_at,
settings: FabroSettings::default(),
graph: Graph::default(),
@ -327,7 +327,7 @@ async fn seed_durable_run(storage_dir: &Path, repo_dir: &Path, run_id: &str) {
run_store.put_run(&run_record).await.unwrap();
run_store
.put_start(&StartRecord {
run_id: run_id.to_string(),
run_id,
start_time: created_at,
run_branch: Some(format!("fabro/run/{run_id}")),
base_sha: None,
@ -835,7 +835,7 @@ fn dry_run_writes_jsonl_and_live_json() {
fn run_id_passthrough_uses_provided_ulid() {
let tmp = tempfile::tempdir().unwrap();
let storage_dir = tmp.path().join("fabro-data");
let my_ulid = "01JTEST1234567890ABCDE";
let my_ulid = fixtures::RUN_10.to_string();
arc()
.args([
@ -843,14 +843,14 @@ fn run_id_passthrough_uses_provided_ulid() {
"--dry-run",
"--auto-approve",
"--run-id",
my_ulid,
my_ulid.as_str(),
"--storage-dir",
storage_dir.to_str().unwrap(),
"../../../test/simple.fabro",
])
.assert()
.success()
.stderr(predicate::str::contains(my_ulid));
.stderr(predicate::str::contains(&my_ulid));
}
// == --detach flag =============================================================
@ -971,8 +971,10 @@ fn rewind_and_fork_recover_missing_metadata_from_store() {
let repo_dir = tempfile::tempdir().unwrap();
Repository::init(repo_dir.path()).unwrap();
let source_run_id = "run-recovery-source";
let expected_shas = seed_run_branch(repo_dir.path(), source_run_id, &["start", "build"]);
let source_run_id = fixtures::RUN_1;
let source_run_id_string = source_run_id.to_string();
let expected_shas =
seed_run_branch(repo_dir.path(), &source_run_id_string, &["start", "build"]);
Runtime::new().unwrap().block_on(seed_durable_run(
&storage_dir,
repo_dir.path(),
@ -988,7 +990,7 @@ fn rewind_and_fork_recover_missing_metadata_from_store() {
.env("HOME", configured_home.path())
.env("NO_COLOR", "1")
.current_dir(repo_dir.path())
.args(["rewind", source_run_id, "--list"])
.args(["rewind", &source_run_id_string, "--list"])
.timeout(Duration::from_secs(15))
.assert()
.success()
@ -1009,7 +1011,7 @@ fn rewind_and_fork_recover_missing_metadata_from_store() {
"rebuilt timeline should persist backfilled SHAs: {rewind_list}"
);
let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), source_run_id);
let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), &source_run_id_string);
assert_eq!(rebuilt_checkpoints.len(), 2);
assert_eq!(
rebuilt_checkpoints[0].git_commit_sha.as_deref(),
@ -1025,7 +1027,7 @@ fn rewind_and_fork_recover_missing_metadata_from_store() {
.env("HOME", configured_home.path())
.env("NO_COLOR", "1")
.current_dir(repo_dir.path())
.args(["fork", source_run_id, "--no-push"])
.args(["fork", &source_run_id_string, "--no-push"])
.timeout(Duration::from_secs(15))
.assert()
.success();
@ -1173,6 +1175,7 @@ fn completed_run_preserves_workflow_slug_for_lookup() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let workflow_dir = project.path().join("workflows").join("sluggy");
let run_id = fixtures::RUN_11.to_string();
std::fs::create_dir_all(&workflow_dir).unwrap();
let workflow_path = workflow_dir.join("workflow.fabro");
std::fs::write(
@ -1195,7 +1198,7 @@ digraph BarBaz {
"--dry-run",
"--auto-approve",
"--run-id",
"opaque-run-999",
run_id.as_str(),
workflow_path.to_str().unwrap(),
])
.assert()
@ -1211,7 +1214,7 @@ digraph BarBaz {
arc()
.env("HOME", home.path())
.current_dir(project.path())
.args(["attach", "opaque-run-999"])
.args(["attach", run_id.as_str()])
.timeout(std::time::Duration::from_secs(10))
.assert()
.success();
@ -1224,7 +1227,7 @@ digraph BarBaz {
.assert()
.success();
let run_dir = find_run_dir(home.path(), "opaque-run-999");
let run_dir = find_run_dir(home.path(), &run_id);
let run_record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap();
assert_eq!(run_record["graph"]["name"].as_str(), Some("BarBaz"));
@ -1236,6 +1239,7 @@ fn standalone_file_run_uses_file_stem_slug_for_lookup() {
let home = tempfile::tempdir().unwrap();
let workflow_dir = tempfile::tempdir().unwrap();
let workflow_path = workflow_dir.path().join("alpha.fabro");
let run_id = fixtures::RUN_12.to_string();
std::fs::write(
&workflow_path,
"\
@ -1255,7 +1259,7 @@ digraph FooWorkflow {
"--dry-run",
"--auto-approve",
"--run-id",
"opaque-run-alpha",
run_id.as_str(),
workflow_path.to_str().unwrap(),
])
.assert()
@ -1274,7 +1278,7 @@ digraph FooWorkflow {
.assert()
.success();
let run_dir = find_run_dir(home.path(), "opaque-run-alpha");
let run_dir = find_run_dir(home.path(), &run_id);
let run_record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(run_dir.join("run.json")).unwrap()).unwrap();
assert_eq!(run_record["graph"]["name"].as_str(), Some("FooWorkflow"));
@ -1284,7 +1288,7 @@ digraph FooWorkflow {
#[test]
fn dry_run_create_start_attach_works_with_default_run_lookup() {
let home = tempfile::tempdir().unwrap();
let run_id = "drysplit-test-123";
let run_id = fixtures::RUN_13.to_string();
arc()
.env("HOME", home.path())
@ -1293,14 +1297,14 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
"--dry-run",
"--auto-approve",
"--run-id",
run_id,
run_id.as_str(),
"../../../test/simple.fabro",
])
.assert()
.success()
.stdout(predicate::str::contains(run_id));
.stdout(predicate::str::contains(&run_id));
let run_dir = find_run_dir(home.path(), run_id);
let run_dir = find_run_dir(home.path(), &run_id);
assert!(
run_dir.join("run.json").exists(),
"create should persist run.json so the run is discoverable"
@ -1308,13 +1312,13 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
arc()
.env("HOME", home.path())
.args(["start", run_id])
.args(["start", run_id.as_str()])
.assert()
.success();
arc()
.env("HOME", home.path())
.args(["attach", run_id])
.args(["attach", run_id.as_str()])
.timeout(std::time::Duration::from_secs(10))
.assert()
.success();
@ -1325,7 +1329,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
#[test]
fn dry_run_detach_attach_works_with_default_run_lookup() {
let home = tempfile::tempdir().unwrap();
let run_id = "drydetach-test-123";
let run_id = fixtures::RUN_14.to_string();
arc()
.env("HOME", home.path())
@ -1335,16 +1339,16 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
"--dry-run",
"--auto-approve",
"--run-id",
run_id,
run_id.as_str(),
"../../../test/simple.fabro",
])
.assert()
.success()
.stdout(predicate::str::contains(run_id));
.stdout(predicate::str::contains(&run_id));
arc()
.env("HOME", home.path())
.args(["attach", run_id])
.args(["attach", run_id.as_str()])
.timeout(std::time::Duration::from_secs(10))
.assert()
.success();
@ -1354,11 +1358,13 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
fn start_by_workflow_name_prefers_newly_created_submitted_run() {
let home = tempfile::tempdir().unwrap();
let old_run_dir = home.path().join(".fabro").join("runs").join("old-smoke");
let old_run_id = fixtures::RUN_15.to_string();
let run_id = fixtures::RUN_16.to_string();
std::fs::create_dir_all(&old_run_dir).unwrap();
std::fs::write(
old_run_dir.join("run.json"),
serde_json::json!({
"run_id": "old-smoke",
"run_id": old_run_id,
"created_at": "2026-01-01T00:00:00Z",
"settings": {},
"graph": {
@ -1379,7 +1385,6 @@ fn start_by_workflow_name_prefers_newly_created_submitted_run() {
)
.unwrap();
let run_id = "new-smoke-run-123";
arc()
.env("HOME", home.path())
.args([
@ -1387,12 +1392,12 @@ fn start_by_workflow_name_prefers_newly_created_submitted_run() {
"--dry-run",
"--auto-approve",
"--run-id",
run_id,
run_id.as_str(),
"smoke",
])
.assert()
.success()
.stdout(predicate::str::contains(run_id));
.stdout(predicate::str::contains(&run_id));
arc()
.env("HOME", home.path())
@ -1402,12 +1407,12 @@ fn start_by_workflow_name_prefers_newly_created_submitted_run() {
arc()
.env("HOME", home.path())
.args(["attach", run_id])
.args(["attach", run_id.as_str()])
.timeout(std::time::Duration::from_secs(10))
.assert()
.success();
let new_run_dir = find_run_dir(home.path(), run_id);
let new_run_dir = find_run_dir(home.path(), &run_id);
let status = std::fs::read_to_string(new_run_dir.join("status.json")).unwrap();
assert!(
status.contains("\"status\": \"succeeded\""),
@ -1423,6 +1428,7 @@ fn bug2_detached_uses_cached_graph_not_original_path() {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
let run_dir = storage_dir.join("runs").join("20260101-test-bug2");
let run_id = fixtures::RUN_17.to_string();
std::fs::create_dir_all(&run_dir).unwrap();
let dot = "\
@ -1434,7 +1440,7 @@ digraph G {
// run.json: working_directory is valid but original workflow path no longer exists
let run_record = serde_json::json!({
"run_id": "test-bug2",
"run_id": run_id,
"created_at": "2026-01-01T00:00:00Z",
"settings": {
"dry_run": true,
@ -1592,6 +1598,7 @@ fn bug5_detached_uses_snapshotted_app_id_for_github_credentials() {
let storage_dir = tempfile::tempdir().unwrap();
let home = init_cli_home(storage_dir.path());
let run_dir = storage_dir.path().join("runs").join("20260101-test-bug5");
let run_id = fixtures::RUN_18.to_string();
std::fs::create_dir_all(&run_dir).unwrap();
let dot = "\
@ -1602,7 +1609,7 @@ digraph G {
}";
let run_record = serde_json::json!({
"run_id": "test-bug5",
"run_id": run_id,
"created_at": "2026-01-01T00:00:00Z",
"settings": {
"dry_run": true,
@ -1661,14 +1668,16 @@ digraph G {
#[test]
fn bug3_attach_leaves_interview_request_until_engine_consumes_response() {
let home = tempfile::tempdir().unwrap();
let run_id = fixtures::RUN_7.to_string();
let stage_started = format!(
r#"{{"ts":"2026-01-01T00:00:01Z","run_id":"{run_id}","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}}"#
);
let run_dir = setup_run_dir(
home.path(),
"bug3-test",
&run_id,
serde_json::json!({}),
&[
r#"{"ts":"2026-01-01T00:00:01Z","run_id":"bug3","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#,
],
&[&stage_started],
);
// Terminal status still allows attach to answer the interview once before exiting.
@ -1702,7 +1711,7 @@ fn bug3_attach_leaves_interview_request_until_engine_consumes_response() {
let _ = arc()
.env("HOME", home.path())
.env("NO_COLOR", "1")
.args(["attach", "bug3-test"])
.args(["attach", &run_id])
.write_stdin("y\n")
.timeout(std::time::Duration::from_secs(5))
.output();
@ -1724,14 +1733,16 @@ fn bug3_attach_leaves_interview_request_until_engine_consumes_response() {
#[test]
fn attach_closed_stdin_keeps_interview_pending() {
let home = tempfile::tempdir().unwrap();
let run_id = fixtures::RUN_8.to_string();
let stage_started = format!(
r#"{{"ts":"2026-01-01T00:00:01Z","run_id":"{run_id}","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}}"#
);
let run_dir = setup_run_dir(
home.path(),
"attach-closed-stdin",
&run_id,
serde_json::json!({}),
&[
r#"{"ts":"2026-01-01T00:00:01Z","run_id":"attach-closed-stdin","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#,
],
&[&stage_started],
);
std::fs::write(
@ -1761,7 +1772,7 @@ fn attach_closed_stdin_keeps_interview_pending() {
let assert = arc()
.env("HOME", home.path())
.env("NO_COLOR", "1")
.args(["attach", "attach-closed-stdin"])
.args(["attach", &run_id])
.timeout(std::time::Duration::from_secs(5))
.assert()
.failure();
@ -1790,21 +1801,34 @@ fn attach_closed_stdin_keeps_interview_pending() {
#[test]
fn bug4_attach_respects_verbose_from_spec() {
let home = tempfile::tempdir().unwrap();
let run_id = fixtures::RUN_9.to_string();
// Use pre-rename field names so handle_json_line can parse them
// (isolates this test from bug 1). With 2 turns and 1 tool call,
// verbose mode should display "(2 turns, 1 tools, …)" in the output.
let run_dir = setup_run_dir(
home.path(),
"bug4-test",
&run_id,
serde_json::json!({"verbose": true}),
&[
r#"{"ts":"2026-01-01T12:00:00Z","run_id":"bug4","event":"StageStarted","node_id":"code","name":"Code","index":0,"attempt":1,"max_attempts":1}"#,
r#"{"ts":"2026-01-01T12:00:01Z","run_id":"bug4","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#,
r#"{"ts":"2026-01-01T12:00:02Z","run_id":"bug4","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#,
r#"{"ts":"2026-01-01T12:00:03Z","run_id":"bug4","event":"Agent.ToolCallStarted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{}}"#,
r#"{"ts":"2026-01-01T12:00:04Z","run_id":"bug4","event":"Agent.ToolCallCompleted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","is_error":false}"#,
r#"{"ts":"2026-01-01T12:00:10Z","run_id":"bug4","event":"StageCompleted","node_id":"code","name":"Code","index":0,"duration_ms":10000,"status":"success","usage":{"input_tokens":1000,"output_tokens":500}}"#,
&format!(
r#"{{"ts":"2026-01-01T12:00:00Z","run_id":"{run_id}","event":"StageStarted","node_id":"code","name":"Code","index":0,"attempt":1,"max_attempts":1}}"#
),
&format!(
r#"{{"ts":"2026-01-01T12:00:01Z","run_id":"{run_id}","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}}"#
),
&format!(
r#"{{"ts":"2026-01-01T12:00:02Z","run_id":"{run_id}","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}}"#
),
&format!(
r#"{{"ts":"2026-01-01T12:00:03Z","run_id":"{run_id}","event":"Agent.ToolCallStarted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{{}}}}"#
),
&format!(
r#"{{"ts":"2026-01-01T12:00:04Z","run_id":"{run_id}","event":"Agent.ToolCallCompleted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","is_error":false}}"#
),
&format!(
r#"{{"ts":"2026-01-01T12:00:10Z","run_id":"{run_id}","event":"StageCompleted","node_id":"code","name":"Code","index":0,"duration_ms":10000,"status":"success","usage":{{"input_tokens":1000,"output_tokens":500}}}}"#
),
],
);
@ -1831,7 +1855,7 @@ fn bug4_attach_respects_verbose_from_spec() {
let output = arc()
.env("HOME", home.path())
.env("NO_COLOR", "1")
.args(["attach", "bug4-test"])
.args(["attach", &run_id])
.timeout(std::time::Duration::from_secs(10))
.output()
.expect("process should start");
@ -1994,7 +2018,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
let (home, project, storage_dir) = setup_external_workflow_fixture();
let cwd = tempfile::tempdir().unwrap();
let workflow = project.path().join("workflow.toml");
let run_id = "external-config-run";
let run_id = fixtures::RUN_19.to_string();
arc()
.env("HOME", home.path())
@ -2005,7 +2029,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
"--model",
"gpt-5.2",
"--run-id",
run_id,
run_id.as_str(),
workflow.to_str().unwrap(),
])
.assert()
@ -2020,7 +2044,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
path.is_dir()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with(run_id))
.is_some_and(|name| name.to_string_lossy().ends_with(&run_id))
})
.unwrap_or_else(|| {
panic!(

View file

@ -2,6 +2,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use fabro_agent::{Sandbox, ToolHookCallback, ToolHookDecision};
use fabro_types::RunId;
use crate::runner::HookRunner;
use crate::types::{HookContext, HookDecision, HookEvent};
@ -13,7 +14,7 @@ use crate::types::{HookContext, HookDecision, HookEvent};
pub struct WorkflowToolHookCallback {
pub hook_runner: Arc<HookRunner>,
pub sandbox: Arc<dyn Sandbox>,
pub run_id: String,
pub run_id: RunId,
pub workflow_name: String,
pub work_dir: Option<PathBuf>,
pub node_id: String,
@ -75,6 +76,7 @@ mod tests {
use crate::config::{HookConfig, HookDefinition};
use crate::executor::HookExecutor;
use crate::types::{HookContext, HookResult};
use fabro_types::fixtures;
use std::path::Path;
use std::sync::Mutex;
@ -127,7 +129,7 @@ mod tests {
WorkflowToolHookCallback {
hook_runner,
sandbox,
run_id: "run-1".into(),
run_id: fixtures::RUN_1,
workflow_name: "test-wf".into(),
work_dir: None,
node_id: "plan".into(),
@ -160,7 +162,7 @@ mod tests {
contexts[0].tool_input,
Some(serde_json::json!({"command": "ls"}))
);
assert_eq!(contexts[0].run_id, "run-1");
assert_eq!(contexts[0].run_id, fixtures::RUN_1);
assert_eq!(contexts[0].node_id.as_deref(), Some("plan"));
}

View file

@ -125,7 +125,7 @@ impl HookExecutorImpl {
let mut env_vars = HashMap::new();
env_vars.insert("FABRO_EVENT".to_string(), context.event.to_string());
env_vars.insert("FABRO_RUN_ID".to_string(), context.run_id.clone());
env_vars.insert("FABRO_RUN_ID".to_string(), context.run_id.to_string());
env_vars.insert("FABRO_WORKFLOW".to_string(), context.workflow_name.clone());
if let Some(ref node_id) = context.node_id {
env_vars.insert("FABRO_NODE_ID".to_string(), node_id.clone());
@ -603,9 +603,10 @@ mod tests {
use super::*;
use crate::config::HookType;
use crate::types::HookEvent;
use fabro_types::fixtures;
fn make_context() -> HookContext {
HookContext::new(HookEvent::StageStart, "run-1".into(), "test-wf".into())
HookContext::new(HookEvent::StageStart, fixtures::RUN_1, "test-wf".into())
}
fn make_sandbox() -> Arc<dyn Sandbox> {

View file

@ -212,6 +212,7 @@ mod tests {
use super::*;
use crate::config::HookConfig;
use crate::types::{HookContext, HookEvent, HookResult};
use fabro_types::fixtures;
struct MockExecutor {
decision: HookDecision,
@ -241,7 +242,7 @@ mod tests {
}
fn make_context(event: HookEvent) -> HookContext {
HookContext::new(event, "run-1".into(), "test-wf".into())
HookContext::new(event, fixtures::RUN_1, "test-wf".into())
}
fn make_hook(event: HookEvent, name: &str) -> HookDefinition {

View file

@ -1,12 +1,13 @@
pub use fabro_config::hook::HookEvent;
use fabro_types::RunId;
use serde::{Deserialize, Serialize};
/// Rich JSON payload sent to hooks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookContext {
pub event: HookEvent,
pub run_id: String,
pub run_id: RunId,
pub workflow_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
@ -44,7 +45,7 @@ pub struct HookContext {
impl HookContext {
#[must_use]
pub fn new(event: HookEvent, run_id: String, workflow_name: String) -> Self {
pub fn new(event: HookEvent, run_id: RunId, workflow_name: String) -> Self {
Self {
event,
run_id,
@ -126,12 +127,13 @@ pub struct HookResult {
#[cfg(test)]
mod tests {
use super::*;
use fabro_types::fixtures;
#[test]
fn hook_context_serde_round_trip() {
let ctx = HookContext {
event: HookEvent::StageStart,
run_id: "run-123".into(),
run_id: fixtures::RUN_1,
workflow_name: "test-wf".into(),
cwd: Some("/tmp".into()),
node_id: Some("plan".into()),
@ -153,13 +155,13 @@ mod tests {
let json = serde_json::to_string(&ctx).unwrap();
let back: HookContext = serde_json::from_str(&json).unwrap();
assert_eq!(back.event, HookEvent::StageStart);
assert_eq!(back.run_id, "run-123");
assert_eq!(back.run_id, fixtures::RUN_1);
assert_eq!(back.node_id.as_deref(), Some("plan"));
}
#[test]
fn hook_context_omits_none_fields() {
let ctx = HookContext::new(HookEvent::RunStart, "run-1".into(), "wf".into());
let ctx = HookContext::new(HookEvent::RunStart, fixtures::RUN_1, "wf".into());
let json = serde_json::to_string(&ctx).unwrap();
assert!(!json.contains("node_id"));
assert!(!json.contains("failure_reason"));
@ -258,7 +260,7 @@ mod tests {
#[test]
fn hook_context_with_tool_fields() {
let mut ctx = HookContext::new(HookEvent::PreToolUse, "run-1".into(), "wf".into());
let mut ctx = HookContext::new(HookEvent::PreToolUse, fixtures::RUN_1, "wf".into());
ctx.tool_name = Some("shell".into());
ctx.tool_input = Some(serde_json::json!({"command": "ls"}));
ctx.tool_call_id = Some("call_123".into());
@ -270,7 +272,7 @@ mod tests {
#[test]
fn hook_context_tool_output_serializes() {
let mut ctx = HookContext::new(HookEvent::PostToolUse, "run-1".into(), "wf".into());
let mut ctx = HookContext::new(HookEvent::PostToolUse, fixtures::RUN_1, "wf".into());
ctx.tool_name = Some("shell".into());
ctx.tool_output = Some("file1.txt\nfile2.txt".into());
let json = serde_json::to_string(&ctx).unwrap();

View file

@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::Path;
use fabro_types::RunId;
pub use fabro_types::retro::{
AggregateStats, FrictionKind, FrictionPoint, Learning, LearningCategory, OpenItem,
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
@ -68,7 +69,7 @@ pub fn extract_stage_durations(run_dir: &Path) -> HashMap<String, u64> {
}
pub fn derive_retro(
run_id: &str,
run_id: RunId,
workflow_name: &str,
goal: &str,
completed_stages: Vec<CompletedStage>,
@ -133,7 +134,7 @@ pub fn derive_retro(
};
Retro {
run_id: run_id.to_string(),
run_id,
workflow_name: workflow_name.to_string(),
goal: goal.to_string(),
timestamp: chrono::Utc::now(),

View file

@ -12,6 +12,7 @@ use crate::{
use async_trait::async_trait;
use daytona_sdk::api_types::SignedPortPreviewUrl;
use fabro_github::GitHubAppCredentials;
use fabro_types::RunId;
use rand::Rng;
use tokio::fs;
use tokio::sync::OnceCell;
@ -36,7 +37,7 @@ pub struct DaytonaSandbox {
event_callback: Option<SandboxEventCallback>,
/// HTTPS origin URL stored after clone so we can refresh push credentials later.
origin_url: OnceCell<String>,
run_id: Option<String>,
run_id: Option<RunId>,
/// Explicit branch to clone. When set, overrides the branch detected by
/// `detect_repo_info` — avoids cloning a local-only worktree branch
/// (e.g. `fabro/run/...`) that was never pushed to origin.
@ -48,7 +49,7 @@ impl DaytonaSandbox {
pub async fn new(
config: DaytonaConfig,
github_app: Option<GitHubAppCredentials>,
run_id: Option<String>,
run_id: Option<RunId>,
clone_branch: Option<String>,
) -> Result<Self, String> {
let client = daytona_sdk::Client::new()

View file

@ -11,6 +11,7 @@ use crate::{
format_lines_numbered,
};
use async_trait::async_trait;
use fabro_types::RunId;
use tokio_util::sync::CancellationToken;
pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner};
@ -73,7 +74,7 @@ pub struct ExeSandbox {
data_ssh_factory: DataSshFactory,
config: ExeConfig,
clone_params: Option<GitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
origin_url: tokio::sync::OnceCell<String>,
github_app: Option<fabro_github::GitHubAppCredentials>,
}
@ -84,7 +85,7 @@ impl ExeSandbox {
mgmt_ssh: Box<dyn SshRunner>,
config: ExeConfig,
clone_params: Option<GitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Self {
Self {

View file

@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use fabro_types::settings::WorktreeMode;
use fabro_types::{RunId, settings::WorktreeMode};
#[cfg(any(feature = "docker", feature = "daytona", feature = "exe"))]
use anyhow::anyhow;
@ -35,14 +35,14 @@ pub enum SandboxSpec {
Daytona {
config: DaytonaConfig,
github_app: Option<GitHubAppCredentials>,
run_id: Option<String>,
run_id: Option<RunId>,
clone_branch: Option<String>,
},
#[cfg(feature = "exe")]
Exe {
config: ExeConfig,
clone_params: Option<ExeGitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
github_app: Option<GitHubAppCredentials>,
mgmt_destination: String,
},
@ -50,7 +50,7 @@ pub enum SandboxSpec {
Ssh {
config: SshConfig,
clone_params: Option<SshGitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
github_app: Option<GitHubAppCredentials>,
},
}

View file

@ -13,6 +13,7 @@ use crate::{
format_lines_numbered,
};
use async_trait::async_trait;
use fabro_types::RunId;
use tokio::fs;
use tokio::sync::OnceCell;
use tokio_util::sync::CancellationToken;
@ -32,7 +33,7 @@ pub struct SshSandbox {
ssh: OnceCell<Box<dyn SshRunner>>,
config: SshConfig,
clone_params: Option<GitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
github_app: Option<fabro_github::GitHubAppCredentials>,
rg_available: OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
@ -44,7 +45,7 @@ impl SshSandbox {
pub fn new(
config: SshConfig,
clone_params: Option<GitCloneParams>,
run_id: Option<String>,
run_id: Option<RunId>,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Self {
Self {

View file

@ -18,6 +18,7 @@ use fabro_llm::types::{
};
use fabro_retro::retro::Retro;
use fabro_store::{InMemoryStore, Store};
use fabro_types::RunId;
use fabro_util::redact::redact_jsonl_line;
use fabro_workflows::error::FabroError;
use fabro_workflows::handler::HandlerRegistry;
@ -30,6 +31,7 @@ use tokio::time::{sleep, timeout};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use tower::{ServiceExt, service_fn};
use ulid::Ulid;
use tracing::{error, info};
@ -117,7 +119,7 @@ type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry +
/// Shared application state for the server.
pub struct AppState {
runs: Mutex<HashMap<String, ManagedRun>>,
runs: Mutex<HashMap<RunId, ManagedRun>>,
aggregate_usage: Mutex<AggregateUsageTotals>,
store: Arc<dyn Store>,
pub db: sqlx::SqlitePool,
@ -452,7 +454,7 @@ async fn list_runs(
let all_items: Vec<RunStatusResponse> = runs
.iter()
.map(|(id, managed_run)| RunStatusResponse {
id: id.clone(),
id: id.to_string(),
status: managed_run.status,
error: managed_run
.error
@ -477,8 +479,8 @@ async fn list_runs(
.into_response()
}
fn compute_queue_positions(runs: &HashMap<String, ManagedRun>) -> HashMap<String, i64> {
let mut queued: Vec<(&String, &ManagedRun)> = runs
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
.iter()
.filter(|(_, r)| r.status == RunStatus::Queued)
.collect();
@ -486,10 +488,15 @@ fn compute_queue_positions(runs: &HashMap<String, ManagedRun>) -> HashMap<String
queued
.into_iter()
.enumerate()
.map(|(i, (id, _))| (id.clone(), i64::try_from(i + 1).unwrap()))
.map(|(i, (id, _))| (*id, i64::try_from(i + 1).unwrap()))
.collect()
}
fn parse_run_id_path(id: &str) -> Result<RunId, Response> {
id.parse::<RunId>()
.map_err(|_| ApiError::bad_request("Invalid run ID.").into_response())
}
fn clear_live_run_state(run: &mut ManagedRun) {
run.interviewer = None;
run.event_tx = None;
@ -502,7 +509,7 @@ async fn start_run(
State(state): State<Arc<AppState>>,
Json(req): Json<StartRunRequest>,
) -> Response {
let run_id = ulid::Ulid::new().to_string();
let run_id = RunId::new();
info!(run_id = %run_id, "Run queued");
let run_dir = std::env::temp_dir().join(format!("fabro-{}", uuid::Uuid::new_v4()));
let settings = state.settings.read().unwrap().clone();
@ -515,7 +522,7 @@ async fn start_run(
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
workflow_slug: None,
run_dir: Some(run_dir.clone()),
run_id: Some(run_id.clone()),
run_id: Some(run_id),
host_repo_path: None,
base_branch: None,
}) {
@ -549,7 +556,7 @@ async fn start_run(
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.insert(
run_id.clone(),
run_id,
ManagedRun {
dot_source: req.dot_source,
status: RunStatus::Queued,
@ -571,7 +578,7 @@ async fn start_run(
(
StatusCode::CREATED,
Json(RunStatusResponse {
id: run_id,
id: run_id.to_string(),
status: RunStatus::Queued,
error: None,
queue_position: None,
@ -582,7 +589,7 @@ async fn start_run(
}
/// Execute a single run: transitions queued → starting → running → completed/failed/cancelled.
async fn execute_run(state: Arc<AppState>, run_id: String) {
async fn execute_run(state: Arc<AppState>, run_id: RunId) {
// Transition to Starting and set up cancel infrastructure
let (cancel_rx, run_dir, event_tx, cancel_token) = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
@ -828,6 +835,10 @@ async fn get_run_status(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
@ -840,7 +851,7 @@ async fn get_run_status(
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
id: id.to_string(),
status: managed_run.status,
error: managed_run
.error
@ -863,6 +874,10 @@ async fn get_questions(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
@ -910,6 +925,10 @@ async fn submit_answer(
Path((id, qid)): Path<(String, String)>,
Json(req): Json<SubmitAnswerRequest>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
@ -970,6 +989,10 @@ async fn get_events(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let rx = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
@ -1002,6 +1025,10 @@ async fn get_checkpoint(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match &managed_run.checkpoint {
@ -1017,6 +1044,10 @@ async fn get_context(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match &managed_run.context {
@ -1032,6 +1063,10 @@ async fn cancel_run(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
@ -1047,7 +1082,7 @@ async fn cancel_run(
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
id: id.to_string(),
status: RunStatus::Cancelled,
error: None,
queue_position: None,
@ -1067,6 +1102,10 @@ async fn pause_run(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
@ -1076,7 +1115,7 @@ async fn pause_run(
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
id: id.to_string(),
status: RunStatus::Paused,
error: None,
queue_position: None,
@ -1096,6 +1135,10 @@ async fn unpause_run(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
@ -1105,7 +1148,7 @@ async fn unpause_run(
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
id: id.to_string(),
status: RunStatus::Running,
error: None,
queue_position: None,
@ -1308,7 +1351,7 @@ async fn create_completion(
// Dry-run mode returns a stub response
if state.dry_run() {
let msg_id = ulid::Ulid::new().to_string();
let msg_id = Ulid::new().to_string();
if use_stream {
let finish_event = StreamEvent::finish(
FinishReason::Stop,
@ -1408,7 +1451,7 @@ async fn create_completion(
.into_response()
} else {
// Non-streaming path
let msg_id = ulid::Ulid::new().to_string();
let msg_id = Ulid::new().to_string();
if let Some(schema) = req.schema {
// Structured output uses generate_object for JSON parsing logic
@ -1469,6 +1512,10 @@ async fn get_retro(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let run_dir = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
@ -1529,6 +1576,10 @@ async fn get_graph(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
@ -1545,6 +1596,7 @@ mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use fabro_types::fixtures;
use fabro_workflows::records::{RunRecord, RunRecordExt};
use tower::ServiceExt;
@ -1720,7 +1772,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Give run a moment to start
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
@ -1736,7 +1788,7 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["id"].as_str().unwrap(), run_id);
assert_eq!(body["id"].as_str().unwrap(), run_id.to_string());
let status = body["status"].as_str().unwrap();
assert!(
status == "queued"
@ -1750,10 +1802,11 @@ mod tests {
#[tokio::test]
async fn get_run_status_not_found() {
let app = test_app_with(test_db().await);
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent")
.uri(format!("/runs/{missing_run_id}"))
.body(Body::empty())
.unwrap();
@ -1778,7 +1831,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Get questions (should be empty for a run without wait.human nodes)
let req = Request::builder()
@ -1798,10 +1851,11 @@ mod tests {
#[tokio::test]
async fn submit_answer_not_found_run() {
let app = test_app_with(test_db().await);
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("POST")
.uri("/runs/nonexistent/questions/q1/answer")
.uri(format!("/runs/{missing_run_id}/questions/q1/answer"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
@ -1815,10 +1869,11 @@ mod tests {
#[tokio::test]
async fn get_events_not_found() {
let app = test_app_with(test_db().await);
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent/events")
.uri(format!("/runs/{missing_run_id}/events"))
.body(Body::empty())
.unwrap();
@ -1843,7 +1898,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Get checkpoint immediately (before run completes, may be null)
let req = Request::builder()
@ -1873,7 +1928,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Get context
let req = Request::builder()
@ -1906,7 +1961,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Cancel it
let req = Request::builder()
@ -1927,10 +1982,11 @@ mod tests {
#[tokio::test]
async fn cancel_nonexistent_run_returns_not_found() {
let app = test_app_with(test_db().await);
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("POST")
.uri("/runs/nonexistent/cancel")
.uri(format!("/runs/{missing_run_id}/cancel"))
.body(Body::empty())
.unwrap();
@ -1955,7 +2011,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Wait for scheduler to promote run (creates event_tx)
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@ -2006,7 +2062,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Poll until run completes
let mut status = String::new();
@ -2045,7 +2101,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Request graph SVG
let req = Request::builder()
@ -2085,10 +2141,11 @@ mod tests {
#[tokio::test]
async fn get_graph_not_found() {
let app = test_app_with(test_db().await);
let missing_run_id = fixtures::RUN_64;
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent/graph")
.uri(format!("/runs/{missing_run_id}/graph"))
.body(Body::empty())
.unwrap();
@ -2126,7 +2183,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// List should now contain one run
let req = Request::builder()
@ -2140,7 +2197,7 @@ mod tests {
let body = body_json(response.into_body()).await;
let items = body["data"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"].as_str().unwrap(), run_id);
assert_eq!(items[0]["id"].as_str().unwrap(), run_id.to_string());
assert!(items[0]["status"].as_str().is_some());
assert!(!body["meta"]["has_more"].as_bool().unwrap());
}
@ -2185,7 +2242,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Poll until run completes
let mut status = String::new();
@ -2236,7 +2293,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Check status is queued (no scheduler running)
let req = Request::builder()
@ -2309,7 +2366,7 @@ mod tests {
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let run_dir = {
let runs = state.runs.lock().expect("runs lock poisoned");
@ -2344,11 +2401,11 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
*state.settings.write().unwrap() = FabroSettings::default();
execute_run(Arc::clone(&state), run_id.clone()).await;
execute_run(Arc::clone(&state), run_id).await;
let runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = runs.get(&run_id).expect("run should still exist");
@ -2378,7 +2435,7 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
// Cancel it
let req = Request::builder()
@ -2425,9 +2482,9 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id.clone()));
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
{
@ -2498,9 +2555,9 @@ mod tests {
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id.clone()));
let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let req = Request::builder()

View file

@ -375,7 +375,8 @@ mod tests {
use super::*;
use crate::{InMemoryStore, Store};
use fabro_types::{
AggregateStats, AttrValue, FabroSettings, Graph, RunStatus, StageStatus, StatusReason,
AggregateStats, AttrValue, FabroSettings, Graph, RunId, RunStatus, StageStatus,
StatusReason, fixtures,
};
fn dt(rfc3339: &str) -> DateTime<Utc> {
@ -384,6 +385,13 @@ mod tests {
.with_timezone(&Utc)
}
fn test_run_id(label: &str) -> RunId {
match label {
"run-1" => fixtures::RUN_1,
_ => panic!("unknown test run id: {label}"),
}
}
fn sample_run_record(run_id: &str, created_at: DateTime<Utc>) -> RunRecord {
let mut graph = Graph::new("night-sky");
graph.attrs.insert(
@ -391,7 +399,7 @@ mod tests {
AttrValue::String("map the constellations".to_string()),
);
RunRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
created_at,
settings: FabroSettings::default(),
graph,
@ -405,7 +413,7 @@ mod tests {
fn sample_start_record(run_id: &str, created_at: DateTime<Utc>) -> StartRecord {
StartRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
start_time: created_at + ChronoDuration::seconds(5),
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("abc123".to_string()),
@ -460,7 +468,7 @@ mod tests {
fn sample_retro(run_id: &str) -> Retro {
Retro {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
workflow_name: "night-sky".to_string(),
goal: "map the constellations".to_string(),
timestamp: dt("2026-03-27T12:20:00Z"),
@ -506,10 +514,10 @@ mod tests {
EventPayload::new(
serde_json::json!({
"ts": ts,
"run_id": run_id,
"run_id": test_run_id(run_id).to_string(),
"event": event,
}),
run_id,
&test_run_id(run_id),
)
.unwrap()
}
@ -520,7 +528,7 @@ mod tests {
) -> (Arc<dyn RunStore>, DiskProjectingRunStore) {
let inner = InMemoryStore::default()
.create_run(
"run-1",
&test_run_id("run-1"),
created_at,
Some(run_dir.to_string_lossy().as_ref()),
)
@ -796,7 +804,7 @@ mod tests {
let created_at = dt("2026-03-27T12:00:00Z");
let inner = InMemoryStore::default()
.create_run(
"run-1",
&test_run_id("run-1"),
created_at,
Some(temp.path().to_string_lossy().as_ref()),
)

View file

@ -24,8 +24,8 @@ pub use types::{
};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunRecord, RunStatusRecord, SandboxRecord,
StartRecord,
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunId, RunRecord, RunStatusRecord,
SandboxRecord, StartRecord,
};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -38,14 +38,14 @@ pub struct ListRunsQuery {
pub trait Store: Send + Sync {
async fn create_run(
&self,
run_id: &str,
run_id: &RunId,
created_at: DateTime<Utc>,
run_dir: Option<&str>,
) -> Result<Arc<dyn RunStore>>;
async fn open_run(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>>;
async fn open_run_reader(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>>;
async fn open_run(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>>;
async fn open_run_reader(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>>;
async fn list_runs(&self, query: &ListRunsQuery) -> Result<Vec<RunSummary>>;
async fn delete_run(&self, run_id: &str) -> Result<()>;
async fn delete_run(&self, run_id: &RunId) -> Result<()>;
}
#[async_trait]

View file

@ -18,13 +18,13 @@ use crate::{
RunSnapshot, RunStore, RunSummary, Store, StoreError,
};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunRecord, RunStatusRecord, SandboxRecord,
StartRecord,
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunId, RunRecord, RunStatusRecord,
SandboxRecord, StartRecord,
};
#[derive(Debug, Default)]
pub struct InMemoryStore {
runs: Mutex<HashMap<String, InMemoryCatalog>>,
runs: Mutex<HashMap<RunId, InMemoryCatalog>>,
}
#[derive(Debug, Clone)]
@ -35,7 +35,7 @@ struct InMemoryCatalog {
#[derive(Debug)]
struct InMemoryRunStore {
run_id: String,
run_id: RunId,
created_at: DateTime<Utc>,
data: Mutex<BTreeMap<String, Vec<u8>>>,
event_seq: AtomicU32,
@ -45,13 +45,13 @@ struct InMemoryRunStore {
impl InMemoryRunStore {
fn new(
run_id: &str,
run_id: &RunId,
created_at: DateTime<Utc>,
db_prefix: String,
run_dir: Option<String>,
) -> Result<Self> {
let record = CatalogRecord {
run_id: run_id.to_string(),
run_id: *run_id,
created_at,
db_prefix,
run_dir,
@ -59,7 +59,7 @@ impl InMemoryRunStore {
let mut data = BTreeMap::new();
data.insert(keys::init().to_string(), serde_json::to_vec(&record)?);
Ok(Self {
run_id: run_id.to_string(),
run_id: *run_id,
created_at,
data: Mutex::new(data),
event_seq: AtomicU32::new(1),
@ -247,7 +247,7 @@ impl InMemoryRunStore {
impl Store for InMemoryStore {
async fn create_run(
&self,
run_id: &str,
run_id: &RunId,
created_at: DateTime<Utc>,
run_dir: Option<&str>,
) -> Result<Arc<dyn RunStore>> {
@ -268,25 +268,25 @@ impl Store for InMemoryStore {
)?);
let catalog = InMemoryCatalog {
record: CatalogRecord {
run_id: run_id.to_string(),
run_id: *run_id,
created_at,
db_prefix,
run_dir: run_dir.map(ToOwned::to_owned),
},
run_store: Arc::clone(&run_store),
};
runs.insert(run_id.to_string(), catalog);
runs.insert(*run_id, catalog);
Ok(run_store as Arc<dyn RunStore>)
}
async fn open_run(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>> {
async fn open_run(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>> {
let runs = self.runs.lock().await;
Ok(runs
.get(run_id)
.map(|catalog| Arc::clone(&catalog.run_store) as Arc<dyn RunStore>))
}
async fn open_run_reader(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>> {
async fn open_run_reader(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>> {
self.open_run(run_id).await
}
@ -308,7 +308,7 @@ impl Store for InMemoryStore {
Ok(summaries)
}
async fn delete_run(&self, run_id: &str) -> Result<()> {
async fn delete_run(&self, run_id: &RunId) -> Result<()> {
self.runs.lock().await.remove(run_id);
Ok(())
}
@ -621,7 +621,7 @@ fn build_run_summary(
});
Ok(RunSummary {
run_id: record.run_id.clone(),
run_id: record.run_id,
created_at: record.created_at,
db_prefix: record.db_prefix.clone(),
run_dir: record.run_dir.clone(),
@ -649,13 +649,25 @@ mod tests {
use std::time::Duration;
use chrono::Duration as ChronoDuration;
use fabro_types::{AttrValue, FabroSettings, Graph, RunStatus, StageStatus, StatusReason};
use fabro_types::{
AttrValue, FabroSettings, Graph, RunId, RunStatus, StageStatus, StatusReason, fixtures,
};
fn dt(rfc3339: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(rfc3339)
.unwrap()
.with_timezone(&Utc)
}
fn test_run_id(label: &str) -> RunId {
match label {
"run-1" => fixtures::RUN_1,
"run-early" => fixtures::RUN_2,
"run-late" => fixtures::RUN_3,
"other-run" => fixtures::RUN_4,
_ => panic!("unknown test run id: {label}"),
}
}
fn sample_run_record(run_id: &str, created_at: DateTime<Utc>) -> RunRecord {
let mut graph = Graph::new("night-sky");
graph.attrs.insert(
@ -663,7 +675,7 @@ mod tests {
AttrValue::String("map the constellations".to_string()),
);
RunRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
created_at,
settings: FabroSettings::default(),
graph,
@ -677,7 +689,7 @@ mod tests {
fn sample_start_record(run_id: &str, created_at: DateTime<Utc>) -> StartRecord {
StartRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
start_time: created_at + ChronoDuration::seconds(5),
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("abc123".to_string()),
@ -732,7 +744,7 @@ mod tests {
fn sample_retro(run_id: &str) -> Retro {
Retro {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
workflow_name: "night-sky".to_string(),
goal: "map the constellations".to_string(),
timestamp: dt("2026-03-27T12:20:00Z"),
@ -778,7 +790,10 @@ mod tests {
async fn create_run_put_get_and_snapshot_round_trip() {
let store = InMemoryStore::default();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
let run_record = sample_run_record("run-1", created_at);
let start_record = sample_start_record("run-1", created_at);
@ -899,7 +914,10 @@ mod tests {
async fn list_artifact_values_and_all_assets_include_asset_only_visits() {
let store = InMemoryStore::default();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
@ -955,7 +973,7 @@ mod tests {
async fn append_event_validates_payload_shape_and_run_id() {
let store = InMemoryStore::default();
let run = store
.create_run("run-1", dt("2026-03-27T12:00:00Z"), None)
.create_run(&test_run_id("run-1"), dt("2026-03-27T12:00:00Z"), None)
.await
.unwrap();
@ -980,7 +998,10 @@ mod tests {
async fn put_run_rejects_created_at_mismatch() {
let store = InMemoryStore::default();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
let err = run
.put_run(&sample_run_record(
"run-1",
@ -995,25 +1016,25 @@ mod tests {
async fn watch_events_from_receives_existing_and_live_events() {
let store = InMemoryStore::default();
let run = store
.create_run("run-1", dt("2026-03-27T12:00:00Z"), None)
.create_run(&test_run_id("run-1"), dt("2026-03-27T12:00:00Z"), None)
.await
.unwrap();
let first = EventPayload::new(
serde_json::json!({
"ts": "2026-03-27T12:00:00.000Z",
"run_id": "run-1",
"run_id": test_run_id("run-1").to_string(),
"event": "WorkflowRunStarted"
}),
"run-1",
&test_run_id("run-1"),
)
.unwrap();
let second = EventPayload::new(
serde_json::json!({
"ts": "2026-03-27T12:00:01.000Z",
"run_id": "run-1",
"run_id": test_run_id("run-1").to_string(),
"event": "StageCompleted"
}),
"run-1",
&test_run_id("run-1"),
)
.unwrap();
@ -1046,7 +1067,7 @@ mod tests {
async fn checkpoint_history_round_trips() {
let store = InMemoryStore::default();
let run = store
.create_run("run-1", dt("2026-03-27T12:00:00Z"), None)
.create_run(&test_run_id("run-1"), dt("2026-03-27T12:00:00Z"), None)
.await
.unwrap();
let checkpoint = sample_checkpoint();
@ -1065,7 +1086,7 @@ mod tests {
async fn node_visit_storage_round_trips() {
let store = InMemoryStore::default();
let run = store
.create_run("run-1", dt("2026-03-27T12:00:00Z"), None)
.create_run(&test_run_id("run-1"), dt("2026-03-27T12:00:00Z"), None)
.await
.unwrap();
@ -1094,13 +1115,19 @@ mod tests {
let early = dt("2026-03-27T10:00:00Z");
let late = dt("2026-03-27T12:00:00Z");
let early_run = store.create_run("run-early", early, None).await.unwrap();
let early_run = store
.create_run(&test_run_id("run-early"), early, None)
.await
.unwrap();
early_run
.put_run(&sample_run_record("run-early", early))
.await
.unwrap();
let late_run = store.create_run("run-late", late, None).await.unwrap();
let late_run = store
.create_run(&test_run_id("run-late"), late, None)
.await
.unwrap();
late_run
.put_run(&sample_run_record("run-late", late))
.await
@ -1120,7 +1147,7 @@ mod tests {
let all = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].run_id, "run-late");
assert_eq!(all[0].run_id, test_run_id("run-late"));
assert_eq!(all[0].workflow_name, Some("night-sky".to_string()));
assert_eq!(all[0].goal, Some("map the constellations".to_string()));
assert_eq!(
@ -1140,19 +1167,25 @@ mod tests {
.await
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].run_id, "run-late");
assert_eq!(filtered[0].run_id, test_run_id("run-late"));
}
#[tokio::test]
async fn delete_run_is_idempotent() {
let store = InMemoryStore::default();
store
.create_run("run-1", dt("2026-03-27T12:00:00Z"), None)
.create_run(&test_run_id("run-1"), dt("2026-03-27T12:00:00Z"), None)
.await
.unwrap();
store.delete_run("run-1").await.unwrap();
store.delete_run("run-1").await.unwrap();
assert!(store.open_run("run-1").await.unwrap().is_none());
store.delete_run(&test_run_id("run-1")).await.unwrap();
store.delete_run(&test_run_id("run-1")).await.unwrap();
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
@ -1161,14 +1194,23 @@ mod tests {
let ts = dt("2026-03-27T12:00:00Z");
// First create succeeds.
store.create_run("run-1", ts, None).await.unwrap();
store
.create_run(&test_run_id("run-1"), ts, None)
.await
.unwrap();
// Retry with exact same created_at succeeds (idempotent).
store.create_run("run-1", ts, None).await.unwrap();
store
.create_run(&test_run_id("run-1"), ts, None)
.await
.unwrap();
// Different created_at for the same run_id is rejected.
let different_ts = dt("2026-03-27T12:00:01Z");
match store.create_run("run-1", different_ts, None).await {
match store
.create_run(&test_run_id("run-1"), different_ts, None)
.await
{
Err(StoreError::RunAlreadyExists(_)) => {} // expected
Err(other) => panic!("expected RunAlreadyExists, got: {other:?}"),
Ok(_) => panic!("expected RunAlreadyExists, but create_run succeeded"),

View file

@ -7,17 +7,18 @@ use object_store::ObjectStore;
use object_store::path::Path;
use crate::{CatalogRecord, ListRunsQuery, Result};
use fabro_types::RunId;
pub(crate) async fn write_catalog(
store: Arc<dyn ObjectStore>,
base_prefix: &str,
run_id: &str,
run_id: &RunId,
created_at: DateTime<Utc>,
db_prefix: &str,
run_dir: Option<&str>,
) -> Result<CatalogRecord> {
let record = CatalogRecord {
run_id: run_id.to_string(),
run_id: *run_id,
created_at,
db_prefix: db_prefix.to_string(),
run_dir: run_dir.map(ToOwned::to_owned),
@ -38,7 +39,7 @@ pub(crate) async fn write_catalog(
pub(crate) async fn read_locator(
store: Arc<dyn ObjectStore>,
base_prefix: &str,
run_id: &str,
run_id: &RunId,
) -> Result<Option<CatalogRecord>> {
read_catalog_path(store, by_id_path(base_prefix, run_id)).await
}
@ -129,18 +130,18 @@ pub(super) async fn repair_catalog(store: Arc<dyn ObjectStore>, base_prefix: &st
Ok(())
}
pub(crate) fn db_prefix(base_prefix: &str, created_at: DateTime<Utc>, run_id: &str) -> String {
pub(crate) fn db_prefix(base_prefix: &str, created_at: DateTime<Utc>, run_id: &RunId) -> String {
format!(
"{base_prefix}db/{}/{run_id}/",
created_at.format("%Y-%m-%d-%H-%M-%S-%3f")
)
}
pub(crate) fn by_id_path(base_prefix: &str, run_id: &str) -> Path {
pub(crate) fn by_id_path(base_prefix: &str, run_id: &RunId) -> Path {
Path::from(format!("{base_prefix}by-id/{run_id}.json"))
}
pub(crate) fn by_start_path(base_prefix: &str, created_at: DateTime<Utc>, run_id: &str) -> Path {
pub(crate) fn by_start_path(base_prefix: &str, created_at: DateTime<Utc>, run_id: &RunId) -> Path {
Path::from(format!(
"{base_prefix}by-start/{}/{run_id}.json",
created_at.format("%Y-%m-%d-%H-%M")

View file

@ -16,6 +16,7 @@ use tokio::sync::Mutex;
use crate::keys;
use crate::{CatalogRecord, ListRunsQuery, Result, RunStore, RunSummary, Store, StoreError};
use fabro_types::RunId;
use run_store::{SlateRunStore, SlateRunStoreInner};
#[derive(Clone)]
@ -23,7 +24,7 @@ pub struct SlateStore {
object_store: Arc<dyn ObjectStore>,
base_prefix: String,
flush_interval: Duration,
active_runs: Arc<Mutex<HashMap<String, std::sync::Weak<SlateRunStoreInner>>>>,
active_runs: Arc<Mutex<HashMap<RunId, std::sync::Weak<SlateRunStoreInner>>>>,
}
impl std::fmt::Debug for SlateStore {
@ -81,7 +82,7 @@ impl SlateStore {
Ok(items.try_next().await?.is_some())
}
async fn get_active_run(&self, run_id: &str) -> Option<SlateRunStore> {
async fn get_active_run(&self, run_id: &RunId) -> Option<SlateRunStore> {
let mut active_runs = self.active_runs.lock().await;
let weak = active_runs.get(run_id).cloned()?;
if let Some(inner) = weak.upgrade() {
@ -96,10 +97,10 @@ impl SlateStore {
self.active_runs
.lock()
.await
.insert(run_store.record().run_id.clone(), run_store.downgrade());
.insert(run_store.record().run_id, run_store.downgrade());
}
async fn remove_active_run(&self, run_id: &str) -> Option<SlateRunStore> {
async fn remove_active_run(&self, run_id: &RunId) -> Option<SlateRunStore> {
let weak = self.active_runs.lock().await.remove(run_id)?;
weak.upgrade().map(SlateRunStore::from_inner)
}
@ -173,7 +174,7 @@ impl SlateStore {
impl Store for SlateStore {
async fn create_run(
&self,
run_id: &str,
run_id: &RunId,
created_at: DateTime<Utc>,
run_dir: Option<&str>,
) -> Result<Arc<dyn RunStore>> {
@ -209,7 +210,7 @@ impl Store for SlateStore {
};
let record = CatalogRecord {
run_id: run_id.to_string(),
run_id: *run_id,
created_at,
db_prefix: db_prefix.clone(),
run_dir: run_dir.map(ToOwned::to_owned),
@ -232,7 +233,7 @@ impl Store for SlateStore {
Ok(Arc::new(run_store) as Arc<dyn RunStore>)
}
async fn open_run(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>> {
async fn open_run(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>> {
let Some(locator) =
catalog::read_locator(self.object_store.clone(), &self.base_prefix, run_id).await?
else {
@ -245,7 +246,7 @@ impl Store for SlateStore {
Ok(Some(Arc::new(run_store) as Arc<dyn RunStore>))
}
async fn open_run_reader(&self, run_id: &str) -> Result<Option<Arc<dyn RunStore>>> {
async fn open_run_reader(&self, run_id: &RunId) -> Result<Option<Arc<dyn RunStore>>> {
let Some(locator) =
catalog::read_locator(self.object_store.clone(), &self.base_prefix, run_id).await?
else {
@ -291,7 +292,7 @@ impl Store for SlateStore {
Ok(summaries)
}
async fn delete_run(&self, run_id: &str) -> Result<()> {
async fn delete_run(&self, run_id: &RunId) -> Result<()> {
let active = self.remove_active_run(run_id).await;
let active_record = active.as_ref().map(SlateRunStore::record);
if let Some(active) = &active {
@ -382,8 +383,8 @@ mod tests {
use bytes::Bytes;
use fabro_types::{
AttrValue, Checkpoint, Conclusion, FabroSettings, Graph, NodeStatusRecord, RunRecord,
RunStatus, RunStatusRecord, StageStatus, StartRecord, StatusReason,
AttrValue, Checkpoint, Conclusion, FabroSettings, Graph, NodeStatusRecord, RunId,
RunRecord, RunStatus, RunStatusRecord, StageStatus, StartRecord, StatusReason, fixtures,
};
use object_store::memory::InMemory;
use slatedb::{CloseReason, ErrorKind};
@ -402,6 +403,14 @@ mod tests {
(object_store, store)
}
fn test_run_id(label: &str) -> RunId {
match label {
"run-1" => fixtures::RUN_1,
"other-run" => fixtures::RUN_2,
_ => panic!("unknown test run id: {label}"),
}
}
fn sample_run_record(run_id: &str, created_at: DateTime<Utc>) -> RunRecord {
let mut graph = Graph::new("night-sky");
graph.attrs.insert(
@ -409,7 +418,7 @@ mod tests {
AttrValue::String("map the constellations".to_string()),
);
RunRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
created_at,
settings: FabroSettings::default(),
graph,
@ -423,7 +432,7 @@ mod tests {
fn sample_start_record(run_id: &str, created_at: DateTime<Utc>) -> StartRecord {
StartRecord {
run_id: run_id.to_string(),
run_id: test_run_id(run_id),
start_time: created_at + chrono::Duration::seconds(5),
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("abc123".to_string()),
@ -486,10 +495,10 @@ mod tests {
EventPayload::new(
serde_json::json!({
"ts": ts,
"run_id": run_id,
"run_id": test_run_id(run_id).to_string(),
"event": event
}),
run_id,
&test_run_id(run_id),
)
.unwrap()
}
@ -534,7 +543,10 @@ mod tests {
async fn create_open_list_and_delete_full_lifecycle() {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
@ -550,25 +562,35 @@ mod tests {
.unwrap();
run.put_conclusion(&sample_conclusion()).await.unwrap();
let by_id = catalog::by_id_path("runs/", "run-1");
let by_start = catalog::by_start_path("runs/", created_at, "run-1");
let by_id = catalog::by_id_path("runs/", &test_run_id("run-1"));
let by_start = catalog::by_start_path("runs/", created_at, &test_run_id("run-1"));
assert!(object_exists(object_store.clone(), &by_id).await);
assert!(object_exists(object_store.clone(), &by_start).await);
let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].run_id, "run-1");
assert_eq!(summary[0].run_id, test_run_id("run-1"));
assert_eq!(summary[0].workflow_name, Some("night-sky".to_string()));
assert_eq!(summary[0].goal, Some("map the constellations".to_string()));
assert_eq!(summary[0].status, Some(RunStatus::Succeeded));
assert_eq!(summary[0].status_reason, Some(StatusReason::Completed));
let reopened = store.open_run("run-1").await.unwrap().unwrap();
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.unwrap();
let stored = reopened.get_run().await.unwrap().unwrap();
assert_eq!(stored.run_id, "run-1");
assert_eq!(stored.run_id, test_run_id("run-1"));
store.delete_run("run-1").await.unwrap();
assert!(store.open_run("run-1").await.unwrap().is_none());
store.delete_run(&test_run_id("run-1")).await.unwrap();
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.is_none()
);
assert!(!object_exists(object_store.clone(), &by_id).await);
assert!(!object_exists(object_store.clone(), &by_start).await);
assert!(list_paths(object_store, "runs/db").await.is_empty());
@ -579,9 +601,9 @@ mod tests {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let record = CatalogRecord {
run_id: "run-1".to_string(),
run_id: test_run_id("run-1"),
created_at,
db_prefix: catalog::db_prefix("runs/", created_at, "run-1"),
db_prefix: catalog::db_prefix("runs/", created_at, &test_run_id("run-1")),
run_dir: None,
};
@ -596,13 +618,19 @@ mod tests {
object_store
.put(
&catalog::by_id_path("runs/", "run-1"),
&catalog::by_id_path("runs/", &test_run_id("run-1")),
serde_json::to_vec(&record).unwrap().into(),
)
.await
.unwrap();
assert!(store.open_run("run-1").await.unwrap().is_some());
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.is_some()
);
assert!(
store
.list_runs(&ListRunsQuery::default())
@ -617,7 +645,7 @@ mod tests {
assert!(
object_exists(
object_store,
&catalog::by_start_path("runs/", created_at, "run-1")
&catalog::by_start_path("runs/", created_at, &test_run_id("run-1"))
)
.await
);
@ -627,7 +655,10 @@ mod tests {
async fn reopen_recovers_event_and_checkpoint_sequences() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
@ -640,7 +671,11 @@ mod tests {
run.append_checkpoint(&sample_checkpoint()).await.unwrap();
drop(run);
let reopened = store.open_run("run-1").await.unwrap().unwrap();
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.unwrap();
let next_event = reopened
.append_event(&event_payload(
"run-1",
@ -662,9 +697,9 @@ mod tests {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let record = CatalogRecord {
run_id: "run-1".to_string(),
run_id: test_run_id("run-1"),
created_at,
db_prefix: catalog::db_prefix("runs/", created_at, "run-1"),
db_prefix: catalog::db_prefix("runs/", created_at, &test_run_id("run-1")),
run_dir: None,
};
@ -672,20 +707,26 @@ mod tests {
db.close().await.unwrap();
object_store
.put(
&catalog::by_id_path("runs/", "run-1"),
&catalog::by_id_path("runs/", &test_run_id("run-1")),
serde_json::to_vec(&record).unwrap().into(),
)
.await
.unwrap();
object_store
.put(
&catalog::by_start_path("runs/", created_at, "run-1"),
&catalog::by_start_path("runs/", created_at, &test_run_id("run-1")),
serde_json::to_vec(&record).unwrap().into(),
)
.await
.unwrap();
assert!(store.open_run("run-1").await.unwrap().is_none());
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.is_none()
);
assert!(
store
.list_runs(&ListRunsQuery::default())
@ -699,11 +740,21 @@ mod tests {
async fn create_run_allows_idempotent_retry_and_rejects_conflict() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
store.create_run("run-1", created_at, None).await.unwrap();
store.create_run("run-1", created_at, None).await.unwrap();
store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
let conflict = store
.create_run("run-1", created_at + chrono::Duration::seconds(1), None)
.create_run(
&test_run_id("run-1"),
created_at + chrono::Duration::seconds(1),
None,
)
.await;
assert!(matches!(conflict, Err(StoreError::RunAlreadyExists(_))));
}
@ -712,7 +763,10 @@ mod tests {
async fn list_runs_and_open_run_reuse_active_handle_without_fencing() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
@ -720,7 +774,11 @@ mod tests {
let listed = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(listed.len(), 1);
let reopened = store.open_run("run-1").await.unwrap().unwrap();
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.unwrap();
let first_event = run
.append_event(&event_payload("run-1", "2026-03-27T12:00:00Z", "Started"))
.await
@ -745,7 +803,10 @@ mod tests {
async fn watch_events_from_polls_new_events() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
let mut stream = run.watch_events_from(1).await.unwrap();
run.append_event(&event_payload("run-1", "2026-03-27T12:00:00Z", "Started"))
@ -768,9 +829,9 @@ mod tests {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let record = CatalogRecord {
run_id: "run-1".to_string(),
run_id: test_run_id("run-1"),
created_at,
db_prefix: catalog::db_prefix("runs/", created_at, "run-1"),
db_prefix: catalog::db_prefix("runs/", created_at, &test_run_id("run-1")),
run_dir: None,
};
let db = seed_db(object_store.clone(), &record, true).await;
@ -783,14 +844,14 @@ mod tests {
db.close().await.unwrap();
object_store
.put(
&catalog::by_start_path("runs/", created_at, "run-1"),
&catalog::by_start_path("runs/", created_at, &test_run_id("run-1")),
serde_json::to_vec(&record).unwrap().into(),
)
.await
.unwrap();
store.delete_run("run-1").await.unwrap();
store.delete_run("run-1").await.unwrap();
store.delete_run(&test_run_id("run-1")).await.unwrap();
store.delete_run(&test_run_id("run-1")).await.unwrap();
assert!(list_paths(object_store, "runs").await.is_empty());
}
@ -798,19 +859,28 @@ mod tests {
async fn delete_run_closes_active_handles() {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
store.delete_run("run-1").await.unwrap();
store.delete_run(&test_run_id("run-1")).await.unwrap();
let err = run.put_graph("digraph night_sky {}").await.unwrap_err();
assert!(matches!(
err,
StoreError::Slate(err) if matches!(err.kind(), ErrorKind::Closed(CloseReason::Clean))
));
assert!(store.open_run("run-1").await.unwrap().is_none());
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.unwrap()
.is_none()
);
assert!(list_paths(object_store, "runs").await.is_empty());
}
@ -819,18 +889,21 @@ mod tests {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let wrong_time = dt("2026-03-27T11:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
let locator = catalog::read_locator(object_store.clone(), "runs/", "run-1")
let locator = catalog::read_locator(object_store.clone(), "runs/", &test_run_id("run-1"))
.await
.unwrap()
.unwrap();
object_store
.put(
&catalog::by_start_path("runs/", wrong_time, "run-1"),
&catalog::by_start_path("runs/", wrong_time, &test_run_id("run-1")),
serde_json::to_vec(&locator).unwrap().into(),
)
.await
@ -839,7 +912,7 @@ mod tests {
store.repair_catalog().await.unwrap();
let paths = list_paths(object_store, "runs/by-start").await;
assert_eq!(paths.len(), 1);
assert!(paths[0].contains("2026-03-27-12-00/run-1.json"));
assert!(paths[0].contains(&format!("2026-03-27-12-00/{}.json", test_run_id("run-1"))));
}
#[tokio::test]
@ -848,12 +921,12 @@ mod tests {
let old_created_at = dt("2026-03-27T12:00:00Z");
let new_created_at = dt("2026-03-27T12:00:30Z");
let orphan = CatalogRecord {
run_id: "run-1".to_string(),
run_id: test_run_id("run-1"),
created_at: old_created_at,
db_prefix: catalog::db_prefix("runs/", old_created_at, "run-1"),
db_prefix: catalog::db_prefix("runs/", old_created_at, &test_run_id("run-1")),
run_dir: None,
};
let new_prefix = catalog::db_prefix("runs/", new_created_at, "run-1");
let new_prefix = catalog::db_prefix("runs/", new_created_at, &test_run_id("run-1"));
assert_ne!(orphan.db_prefix, new_prefix);
let db = seed_db(object_store.clone(), &orphan, true).await;
@ -861,12 +934,12 @@ mod tests {
db.close().await.unwrap();
let run = store
.create_run("run-1", new_created_at, None)
.create_run(&test_run_id("run-1"), new_created_at, None)
.await
.unwrap();
assert_eq!(run.get_graph().await.unwrap(), None);
let locator = catalog::read_locator(object_store, "runs/", "run-1")
let locator = catalog::read_locator(object_store, "runs/", &test_run_id("run-1"))
.await
.unwrap()
.unwrap();
@ -878,7 +951,7 @@ mod tests {
async fn create_run_rejects_mismatched_init_for_existing_prefix() {
let (object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let db_prefix = catalog::db_prefix("runs/", created_at, "run-1");
let db_prefix = catalog::db_prefix("runs/", created_at, &test_run_id("run-1"));
let db = slatedb::Db::builder(db_prefix.clone(), object_store)
.with_settings(slatedb::config::Settings {
flush_interval: Some(Duration::from_millis(5)),
@ -888,7 +961,7 @@ mod tests {
.await
.unwrap();
let mismatched = CatalogRecord {
run_id: "other-run".to_string(),
run_id: test_run_id("other-run"),
created_at,
db_prefix,
run_dir: None,
@ -898,7 +971,10 @@ mod tests {
.unwrap();
db.close().await.unwrap();
let err = match store.create_run("run-1", created_at, None).await {
let err = match store
.create_run(&test_run_id("run-1"), created_at, None)
.await
{
Ok(_) => panic!("expected create_run to reject mismatched _init.json"),
Err(err) => err,
};
@ -912,7 +988,10 @@ mod tests {
async fn slate_run_store_round_trips_node_data_and_assets() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
@ -944,7 +1023,10 @@ mod tests {
async fn slate_run_store_lists_artifact_values_and_asset_only_visits() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store.create_run("run-1", created_at, None).await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();

View file

@ -20,8 +20,8 @@ use crate::{
RunStore, RunSummary, StoreError,
};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunRecord, RunStatusRecord, SandboxRecord,
StartRecord,
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunId, RunRecord, RunStatusRecord,
SandboxRecord, StartRecord,
};
#[derive(Clone)]
@ -30,7 +30,7 @@ pub(crate) struct SlateRunStore {
}
pub(crate) struct SlateRunStoreInner {
run_id: String,
run_id: RunId,
created_at: DateTime<Utc>,
db_prefix: String,
run_dir: Option<String>,
@ -92,7 +92,7 @@ impl SlateRunStore {
pub(crate) fn record(&self) -> CatalogRecord {
CatalogRecord {
run_id: self.inner.run_id.clone(),
run_id: self.inner.run_id,
created_at: self.inner.created_at,
db_prefix: self.inner.db_prefix.clone(),
run_dir: self.inner.run_dir.clone(),
@ -161,7 +161,7 @@ impl SlateRunStore {
});
Ok(RunSummary {
run_id: catalog.run_id.clone(),
run_id: catalog.run_id,
created_at: catalog.created_at,
db_prefix: catalog.db_prefix.clone(),
run_dir: catalog.run_dir.clone(),

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::{Result, StoreError};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunRecord, RunStatus, RunStatusRecord,
Checkpoint, Conclusion, NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord,
SandboxRecord, StartRecord, StatusReason,
};
@ -17,7 +17,7 @@ pub struct NodeVisitRef<'a> {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogRecord {
pub run_id: String,
pub run_id: RunId,
pub created_at: DateTime<Utc>,
pub db_prefix: String,
pub run_dir: Option<String>,
@ -25,7 +25,7 @@ pub struct CatalogRecord {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
pub run_id: String,
pub run_id: RunId,
pub created_at: DateTime<Utc>,
pub db_prefix: String,
pub run_dir: Option<String>,
@ -70,13 +70,13 @@ pub struct NodeSnapshot {
pub struct EventPayload(serde_json::Value);
impl EventPayload {
pub fn new(value: serde_json::Value, expected_run_id: &str) -> Result<Self> {
pub fn new(value: serde_json::Value, expected_run_id: &RunId) -> Result<Self> {
let payload = Self(value);
payload.validate(expected_run_id)?;
Ok(payload)
}
pub fn validate(&self, expected_run_id: &str) -> Result<()> {
pub fn validate(&self, expected_run_id: &RunId) -> Result<()> {
let obj = self.0.as_object().ok_or_else(|| {
StoreError::InvalidEvent("event payload must be a JSON object".into())
})?;
@ -93,7 +93,9 @@ impl EventPayload {
}
match obj.get("run_id") {
Some(serde_json::Value::String(run_id)) if run_id == expected_run_id => Ok(()),
Some(serde_json::Value::String(run_id)) if run_id == &expected_run_id.to_string() => {
Ok(())
}
Some(serde_json::Value::String(run_id)) => Err(StoreError::InvalidEvent(format!(
"payload run_id {run_id:?} does not match store run_id {expected_run_id:?}"
))),

View file

@ -23,3 +23,4 @@ clap = { workspace = true, optional = true }
fabro-macros = { path = "../fabro-macros" }
serde.workspace = true
serde_json.workspace = true
ulid.workspace = true

View file

@ -9,6 +9,7 @@ pub mod node_status;
pub mod outcome;
pub mod retro;
pub mod run;
pub mod run_id;
pub mod sandbox_record;
pub mod settings;
pub mod start;
@ -26,6 +27,8 @@ pub use retro::{
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::RunRecord;
pub use run_id::RunId;
pub use run_id::fixtures;
pub use sandbox_record::SandboxRecord;
pub use settings::FabroSettings;
pub use start::StartRecord;

View file

@ -3,6 +3,8 @@ use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::run_id::RunId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SmoothnessRating {
@ -118,7 +120,7 @@ pub struct RetroNarrative {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Retro {
pub run_id: String,
pub run_id: RunId,
pub workflow_name: String,
pub goal: String,
pub timestamp: DateTime<Utc>,

View file

@ -5,11 +5,12 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::graph::Graph;
use crate::run_id::RunId;
use crate::settings::FabroSettings;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub run_id: RunId,
pub created_at: DateTime<Utc>,
pub settings: FabroSettings,
pub graph: Graph,

View file

@ -0,0 +1,175 @@
use std::fmt;
use std::str::FromStr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use ulid::Ulid;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RunId(Ulid);
impl RunId {
pub fn new() -> Self {
Self(Ulid::new())
}
}
impl Default for RunId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for RunId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for RunId {
type Err = ulid::DecodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(Ulid::from_str(s)?))
}
}
impl From<Ulid> for RunId {
fn from(value: Ulid) -> Self {
Self(value)
}
}
impl From<RunId> for Ulid {
fn from(value: RunId) -> Self {
value.0
}
}
impl From<RunId> for String {
fn from(value: RunId) -> Self {
value.to_string()
}
}
impl From<&RunId> for String {
fn from(value: &RunId) -> Self {
value.to_string()
}
}
impl Serialize for RunId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for RunId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(D::Error::custom)
}
}
pub mod fixtures {
use super::RunId;
use ulid::Ulid;
macro_rules! define_run_ids {
($($name:ident = $value:expr),+ $(,)?) => {
$(pub const $name: RunId = RunId(Ulid($value));)+
};
}
define_run_ids!(
RUN_1 = 1,
RUN_2 = 2,
RUN_3 = 3,
RUN_4 = 4,
RUN_5 = 5,
RUN_6 = 6,
RUN_7 = 7,
RUN_8 = 8,
RUN_9 = 9,
RUN_10 = 10,
RUN_11 = 11,
RUN_12 = 12,
RUN_13 = 13,
RUN_14 = 14,
RUN_15 = 15,
RUN_16 = 16,
RUN_17 = 17,
RUN_18 = 18,
RUN_19 = 19,
RUN_20 = 20,
RUN_21 = 21,
RUN_22 = 22,
RUN_23 = 23,
RUN_24 = 24,
RUN_25 = 25,
RUN_26 = 26,
RUN_27 = 27,
RUN_28 = 28,
RUN_29 = 29,
RUN_30 = 30,
RUN_31 = 31,
RUN_32 = 32,
RUN_33 = 33,
RUN_34 = 34,
RUN_35 = 35,
RUN_36 = 36,
RUN_37 = 37,
RUN_38 = 38,
RUN_39 = 39,
RUN_40 = 40,
RUN_41 = 41,
RUN_42 = 42,
RUN_43 = 43,
RUN_44 = 44,
RUN_45 = 45,
RUN_46 = 46,
RUN_47 = 47,
RUN_48 = 48,
RUN_49 = 49,
RUN_50 = 50,
RUN_51 = 51,
RUN_52 = 52,
RUN_53 = 53,
RUN_54 = 54,
RUN_55 = 55,
RUN_56 = 56,
RUN_57 = 57,
RUN_58 = 58,
RUN_59 = 59,
RUN_60 = 60,
RUN_61 = 61,
RUN_62 = 62,
RUN_63 = 63,
RUN_64 = 64
);
}
#[cfg(test)]
mod tests {
use super::{RunId, fixtures};
#[test]
fn serializes_as_a_ulid_string() {
let value = serde_json::to_value(fixtures::RUN_1).unwrap();
assert_eq!(value, serde_json::json!("00000000000000000000000001"));
}
#[test]
fn deserializes_from_a_ulid_string() {
let value: RunId =
serde_json::from_value(serde_json::json!("0000000000000000000000001A")).unwrap();
assert_eq!(value, fixtures::RUN_42);
}
}

View file

@ -1,9 +1,11 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::run_id::RunId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecord {
pub run_id: String,
pub run_id: RunId,
pub start_time: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,

View file

@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicI64, Ordering};
use anyhow::{Context, Result};
use chrono::{SecondsFormat, Utc};
use fabro_store::{EventPayload, RunStore};
use fabro_types::RunId;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, oneshot};
@ -28,7 +29,7 @@ pub enum RunNoticeLevel {
pub enum WorkflowRunEvent {
WorkflowRunStarted {
name: String,
run_id: String,
run_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -344,7 +345,7 @@ impl WorkflowRunEvent {
use tracing::{debug, error, info, warn};
match self {
Self::WorkflowRunStarted { name, run_id, .. } => {
info!(workflow = name.as_str(), run_id, "Workflow run started");
info!(workflow = name.as_str(), run_id = %run_id, "Workflow run started");
}
Self::WorkflowRunCompleted {
duration_ms,
@ -804,7 +805,7 @@ pub fn flatten_event(
(event_name, fields)
}
pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &str) -> serde_json::Value {
pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &RunId) -> serde_json::Value {
let (event_name, event_fields) = flatten_event(event);
let mut envelope = serde_json::Map::new();
envelope.insert(
@ -826,7 +827,7 @@ pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &str) -> serde_jso
pub fn build_redacted_event_payload(
event: &WorkflowRunEvent,
run_id: &str,
run_id: &RunId,
) -> Result<EventPayload> {
let envelope = build_event_envelope(event, run_id);
let line = serde_json::to_string(&envelope)?;
@ -835,7 +836,11 @@ pub fn build_redacted_event_payload(
EventPayload::new(value, run_id).map_err(anyhow::Error::from)
}
pub fn append_progress_event(run_dir: &Path, run_id: &str, event: &WorkflowRunEvent) -> Result<()> {
pub fn append_progress_event(
run_dir: &Path,
run_id: &RunId,
event: &WorkflowRunEvent,
) -> Result<()> {
let envelope = build_event_envelope(event, run_id);
let line = serde_json::to_string(&envelope)?;
let line = redact_jsonl_line(&line);
@ -861,15 +866,15 @@ pub fn append_progress_event(run_dir: &Path, run_id: &str, event: &WorkflowRunEv
pub struct ProgressLogger {
run_dir: PathBuf,
run_id: String,
run_id: RunId,
}
impl ProgressLogger {
#[must_use]
pub fn new(run_dir: impl Into<PathBuf>, run_id: impl Into<String>) -> Self {
pub fn new(run_dir: impl Into<PathBuf>, run_id: RunId) -> Self {
Self {
run_dir: run_dir.into(),
run_id: run_id.into(),
run_id,
}
}
@ -897,12 +902,12 @@ enum StoreProgressCommand {
#[derive(Clone)]
pub struct StoreProgressLogger {
tx: mpsc::UnboundedSender<StoreProgressCommand>,
run_id: Arc<std::sync::Mutex<String>>,
run_id: Arc<std::sync::Mutex<RunId>>,
}
impl StoreProgressLogger {
#[must_use]
pub fn new(run_store: Arc<dyn RunStore>, run_id: impl Into<String>) -> Self {
pub fn new(run_store: Arc<dyn RunStore>, run_id: RunId) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
@ -922,7 +927,7 @@ impl StoreProgressLogger {
Self {
tx,
run_id: Arc::new(std::sync::Mutex::new(run_id.into())),
run_id: Arc::new(std::sync::Mutex::new(run_id)),
}
}
@ -1291,6 +1296,7 @@ impl EventEmitter {
mod tests {
use super::*;
use fabro_llm::types::Usage;
use fabro_types::fixtures;
use std::sync::{Arc, Mutex};
#[test]
@ -1313,7 +1319,7 @@ mod tests {
});
emitter.emit(&WorkflowRunEvent::WorkflowRunStarted {
name: "test".to_string(),
run_id: "1".to_string(),
run_id: fixtures::RUN_1,
base_branch: None,
base_sha: None,
run_branch: None,
@ -1814,7 +1820,7 @@ mod tests {
assert_eq!(emitter.last_event_at(), 0);
emitter.emit(&WorkflowRunEvent::WorkflowRunStarted {
name: "test".to_string(),
run_id: "1".to_string(),
run_id: fixtures::RUN_1,
base_branch: None,
base_sha: None,
run_branch: None,
@ -2023,7 +2029,7 @@ mod tests {
fn rename_fields_workflow_run_started() {
let event = WorkflowRunEvent::WorkflowRunStarted {
name: "my_pipeline".to_string(),
run_id: "r1".to_string(),
run_id: fixtures::RUN_1,
base_branch: None,
base_sha: None,
run_branch: None,
@ -2669,7 +2675,7 @@ mod tests {
fn workflow_run_started_with_goal_round_trip() {
let event = WorkflowRunEvent::WorkflowRunStarted {
name: "my_workflow".to_string(),
run_id: "r42".to_string(),
run_id: fixtures::RUN_42,
base_branch: None,
base_sha: None,
run_branch: None,
@ -2687,7 +2693,8 @@ mod tests {
#[test]
fn workflow_run_started_without_goal_backward_compat() {
// Old JSONL without `goal` field should deserialize to `goal: None`
let json = r#"{"WorkflowRunStarted":{"name":"old_wf","run_id":"r1"}}"#;
let json =
r#"{"WorkflowRunStarted":{"name":"old_wf","run_id":"00000000000000000000000001"}}"#;
let deserialized: WorkflowRunEvent = serde_json::from_str(json).unwrap();
assert!(matches!(
deserialized,
@ -2699,7 +2706,7 @@ mod tests {
fn workflow_run_started_goal_none_omitted_from_json() {
let event = WorkflowRunEvent::WorkflowRunStarted {
name: "wf".to_string(),
run_id: "r1".to_string(),
run_id: fixtures::RUN_1,
base_branch: None,
base_sha: None,
run_branch: None,

View file

@ -558,6 +558,7 @@ impl MetadataStore {
#[cfg(test)]
mod tests {
use super::*;
use fabro_types::fixtures;
use std::fs;
use crate::records::{CheckpointExt, RunRecordExt};
@ -654,13 +655,18 @@ mod tests {
init_repo(dir.path());
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let run_record = br#"{"run_id":"RUN1","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
store.init_run("RUN1", &[("run.json", run_record)]).unwrap();
let run_id = fixtures::RUN_1.to_string();
let run_record = format!(
r#"{{"run_id":"{run_id}","created_at":"2025-01-01T00:00:00Z","settings":{{}},"graph":{{"name":"test","nodes":{{}},"edges":[],"attrs":{{}}}},"working_directory":"/tmp"}}"#
);
store
.init_run(&run_id, &[("run.json", run_record.as_bytes())])
.unwrap();
let read_record = MetadataStore::read_run_record(dir.path(), "RUN1")
let read_record = MetadataStore::read_run_record(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(read_record.run_id, "RUN1");
assert_eq!(read_record.run_id, fixtures::RUN_1);
assert_eq!(read_record.workflow_name(), "test");
}
@ -834,27 +840,32 @@ mod tests {
init_repo(dir.path());
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let run_record = br#"{"run_id":"RUN5","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
store.init_run("RUN5", &[("run.json", run_record)]).unwrap();
let run_id = fixtures::RUN_5.to_string();
let run_record = format!(
r#"{{"run_id":"{run_id}","created_at":"2025-01-01T00:00:00Z","settings":{{}},"graph":{{"name":"test","nodes":{{}},"edges":[],"attrs":{{}}}},"working_directory":"/tmp"}}"#
);
store
.init_run(&run_id, &[("run.json", run_record.as_bytes())])
.unwrap();
store
.write_files(
"RUN5",
&run_id,
&[("retro.json", b"{\"status\":\"ok\"}")],
"finalize",
)
.unwrap();
let data = MetadataStore::read_file(dir.path(), "RUN5", "retro.json")
let data = MetadataStore::read_file(dir.path(), &run_id, "retro.json")
.unwrap()
.unwrap();
assert_eq!(data, b"{\"status\":\"ok\"}");
// Original files still present
let record = MetadataStore::read_run_record(dir.path(), "RUN5")
let record = MetadataStore::read_run_record(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(record.run_id, "RUN5");
assert_eq!(record.run_id, fixtures::RUN_5);
}
#[test]

View file

@ -5,6 +5,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_store::NodeVisitRef;
use fabro_types::RunId;
use crate::context::keys;
use crate::context::{Context, WorkflowContext};
@ -271,12 +272,16 @@ impl Handler for AgentHandler {
// 3. Call LLM backend (agent loop)
let thread_id = context.thread_id();
let run_id = context
.run_id()
.parse::<RunId>()
.map_err(|err| FabroError::handler(format!("invalid internal run_id: {err}")))?;
let tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>> =
services.hook_runner.as_ref().map(|hr| {
Arc::new(fabro_hooks::WorkflowToolHookCallback {
hook_runner: Arc::clone(hr),
sandbox: Arc::clone(&services.sandbox),
run_id: context.run_id(),
run_id,
workflow_name: graph.name.clone(),
work_dir: None,
node_id: node.id.clone(),
@ -397,17 +402,27 @@ mod tests {
use super::*;
use crate::event::EventEmitter;
use fabro_graphviz::graph::AttrValue;
use fabro_types::fixtures;
use tempfile::TempDir;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn test_context() -> Context {
let context = Context::new();
context.set(
crate::context::keys::INTERNAL_RUN_ID,
serde_json::json!(fixtures::RUN_1.to_string()),
);
context
}
#[tokio::test]
async fn codergen_handler_simulate() {
let handler = AgentHandler::new(None);
let node = Node::new("plan");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -436,7 +451,7 @@ mod tests {
"prompt".to_string(),
AttrValue::String("Achieve: $goal".to_string()),
);
let context = Context::new();
let context = test_context();
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
@ -463,7 +478,7 @@ mod tests {
"label".to_string(),
AttrValue::String("Do work".to_string()),
);
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -482,7 +497,7 @@ mod tests {
async fn codergen_handler_context_updates() {
let handler = AgentHandler::new(None);
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -515,7 +530,7 @@ mod tests {
let handler = AgentHandler::new(None);
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -573,7 +588,7 @@ mod tests {
let handler = AgentHandler::new(Some(Box::new(DirectiveBackend)));
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -632,7 +647,7 @@ mod tests {
let handler = AgentHandler::new(Some(Box::new(LastFileBackend)));
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -737,7 +752,7 @@ mod tests {
let handler = AgentHandler::new(Some(Box::new(backend)));
let node = Node::new("work");
let context = Context::new();
let context = test_context();
// Simulate what the engine stores in internal.thread_id
context.set(keys::INTERNAL_THREAD_ID, serde_json::json!("main"));
let graph = Graph::new("test");
@ -790,7 +805,7 @@ mod tests {
let handler = AgentHandler::new(Some(Box::new(backend)));
let node = Node::new("work");
let context = Context::new();
let context = test_context();
// No thread context set
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -827,7 +842,7 @@ mod tests {
let handler = AgentHandler::new(Some(Box::new(FailingBackend)));
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -971,7 +986,7 @@ Some text in between.
let handler = AgentHandler::new(Some(Box::new(ValidationFailBackend)));
let node = Node::new("step");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -1025,7 +1040,7 @@ Some text in between.
"prompt".to_string(),
AttrValue::String("Summarize the results".to_string()),
);
let context = Context::new();
let context = test_context();
context.set(
keys::CURRENT_PREAMBLE,
serde_json::json!("## Test Output\n10 passed, 0 failed"),
@ -1095,7 +1110,7 @@ Some text in between.
"prompt".to_string(),
AttrValue::String("Summarize the results".to_string()),
);
let context = Context::new();
let context = test_context();
// No preamble set -- context.get_string returns ""
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
@ -1117,7 +1132,7 @@ Some text in between.
"prompt".to_string(),
AttrValue::String("Summarize".to_string()),
);
let context = Context::new();
let context = test_context();
context.set(
keys::CURRENT_PREAMBLE,
serde_json::json!("## Script Output\nAll tests passed"),

View file

@ -8,6 +8,7 @@ use async_trait::async_trait;
use chrono::Utc;
use fabro_config::FabroSettings;
use fabro_store::{InMemoryStore, Store};
use fabro_types::RunId;
use crate::condition::evaluate_condition;
use crate::context::keys;
@ -158,7 +159,6 @@ impl Handler for SubWorkflowHandler {
let child_logs = run_dir.join(format!("nodes/{}_{visit}/child", node.id));
let _ = std::fs::create_dir_all(&child_logs);
let parent_run_id = context.run_id();
let cancel_token = Arc::new(AtomicBool::new(false));
let child_cancel = Arc::clone(&cancel_token);
@ -166,7 +166,7 @@ impl Handler for SubWorkflowHandler {
settings: fabro_config::FabroSettings::default(),
run_dir: child_logs,
cancel_token: Some(cancel_token),
run_id: format!("{parent_run_id}_child_{}", node.id),
run_id: RunId::new(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,

View file

@ -4,6 +4,7 @@ use std::time::Instant;
use async_trait::async_trait;
use fabro_agent::{Sandbox, WorktreeConfig, WorktreeSandbox};
use fabro_types::RunId;
use tokio::sync::Semaphore;
use crate::context::keys;
@ -155,11 +156,12 @@ impl Handler for ParallelHandler {
join_policy: join_policy.to_string(),
});
{
let mut hook_ctx = HookContext::new(
HookEvent::ParallelStart,
context.run_id(),
graph.name.clone(),
);
let run_id = context
.run_id()
.parse::<RunId>()
.map_err(|err| FabroError::handler(format!("invalid internal run_id: {err}")))?;
let mut hook_ctx =
HookContext::new(HookEvent::ParallelStart, run_id, graph.name.clone());
set_hook_node(&mut hook_ctx, node);
let _ = services.run_hooks(&hook_ctx).await;
}
@ -177,7 +179,7 @@ impl Handler for ParallelHandler {
let base_sha: Option<String> = if let Some(ref gs) = git_state {
let result = git_checkpoint(
&*services.sandbox,
&gs.run_id,
&gs.run_id.to_string(),
&node.id,
"parallel_base",
0,
@ -219,9 +221,12 @@ impl Handler for ParallelHandler {
);
// Compute worktree path (each sandbox type knows its own path scheme)
let wt_path_str = services
.sandbox
.parallel_worktree_path(run_dir, &gs.run_id, &node.id, branch_key);
let wt_path_str = services.sandbox.parallel_worktree_path(
run_dir,
&gs.run_id.to_string(),
&node.id,
branch_key,
);
tracing::debug!(branch = %branch_name, path = %wt_path_str, "Creating worktree for parallel branch");
// Set up worktree via WorktreeSandbox
@ -329,7 +334,9 @@ impl Handler for ParallelHandler {
// Checkpoint commit after branch execution (capture head_sha)
let head_sha = if has_git {
let rid = run_id.as_deref().unwrap_or("unknown");
let rid = run_id
.map(|run_id| run_id.to_string())
.unwrap_or_else(|| "unknown".to_string());
let nid = &setup.target_id;
let status_str = outcome.status.to_string();
// Use exec_command to commit and capture HEAD in the branch worktree
@ -482,11 +489,12 @@ impl Handler for ParallelHandler {
failure_count: fail_count,
});
{
let mut hook_ctx = HookContext::new(
HookEvent::ParallelComplete,
context.run_id(),
graph.name.clone(),
);
let run_id = context
.run_id()
.parse::<RunId>()
.map_err(|err| FabroError::handler(format!("invalid internal run_id: {err}")))?;
let mut hook_ctx =
HookContext::new(HookEvent::ParallelComplete, run_id, graph.name.clone());
set_hook_node(&mut hook_ctx, node);
let _ = services.run_hooks(&hook_ctx).await;
}
@ -574,16 +582,26 @@ fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option<String> {
mod tests {
use super::*;
use fabro_graphviz::graph::{AttrValue, Edge};
use fabro_types::fixtures;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn test_context() -> Context {
let context = Context::new();
context.set(
crate::context::keys::INTERNAL_RUN_ID,
serde_json::json!(fixtures::RUN_1.to_string()),
);
context
}
#[tokio::test]
async fn parallel_handler_no_branches() {
let services = make_services();
let node = Node::new("par");
let context = Context::new();
let context = test_context();
let graph = Graph::new("test");
let run_dir = Path::new("/tmp/test");
@ -602,7 +620,7 @@ mod tests {
"shape".to_string(),
AttrValue::String("component".to_string()),
);
let context = Context::new();
let context = test_context();
let mut graph = Graph::new("test");
graph.nodes.insert("par".to_string(), node.clone());
graph
@ -654,7 +672,7 @@ mod tests {
"join_policy".to_string(),
AttrValue::String("first_success".to_string()),
);
let context = Context::new();
let context = test_context();
let mut graph = Graph::new("test");
graph.nodes.insert("par".to_string(), node.clone());
graph
@ -696,7 +714,7 @@ mod tests {
"shape".to_string(),
AttrValue::String("component".to_string()),
);
let context = Context::new();
let context = test_context();
let mut graph = Graph::new("test");
graph.nodes.insert("par".to_string(), node.clone());
graph

View file

@ -12,6 +12,7 @@ pub(crate) fn set_hook_node(ctx: &mut HookContext, node: &GvNode) {
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_hooks::HookEvent;
use fabro_types::fixtures;
use super::*;
@ -25,7 +26,7 @@ mod tests {
node.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
let mut ctx = HookContext::new(HookEvent::StageStart, "run-1".into(), "graph".into());
let mut ctx = HookContext::new(HookEvent::StageStart, fixtures::RUN_1, "graph".into());
set_hook_node(&mut ctx, &node);
assert_eq!(ctx.node_id.as_deref(), Some("approve"));

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_store::{NodeVisitRef, RunStore};
use fabro_types::NodeStatusRecord;
use fabro_types::{NodeStatusRecord, RunId};
use fabro_core::error::Result as CoreResult;
use fabro_core::graph::NodeSpec;
@ -27,7 +27,7 @@ type WfNodeResult = NodeResult<Option<StageUsage>>;
/// Sub-lifecycle responsible for writing run state to disk (node status, checkpoints).
pub(crate) struct DiskLifecycle {
pub run_dir: PathBuf,
pub run_id: String,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub graph: Arc<GvGraph>,
pub run_options: Arc<RunOptions>,
@ -41,7 +41,7 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> {
let git_state = self.run_options.git.as_ref();
let start_record = StartRecord {
run_id: self.run_id.clone(),
run_id: self.run_id,
start_time: chrono::Utc::now(),
run_branch: git_state.and_then(|g| g.run_branch.clone()),
base_sha: git_state.and_then(|g| g.base_sha.clone()),

View file

@ -21,6 +21,7 @@ use crate::outcome::{
FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage, stage_usage_to_llm,
};
use fabro_graphviz::graph::types::Node as GvNode;
use fabro_types::RunId;
type WfRunState = RunState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
@ -37,7 +38,7 @@ fn node_script(node: &GvNode) -> Option<String> {
pub(crate) struct EventLifecycle {
pub emitter: Arc<EventEmitter>,
pub graph_name: String,
pub run_id: String,
pub run_id: RunId,
pub run_start: Mutex<Instant>,
/// Set in on_edge_selected when loop_restart approved; emitted+cleared in on_run_start.
pub restarted_from: Arc<Mutex<Option<(String, String)>>>,

View file

@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_store::RunStore;
use fabro_types::RunId;
use fabro_core::error::{CoreError, Result as CoreResult};
use fabro_core::graph::NodeSpec;
@ -37,7 +38,7 @@ pub(crate) struct GitLifecycle {
pub artifact_store: Arc<Mutex<ArtifactStore>>,
pub emitter: Arc<EventEmitter>,
pub run_dir: PathBuf,
pub run_id: String,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_options: Arc<RunOptions>,
pub start_node_id: Option<String>,
@ -97,7 +98,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
if let Some(ref data) = sandbox_json {
files.push(("sandbox.json", data));
}
if let Err(e) = store.init_run(&self.run_id, &files) {
if let Err(e) = store.init_run(&self.run_id.to_string(), &files) {
tracing::warn!(
run_id = %self.run_id,
error = %e,
@ -160,7 +161,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
match store.write_checkpoint(&self.run_id, &cp_json, &extra_refs) {
match store.write_checkpoint(&self.run_id.to_string(), &cp_json, &extra_refs) {
Ok(sha) => Some(sha),
Err(e) => {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
@ -183,7 +184,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
let git_author = self.run_options.git_author();
let commit_result = git_checkpoint(
&*self.sandbox,
&self.run_id,
&self.run_id.to_string(),
node_id,
&result.outcome.status.to_string(),
completed_count,

View file

@ -16,6 +16,7 @@ use crate::hook_context::set_hook_node;
use crate::outcome::{Outcome, OutcomeExt, StageStatus, StageUsage};
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_sandbox::Sandbox;
use fabro_types::RunId;
type WfRunState = RunState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
@ -26,7 +27,7 @@ pub(crate) struct HookLifecycle {
pub hook_runner: Option<Arc<HookRunner>>,
pub sandbox: Arc<dyn Sandbox>,
pub hook_work_dir: Option<PathBuf>,
pub run_id: String,
pub run_id: RunId,
pub graph_name: String,
}

View file

@ -16,6 +16,7 @@ use std::time::Instant;
use async_trait::async_trait;
use fabro_store::RunStore;
use fabro_store::RuntimeState;
use fabro_types::RunId;
use fabro_core::error::Result as CoreResult;
use fabro_core::graph::NodeSpec;
@ -72,7 +73,7 @@ pub(crate) struct WorkflowLifecycle {
is_initial_resume: AtomicBool,
// Config needed for context seeding
graph: Arc<GvGraph>,
run_id: String,
run_id: RunId,
working_directory: Option<String>,
}

View file

@ -6,6 +6,7 @@ use fabro_config::{FabroSettings, FabroSettingsExt};
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
use fabro_types::RunId;
use crate::error::FabroError;
use crate::pipeline::types::PersistOptions;
@ -27,7 +28,7 @@ pub struct CreateRunInput {
pub cwd: PathBuf,
pub workflow_slug: Option<String>,
pub run_dir: Option<PathBuf>,
pub run_id: Option<String>,
pub run_id: Option<RunId>,
pub host_repo_path: Option<String>,
pub base_branch: Option<String>,
}
@ -35,7 +36,7 @@ pub struct CreateRunInput {
#[derive(Debug)]
pub struct CreatedRun {
pub persisted: Persisted,
pub run_id: String,
pub run_id: RunId,
pub run_dir: PathBuf,
pub dot_path: Option<PathBuf>,
}
@ -43,7 +44,7 @@ pub struct CreatedRun {
struct PersistCreateOptions {
settings: FabroSettings,
run_dir: Option<PathBuf>,
run_id: Option<String>,
run_id: Option<RunId>,
workflow_slug: Option<String>,
labels: HashMap<String, String>,
base_branch: Option<String>,
@ -76,12 +77,12 @@ pub fn create(request: CreateRunInput) -> Result<CreatedRun, FabroError> {
} = request;
let settings = resolved.settings.clone();
let run_id = run_id.unwrap_or_else(|| ulid::Ulid::new().to_string());
let run_id = run_id.unwrap_or_else(RunId::new);
let storage_dir = settings.storage_dir();
let run_dir = run_dir.unwrap_or_else(|| {
make_run_dir(
&storage_dir.join("runs"),
&run_id,
&run_id.to_string(),
settings.dry_run_enabled(),
)
});
@ -102,7 +103,7 @@ pub fn create(request: CreateRunInput) -> Result<CreatedRun, FabroError> {
PersistCreateOptions {
settings,
run_dir: Some(run_dir.clone()),
run_id: Some(run_id.clone()),
run_id: Some(run_id),
workflow_slug: workflow_slug.or(resolved.workflow_slug.clone()),
labels: resolved.settings.labels.clone(),
base_branch,
@ -230,8 +231,9 @@ fn persist_validated(
let settings = resolve_run_settings(settings, validated.graph());
let run_id = run_id.unwrap_or_else(|| ulid::Ulid::new().to_string());
let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id, settings.dry_run_enabled()));
let run_id = run_id.unwrap_or_else(RunId::new);
let run_dir =
run_dir.unwrap_or_else(|| default_run_dir(&run_id.to_string(), settings.dry_run_enabled()));
let run_record = RunRecord {
run_id,
@ -319,6 +321,7 @@ pub(crate) fn make_run_dir(runs_base: &Path, run_id: &str, dry_run: bool) -> Pat
mod tests {
use super::*;
use fabro_graphviz::graph::AttrValue;
use fabro_types::fixtures;
use crate::operations::{ValidateInput, validate};
use crate::run_status::RunStatusRecordExt;
@ -564,13 +567,13 @@ mod tests {
cwd: dir.path().to_path_buf(),
workflow_slug: Some("slug".to_string()),
run_dir: Some(dir.path().join("run")),
run_id: Some("run-123".to_string()),
run_id: Some(fixtures::RUN_1),
host_repo_path: Some(dir.path().display().to_string()),
base_branch: Some("main".to_string()),
})
.unwrap();
assert_eq!(created.run_id, "run-123");
assert_eq!(created.run_id, fixtures::RUN_1);
assert_eq!(created.persisted.run_record().graph.goal(), "override goal");
assert_eq!(
created
@ -670,7 +673,7 @@ mod tests {
cwd: dir.path().to_path_buf(),
workflow_slug: None,
run_dir: Some(dir.path().join("run")),
run_id: Some("run-cwd".to_string()),
run_id: Some(fixtures::RUN_2),
host_repo_path: None,
base_branch: None,
})

View file

@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use fabro_git_storage::branchstore::BranchStore;
use fabro_git_storage::gitobj::Store;
use fabro_types::RunId;
use git2::{Oid, Signature};
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
@ -12,7 +13,7 @@ use super::rewind::{RewindTarget, TimelineEntry, build_timeline};
#[derive(Debug, Clone)]
pub struct ForkRunInput {
pub source_run_id: String,
pub source_run_id: RunId,
pub target: Option<RewindTarget>,
pub push: bool,
}
@ -20,8 +21,8 @@ pub struct ForkRunInput {
/// Create a new run that branches from an existing run at a specific checkpoint.
///
/// Returns the new run ID.
pub fn fork(store: &Store, input: &ForkRunInput) -> Result<String> {
let timeline = build_timeline(store, &input.source_run_id)?;
pub fn fork(store: &Store, input: &ForkRunInput) -> Result<RunId> {
let timeline = build_timeline(store, &input.source_run_id.to_string())?;
let entry = match input.target.as_ref() {
Some(target) => timeline.resolve(target)?,
None => timeline.entries.last().ok_or_else(|| {
@ -33,11 +34,11 @@ pub fn fork(store: &Store, input: &ForkRunInput) -> Result<String> {
fn fork_from_entry(
store: &Store,
source_run_id: &str,
source_run_id: &RunId,
entry: &TimelineEntry,
push: bool,
) -> Result<String> {
let new_run_id = ulid::Ulid::new().to_string();
) -> Result<RunId> {
let new_run_id = RunId::new();
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
let new_run_branch = format!("{RUN_BRANCH_PREFIX}{new_run_id}");
@ -57,8 +58,8 @@ fn fork_from_entry(
}
}
let source_meta_branch = MetadataStore::branch_name(source_run_id);
let new_meta_branch = MetadataStore::branch_name(&new_run_id);
let source_meta_branch = MetadataStore::branch_name(&source_run_id.to_string());
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
let source_bs = BranchStore::new(store, &source_meta_branch, &sig);
let new_bs = BranchStore::new(store, &new_meta_branch, &sig);
@ -88,14 +89,14 @@ fn fork_from_entry(
let mut run_record: RunRecord =
serde_json::from_slice(&run_record_bytes).context("failed to parse source run.json")?;
run_record.run_id.clone_from(&new_run_id);
run_record.run_id = new_run_id;
run_record.created_at = now;
let new_run_record_bytes =
serde_json::to_vec_pretty(&run_record).context("failed to serialize new run.json")?;
let new_start_record_bytes = if start_record_bytes.is_some() {
let start_record = StartRecord {
run_id: new_run_id.clone(),
run_id: new_run_id,
start_time: now,
run_branch: Some(new_run_branch.clone()),
base_sha: None,
@ -160,13 +161,18 @@ mod tests {
use super::super::test_support::*;
use super::*;
use fabro_types::RunId;
use git2::Oid;
use crate::operations::find_run_id_by_prefix;
fn make_run_record_json(run_id: &str) -> Vec<u8> {
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn make_run_record_json(run_id: &RunId) -> Vec<u8> {
let record = serde_json::json!({
"run_id": run_id,
"run_id": run_id.to_string(),
"created_at": "2025-01-01T00:00:00Z",
"settings": {},
"graph": {
@ -187,16 +193,16 @@ mod tests {
serde_json::to_vec_pretty(&record).unwrap()
}
fn make_start_record_json(run_id: &str) -> Vec<u8> {
fn make_start_record_json(run_id: &RunId) -> Vec<u8> {
let record = serde_json::json!({
"run_id": run_id,
"run_id": run_id.to_string(),
"start_time": "2025-01-01T00:00:00Z",
"run_branch": format!("{}{}", RUN_BRANCH_PREFIX, run_id),
});
serde_json::to_vec_pretty(&record).unwrap()
}
fn setup_source_run(store: &Store, run_id: &str, nodes: &[&str]) -> Vec<Oid> {
fn setup_source_run(store: &Store, run_id: &RunId, nodes: &[&str]) -> Vec<Oid> {
let sig = test_sig();
let run_branch = format!("{}{run_id}", RUN_BRANCH_PREFIX);
@ -222,7 +228,7 @@ mod tests {
parent = Some(oid);
}
let meta_branch = MetadataStore::branch_name(run_id);
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
@ -246,13 +252,13 @@ mod tests {
#[test]
fn fork_creates_new_run_and_metadata_branches() {
let (_dir, store) = temp_repo();
let source_run_id = "run-source";
let run_oids = setup_source_run(&store, source_run_id, &["start", "build", "test"]);
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let run_oids = setup_source_run(&store, &source_run_id, &["start", "build", "test"]);
let new_run_id = fork(
&store,
&ForkRunInput {
source_run_id: source_run_id.to_string(),
source_run_id,
target: Some(RewindTarget::from_str("@2").unwrap()),
push: false,
},
@ -260,7 +266,7 @@ mod tests {
.unwrap();
let new_run_branch = format!("{}{new_run_id}", RUN_BRANCH_PREFIX);
let new_meta_branch = MetadataStore::branch_name(&new_run_id);
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
assert!(store.resolve_ref(&new_run_branch).unwrap().is_some());
assert!(store.resolve_ref(&new_meta_branch).unwrap().is_some());
@ -271,7 +277,7 @@ mod tests {
let run_record: RunRecord = serde_json::from_slice(&run_json).unwrap();
assert_eq!(run_record.run_id, new_run_id);
let timeline = build_timeline(&store, &new_run_id).unwrap();
let timeline = build_timeline(&store, &new_run_id.to_string()).unwrap();
assert_eq!(timeline.entries.len(), 1);
assert_eq!(timeline.entries[0].node_name, "build");
assert_eq!(
@ -284,11 +290,11 @@ mod tests {
fn fork_rejects_checkpoint_without_run_sha() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let run_id = "run-no-sha";
let meta_branch = MetadataStore::branch_name(run_id);
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW");
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
bs.write_entry("run.json", &make_run_record_json(run_id), "init")
bs.write_entry("run.json", &make_run_record_json(&run_id), "init")
.unwrap();
let cp = make_checkpoint_json("start", 1, None);
@ -303,7 +309,7 @@ mod tests {
run_commit_sha: None,
};
let err = fork_from_entry(&store, run_id, &entry, false)
let err = fork_from_entry(&store, &run_id, &entry, false)
.unwrap_err()
.to_string();
assert!(err.contains("cannot fork"));
@ -312,10 +318,10 @@ mod tests {
#[test]
fn fork_supports_prefix_resolved_source_run_ids() {
let (_dir, store) = temp_repo();
let source_run_id = "abc-123-long";
setup_source_run(&store, source_run_id, &["start", "build"]);
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAX");
setup_source_run(&store, &source_run_id, &["start", "build"]);
let resolved = find_run_id_by_prefix(store.repo(), "abc-123").unwrap();
let resolved = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(resolved, source_run_id);
}
}

View file

@ -95,7 +95,7 @@ pub async fn open_or_hydrate_run(
async fn hydrate_events(
run_dir: &Path,
run_id: &str,
run_id: &fabro_types::RunId,
run_store: &dyn RunStore,
) -> Result<(), FabroError> {
let progress_path = run_dir.join("progress.jsonl");
@ -224,16 +224,20 @@ mod tests {
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_types::{Conclusion, RunStatus, RunStatusRecord, StageStatus};
use fabro_types::{Conclusion, RunStatus, RunStatusRecord, StageStatus, fixtures};
use super::open_or_hydrate_run;
use crate::event::{WorkflowRunEvent, append_progress_event};
use crate::records::{Checkpoint, CheckpointExt, ConclusionExt, RunRecord, RunRecordExt};
use crate::run_status::RunStatusRecordExt;
fn test_run_id() -> fabro_types::RunId {
fixtures::RUN_1
}
fn write_run(run_dir: &Path) {
let record = RunRecord {
run_id: "run-123".to_string(),
run_id: test_run_id(),
created_at: Utc::now(),
settings: FabroSettings::default(),
graph: Graph::new("test"),
@ -284,7 +288,7 @@ mod tests {
conclusion.save(&run_dir.join("conclusion.json")).unwrap();
append_progress_event(
run_dir,
"run-123",
&test_run_id(),
&WorkflowRunEvent::RunNotice {
level: crate::event::RunNoticeLevel::Info,
code: "hydrated".to_string(),
@ -305,7 +309,7 @@ mod tests {
assert_eq!(
run_store.get_run().await.unwrap().unwrap().run_id,
"run-123"
test_run_id()
);
assert!(run_store.get_checkpoint().await.unwrap().is_some());
assert!(run_store.get_conclusion().await.unwrap().is_some());

View file

@ -8,6 +8,7 @@ use fabro_git_storage::gitobj::Store as GitStore;
use fabro_store::{
ListRunsQuery, NodeVisitRef, RunStore as DurableRunStore, Store as DurableStore,
};
use fabro_types::RunId;
use git2::{Repository, Signature};
use ulid::Ulid;
@ -18,9 +19,9 @@ use crate::records::Checkpoint;
pub async fn rebuild_metadata_branch(
git_store: &GitStore,
run_store: &dyn DurableRunStore,
run_id: &str,
run_id: &RunId,
) -> Result<()> {
let branch = MetadataStore::branch_name(run_id);
let branch = MetadataStore::branch_name(&run_id.to_string());
if git_store.resolve_ref(&branch)?.is_some() {
bail!("metadata branch already exists for run {run_id}");
}
@ -121,16 +122,16 @@ pub async fn rebuild_metadata_branch(
pub async fn build_timeline_or_rebuild(
git_store: &GitStore,
run_store: Option<&dyn DurableRunStore>,
run_id: &str,
run_id: &RunId,
) -> Result<RunTimeline> {
let branch = MetadataStore::branch_name(run_id);
let branch = MetadataStore::branch_name(&run_id.to_string());
if git_store.resolve_ref(&branch)?.is_some() {
return build_timeline(git_store, run_id);
return build_timeline(git_store, &run_id.to_string());
}
if let Some(run_store) = run_store {
rebuild_metadata_branch(git_store, run_store, run_id).await?;
return build_timeline(git_store, run_id);
return build_timeline(git_store, &run_id.to_string());
}
Ok(RunTimeline {
@ -143,7 +144,7 @@ pub async fn find_run_id_by_prefix_or_store(
repo: &Repository,
fabro_store: &dyn DurableStore,
prefix: &str,
) -> Result<String> {
) -> Result<RunId> {
if let Some(run_id) = find_run_id_by_prefix_in_refs(repo, prefix)? {
return Ok(run_id);
}
@ -151,7 +152,7 @@ pub async fn find_run_id_by_prefix_or_store(
let current_repo_root = canonical_repo_root(repo)?;
let mut matches = Vec::new();
for summary in fabro_store.list_runs(&ListRunsQuery::default()).await? {
if summary.run_id == prefix {
if summary.run_id.to_string() == prefix {
if summary.host_repo_path.is_none() {
return Ok(summary.run_id);
}
@ -180,7 +181,7 @@ pub async fn find_run_id_by_prefix_or_store(
let Ok(host_repo_root) = canonical_repo_root(&host_repo) else {
continue;
};
if host_repo_root == current_repo_root && summary.run_id.starts_with(prefix) {
if host_repo_root == current_repo_root && summary.run_id.to_string().starts_with(prefix) {
matches.push(summary.run_id);
}
}
@ -203,7 +204,7 @@ fn write_entries(
fn backfill_missing_checkpoint_shas(
git_store: &GitStore,
run_id: &str,
run_id: &RunId,
checkpoints: &mut [(u32, Checkpoint)],
) {
if !checkpoints
@ -213,7 +214,7 @@ fn backfill_missing_checkpoint_shas(
return;
}
let node_commits = rewind::run_commit_shas_by_node(git_store, run_id);
let node_commits = rewind::run_commit_shas_by_node(git_store, &run_id.to_string());
let mut node_indices: HashMap<String, usize> = HashMap::new();
for (_seq, checkpoint) in checkpoints.iter_mut() {
@ -241,7 +242,7 @@ fn node_file_path(node_id: &str, visit: u32, filename: &str) -> String {
}
}
fn find_run_id_by_prefix_in_refs(repo: &Repository, prefix: &str) -> Result<Option<String>> {
fn find_run_id_by_prefix_in_refs(repo: &Repository, prefix: &str) -> Result<Option<RunId>> {
let refs = repo.references()?;
let pattern = "refs/heads/fabro/meta/";
let mut matches = Vec::new();
@ -253,12 +254,15 @@ fn find_run_id_by_prefix_in_refs(repo: &Repository, prefix: &str) -> Result<Opti
let Some(run_id) = name.strip_prefix(pattern) else {
continue;
};
let Ok(run_id) = run_id.parse::<RunId>() else {
continue;
};
if run_id == prefix {
return Ok(Some(run_id.to_string()));
if run_id.to_string() == prefix {
return Ok(Some(run_id));
}
if run_id.starts_with(prefix) {
matches.push(run_id.to_string());
if run_id.to_string().starts_with(prefix) {
matches.push(run_id);
}
}
@ -278,7 +282,7 @@ fn canonical_repo_root(repo: &Repository) -> Result<PathBuf> {
.with_context(|| format!("failed to canonicalize repo root {}", root.display()))
}
fn resolve_prefix_matches(prefix: &str, matches: Vec<String>) -> Result<String> {
fn resolve_prefix_matches(prefix: &str, matches: Vec<RunId>) -> Result<RunId> {
match matches.len() {
0 => bail!("no run found matching '{prefix}'"),
1 => Ok(matches.into_iter().next().unwrap()),
@ -302,7 +306,9 @@ mod tests {
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store as _};
use fabro_types::{NodeStatusRecord, RunRecord, SandboxRecord, StageStatus, StartRecord};
use fabro_types::{
NodeStatusRecord, RunId, RunRecord, SandboxRecord, StageStatus, StartRecord, fixtures,
};
use super::*;
use crate::operations::test_support::{make_checkpoint_json, temp_repo, test_sig};
@ -312,9 +318,17 @@ mod tests {
Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap()
}
fn sample_run_record(run_id: &str, host_repo_path: Option<&str>) -> RunRecord {
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn test_run_id() -> RunId {
fixtures::RUN_1
}
fn sample_run_record(run_id: RunId, host_repo_path: Option<&str>) -> RunRecord {
RunRecord {
run_id: run_id.to_string(),
run_id,
created_at: created_at(),
settings: FabroSettings::default(),
graph: Graph::new("test"),
@ -326,9 +340,9 @@ mod tests {
}
}
fn sample_start_record(run_id: &str) -> StartRecord {
fn sample_start_record(run_id: RunId) -> StartRecord {
StartRecord {
run_id: run_id.to_string(),
run_id,
start_time: created_at(),
run_branch: Some(format!("fabro/run/{run_id}")),
base_sha: Some("base-sha".to_string()),
@ -384,10 +398,10 @@ mod tests {
async fn create_run_store(
store: &InMemoryStore,
run_id: &str,
run_id: RunId,
host_repo_path: Option<&str>,
) -> Arc<dyn DurableRunStore> {
let run_store = store.create_run(run_id, created_at(), None).await.unwrap();
let run_store = store.create_run(&run_id, created_at(), None).await.unwrap();
run_store
.put_run(&sample_run_record(run_id, host_repo_path))
.await
@ -395,7 +409,7 @@ mod tests {
run_store
}
fn seed_run_branch(git_store: &GitStore, run_id: &str, nodes: &[&str]) -> Vec<String> {
fn seed_run_branch(git_store: &GitStore, run_id: RunId, nodes: &[&str]) -> Vec<String> {
let sig = test_sig();
let run_branch = format!("fabro/run/{run_id}");
let empty_tree = git_store.write_empty_tree().unwrap();
@ -424,9 +438,9 @@ mod tests {
async fn rebuild_metadata_branch_round_trips_timeline() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.put_start(&sample_start_record("run-1"))
.put_start(&sample_start_record(test_run_id()))
.await
.unwrap();
run_store
@ -462,11 +476,11 @@ mod tests {
.await
.unwrap();
rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap();
let timeline = build_timeline(&git_store, "run-1").unwrap();
let timeline = build_timeline(&git_store, &test_run_id().to_string()).unwrap();
assert_eq!(timeline.entries.len(), 3);
assert_eq!(timeline.entries[0].node_name, "start");
assert_eq!(timeline.entries[0].visit, 1);
@ -483,7 +497,7 @@ mod tests {
async fn rebuild_metadata_branch_preserves_historical_node_visits() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let build_v1 = NodeVisitRef {
node_id: "build",
@ -526,12 +540,12 @@ mod tests {
.await
.unwrap();
rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap();
let sig = test_sig();
let branch = MetadataStore::branch_name("run-1");
let branch = MetadataStore::branch_name(&test_run_id().to_string());
let bs = BranchStore::new(&git_store, &branch, &sig);
let checkpoint_commits: Vec<_> = bs
.log(100)
@ -576,15 +590,15 @@ mod tests {
async fn rebuild_metadata_branch_refuses_to_overwrite_existing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let sig = test_sig();
let branch = MetadataStore::branch_name("run-1");
let branch = MetadataStore::branch_name(&test_run_id().to_string());
let bs = BranchStore::new(&git_store, &branch, &sig);
bs.ensure_branch().unwrap();
bs.write_entry("run.json", b"{}", "init run").unwrap();
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("metadata branch already exists"));
@ -594,7 +608,7 @@ mod tests {
async fn build_timeline_or_rebuild_rebuilds_missing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
@ -605,9 +619,10 @@ mod tests {
.await
.unwrap();
let timeline = build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), "run-1")
.await
.unwrap();
let timeline =
build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), &test_run_id())
.await
.unwrap();
assert_eq!(timeline.entries.len(), 1);
assert_eq!(timeline.entries[0].node_name, "start");
@ -617,7 +632,7 @@ mod tests {
async fn build_timeline_or_rebuild_preserves_existing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
@ -647,7 +662,7 @@ mod tests {
.unwrap();
let sig = test_sig();
let branch = MetadataStore::branch_name("run-1");
let branch = MetadataStore::branch_name(&test_run_id().to_string());
let bs = BranchStore::new(&git_store, &branch, &sig);
bs.ensure_branch().unwrap();
bs.write_entry("run.json", b"{}", "init run").unwrap();
@ -664,9 +679,10 @@ mod tests {
)
.unwrap();
let timeline = build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), "run-1")
.await
.unwrap();
let timeline =
build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), &test_run_id())
.await
.unwrap();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.entries[0].node_name, "start");
@ -676,7 +692,7 @@ mod tests {
#[tokio::test]
async fn build_timeline_or_rebuild_returns_empty_without_store() {
let (_dir, git_store) = temp_repo();
let timeline = build_timeline_or_rebuild(&git_store, None, "run-1")
let timeline = build_timeline_or_rebuild(&git_store, None, &test_run_id())
.await
.unwrap();
assert!(timeline.entries.is_empty());
@ -688,11 +704,11 @@ mod tests {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = durable_store
.create_run("run-1", created_at(), None)
.create_run(&test_run_id(), created_at(), None)
.await
.unwrap();
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("run record not found"));
@ -703,13 +719,15 @@ mod tests {
let (dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let repo_path = dir.path().to_string_lossy().to_string();
let _run_store = create_run_store(&durable_store, "abc-123-long", Some(&repo_path)).await;
let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store = create_run_store(&durable_store, repo_run_id, Some(&repo_path)).await;
let prefix = &repo_run_id.to_string()[..6];
let run_id = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123")
let run_id = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix)
.await
.unwrap();
assert_eq!(run_id, "abc-123-long");
assert_eq!(run_id, repo_run_id);
}
#[tokio::test]
@ -718,10 +736,12 @@ mod tests {
let (other_dir, _other_git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let other_repo_path = other_dir.path().to_string_lossy().to_string();
let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store =
create_run_store(&durable_store, "abc-123-long", Some(&other_repo_path)).await;
create_run_store(&durable_store, other_run_id, Some(&other_repo_path)).await;
let prefix = &other_run_id.to_string()[..6];
let err = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123")
let err = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix)
.await
.unwrap_err();
@ -732,19 +752,23 @@ mod tests {
async fn find_run_id_by_prefix_or_store_requires_exact_match_without_repo_path() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let _run_store = create_run_store(&durable_store, "abc-123-long", None).await;
let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store = create_run_store(&durable_store, repo_run_id, None).await;
let prefix = &repo_run_id.to_string()[..6];
let prefix_err =
find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123")
.await
.unwrap_err();
let prefix_err = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix)
.await
.unwrap_err();
assert!(prefix_err.to_string().contains("no run found matching"));
let exact =
find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123-long")
.await
.unwrap();
assert_eq!(exact, "abc-123-long");
let exact = find_run_id_by_prefix_or_store(
git_store.repo(),
&durable_store,
&repo_run_id.to_string(),
)
.await
.unwrap();
assert_eq!(exact, repo_run_id);
}
#[tokio::test]
@ -752,35 +776,50 @@ mod tests {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let repo_path = git_store.repo_dir().to_string_lossy().to_string();
let _short = create_run_store(&durable_store, "abc-123", Some(&repo_path)).await;
let _long = create_run_store(&durable_store, "abc-123-long", Some(&repo_path)).await;
let exact_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW");
let _exact = create_run_store(&durable_store, exact_run_id, Some(&repo_path)).await;
let _other = create_run_store(&durable_store, other_run_id, Some(&repo_path)).await;
let from_store =
find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123")
.await
.unwrap();
assert_eq!(from_store, "abc-123");
let from_store = find_run_id_by_prefix_or_store(
git_store.repo(),
&durable_store,
&exact_run_id.to_string(),
)
.await
.unwrap();
assert_eq!(from_store, exact_run_id);
let sig = test_sig();
let short_branch =
BranchStore::new(&git_store, MetadataStore::branch_name("abc-123"), &sig);
short_branch.ensure_branch().unwrap();
let exact_branch = BranchStore::new(
&git_store,
&MetadataStore::branch_name(&exact_run_id.to_string()),
&sig,
);
exact_branch.ensure_branch().unwrap();
let long_branch =
BranchStore::new(&git_store, MetadataStore::branch_name("abc-123-long"), &sig);
long_branch.ensure_branch().unwrap();
let other_branch = BranchStore::new(
&git_store,
&MetadataStore::branch_name(&other_run_id.to_string()),
&sig,
);
other_branch.ensure_branch().unwrap();
let from_refs = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, "abc-123")
.await
.unwrap();
assert_eq!(from_refs, "abc-123");
let from_refs = find_run_id_by_prefix_or_store(
git_store.repo(),
&durable_store,
&exact_run_id.to_string(),
)
.await
.unwrap();
assert_eq!(from_refs, exact_run_id);
}
#[tokio::test]
async fn rebuild_metadata_branch_persists_backfilled_run_shas_in_checkpoint_blobs() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
@ -801,14 +840,14 @@ mod tests {
.await
.unwrap();
let expected_shas = seed_run_branch(&git_store, "run-1", &["start", "build"]);
let expected_shas = seed_run_branch(&git_store, test_run_id(), &["start", "build"]);
rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap();
let sig = test_sig();
let branch = MetadataStore::branch_name("run-1");
let branch = MetadataStore::branch_name(&test_run_id().to_string());
let bs = BranchStore::new(&git_store, &branch, &sig);
let checkpoint_commits: Vec<_> = bs
.log(100)
@ -848,7 +887,7 @@ mod tests {
async fn rebuild_metadata_branch_is_atomic_on_failure() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let run_store = create_run_store(&durable_store, "run-1", None).await;
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let bad_node = "bad\0node";
let bad_visit = NodeVisitRef {
@ -869,13 +908,13 @@ mod tests {
.await
.unwrap();
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), "run-1")
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("nul") || err.to_string().contains("NUL"));
assert!(
git_store
.resolve_ref(&MetadataStore::branch_name("run-1"))
.resolve_ref(&MetadataStore::branch_name(&test_run_id().to_string()))
.unwrap()
.is_none()
);
@ -886,7 +925,9 @@ mod tests {
.unwrap()
.flatten()
.filter_map(|reference| reference.name().map(ToOwned::to_owned))
.filter(|name| name.starts_with("refs/heads/fabro/meta-rebuild/run-1/"))
.filter(|name| {
name.starts_with(&format!("refs/heads/fabro/meta-rebuild/{}/", test_run_id()))
})
.collect();
assert!(
scratch_refs.is_empty(),

View file

@ -5,6 +5,7 @@ use std::str::FromStr;
use anyhow::{Context, Result, bail};
use fabro_git_storage::branchstore::{BranchStore, CommitInfo};
use fabro_git_storage::gitobj::Store;
use fabro_types::RunId;
use git2::{Oid, Repository, Signature};
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
@ -113,7 +114,7 @@ impl RunTimeline {
#[derive(Debug, Clone)]
pub struct RewindInput {
pub run_id: String,
pub run_id: RunId,
pub target: RewindTarget,
pub push: bool,
}
@ -248,14 +249,14 @@ fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
}
pub fn rewind(store: &Store, input: &RewindInput) -> Result<()> {
let timeline = build_timeline(store, &input.run_id)?;
let timeline = build_timeline(store, &input.run_id.to_string())?;
let entry = timeline.resolve(&input.target)?;
rewind_to_entry(store, &input.run_id, entry, input.push)
}
#[allow(clippy::print_stderr)]
fn rewind_to_entry(store: &Store, run_id: &str, entry: &TimelineEntry, push: bool) -> Result<()> {
let meta_branch = MetadataStore::branch_name(run_id);
fn rewind_to_entry(store: &Store, run_id: &RunId, entry: &TimelineEntry, push: bool) -> Result<()> {
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
store
.update_ref(&meta_branch, entry.metadata_commit_oid)
.map_err(|e| anyhow::anyhow!("failed to update metadata ref: {e}"))?;
@ -304,7 +305,7 @@ fn rewind_to_entry(store: &Store, run_id: &str, entry: &TimelineEntry, push: boo
Ok(())
}
pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<String> {
pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<RunId> {
let refs = repo.references()?;
let pattern = "refs/heads/fabro/meta/";
let mut matches = Vec::new();
@ -314,11 +315,14 @@ pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<String>
continue;
};
if let Some(run_id) = name.strip_prefix(pattern) {
if run_id == prefix {
return Ok(run_id.to_string());
let Ok(run_id) = run_id.parse::<RunId>() else {
continue;
};
if run_id.to_string() == prefix {
return Ok(run_id);
}
if run_id.starts_with(prefix) {
matches.push(run_id.to_string());
if run_id.to_string().starts_with(prefix) {
matches.push(run_id);
}
}
}
@ -367,6 +371,11 @@ fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
mod tests {
use super::super::test_support::*;
use super::*;
use fabro_types::{RunId, fixtures};
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
#[test]
fn parse_target_ordinal() {
@ -485,7 +494,7 @@ mod tests {
fn rewind_moves_metadata_ref() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let branch = MetadataStore::branch_name("run-1");
let branch = MetadataStore::branch_name(&fixtures::RUN_1.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
@ -501,7 +510,7 @@ mod tests {
rewind(
&store,
&RewindInput {
run_id: "run-1".to_string(),
run_id: fixtures::RUN_1,
target: RewindTarget::Ordinal(1),
push: false,
},
@ -516,11 +525,12 @@ mod tests {
fn find_run_id_prefix_match() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let branch = MetadataStore::branch_name("abc-123-long-id");
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
let result = find_run_id_by_prefix(store.repo(), "abc-123").unwrap();
assert_eq!(result, "abc-123-long-id");
let result = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(result, run_id);
}
}

View file

@ -12,6 +12,7 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::{SandboxProvider, SandboxSpec, detect_clone_params};
use fabro_store::{DiskProjectingRunStore, ProjectionError, RunStore};
use fabro_types::RunId;
use serde::Serialize;
use crate::context::Context;
@ -672,7 +673,7 @@ const POSTRUN_CANCELLED_MESSAGE: &str = "Run cancelled before post-run finalizat
struct DetachedRunCompletionGuard {
run_dir: PathBuf,
run_store: Arc<dyn RunStore>,
run_id: Option<String>,
run_id: Option<RunId>,
cancel_token: Option<Arc<AtomicBool>>,
active: bool,
}
@ -780,16 +781,16 @@ impl Drop for DetachedRunCompletionGuard {
}
}
fn load_run_id(run_dir: &Path) -> Option<String> {
fn load_run_id(run_dir: &Path) -> Option<RunId> {
RunRecord::load(run_dir)
.ok()
.map(|record| record.run_id)
.filter(|run_id| !run_id.trim().is_empty())
.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())
})
}
@ -909,6 +910,7 @@ mod tests {
use chrono::Utc;
use fabro_config::FabroSettings;
use fabro_store::InMemoryStore;
use fabro_types::fixtures;
use super::*;
use crate::context::Context;
@ -942,7 +944,7 @@ mod tests {
.to_path_buf(),
workflow_slug: Some("test".to_string()),
run_dir: Some(run_dir.to_path_buf()),
run_id: Some("run-test".to_string()),
run_id: Some(fixtures::RUN_1),
host_repo_path: None,
base_branch: None,
})

View file

@ -13,6 +13,7 @@ use fabro_hooks::HookConfig;
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::InMemoryStore;
use fabro_types::{RunId, fixtures};
use super::*;
use crate::context::{self, Context};
@ -67,11 +68,18 @@ fn make_registry() -> HandlerRegistry {
registry
}
fn test_run_id(label: &str) -> RunId {
match label {
"git-cp-test" => fixtures::RUN_2,
_ => fixtures::RUN_1,
}
}
fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions {
RunOptions {
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: run_id.into(),
run_id: test_run_id(run_id),
settings: FabroSettings::default(),
git: None,
host_repo_path: None,
@ -106,14 +114,14 @@ fn simple_validated_graph() -> (Graph, String) {
(graph, source)
}
fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: &str) -> Persisted {
fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunId) -> Persisted {
Persisted::new(
graph.clone(),
source,
vec![],
run_dir.to_path_buf(),
RunRecord {
run_id: run_id.to_string(),
run_id,
created_at: Utc::now(),
settings: FabroSettings::default(),
graph,
@ -139,10 +147,10 @@ fn test_lifecycle(setup_commands: Vec<String>) -> LifecycleOptions {
}
}
async fn test_run_store(_run_dir: &Path) -> Arc<dyn fabro_store::RunStore> {
async fn test_run_store(_run_dir: &Path, run_id: &RunId) -> Arc<dyn fabro_store::RunStore> {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
store
.create_run("test-run", chrono::Utc::now(), None)
.create_run(run_id, chrono::Utc::now(), None)
.await
.unwrap()
}
@ -154,10 +162,10 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
std::fs::create_dir_all(&run_dir).unwrap();
let (graph, source) = simple_validated_graph();
let initialized = initialize(
persisted_workflow(graph, source, &run_dir, "run-test"),
persisted_workflow(graph, source, &run_dir, test_run_id("run-test")),
InitOptions {
run_id: "run-test".to_string(),
run_store: test_run_store(&run_dir).await,
run_id: test_run_id("run-test"),
run_store: test_run_store(&run_dir, &test_run_id("run-test")).await,
dry_run: false,
emitter: Arc::new(crate::event::EventEmitter::new()),
sandbox: SandboxSpec::Local {
@ -205,7 +213,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
executed
.final_context
.get(crate::context::keys::INTERNAL_RUN_ID),
Some(serde_json::json!("run-test"))
Some(serde_json::json!(test_run_id("run-test").to_string()))
);
}
@ -221,10 +229,10 @@ async fn run_with_lifecycle(
let run_dir = run_options.run_dir.clone();
let run_id = run_options.run_id.clone();
let initialized = initialize(
persisted_workflow(graph.clone(), String::new(), &run_dir, &run_id),
persisted_workflow(graph.clone(), String::new(), &run_dir, run_id),
InitOptions {
run_id,
run_store: test_run_store(&run_dir).await,
run_store: test_run_store(&run_dir, &run_id).await,
dry_run: false,
emitter,
sandbox: SandboxSpec::Local {
@ -551,7 +559,7 @@ async fn execute_writes_start_json_and_node_status() {
let mut run_options = test_run_options(dir.path(), "test-run");
run_options.git = Some(GitCheckpointOptions {
base_sha: Some("abc123".into()),
run_branch: Some("fabro/run/test-run".into()),
run_branch: Some(format!("fabro/run/{}", test_run_id("test-run"))),
meta_branch: None,
});
@ -566,8 +574,11 @@ async fn execute_writes_start_json_and_node_status() {
.unwrap();
let start = crate::records::StartRecord::load(dir.path()).unwrap();
assert_eq!(start.run_id, "test-run");
assert_eq!(start.run_branch.as_deref(), Some("fabro/run/test-run"));
assert_eq!(start.run_id, test_run_id("test-run"));
assert_eq!(
start.run_branch.as_deref(),
Some(format!("fabro/run/{}", test_run_id("test-run")).as_str())
);
assert_eq!(start.base_sha.as_deref(), Some("abc123"));
let status_path = dir.path().join("nodes").join("start").join("status.json");
@ -964,7 +975,9 @@ async fn git_checkpoint_skips_start_node() {
run_options.git = Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: None,
meta_branch: Some(crate::git::MetadataStore::branch_name("git-cp-test")),
meta_branch: Some(crate::git::MetadataStore::branch_name(
&test_run_id("git-cp-test").to_string(),
)),
});
run_options.host_repo_path = Some(repo.to_path_buf());

View file

@ -291,7 +291,7 @@ pub async fn write_finalize_commit(
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
if let Err(e) = store.write_files(&run_options.run_id, &refs, "finalize run") {
if let Err(e) = store.write_files(&run_options.run_id.to_string(), &refs, "finalize run") {
tracing::warn!(error = %e, "Failed to write finalize commit to metadata branch");
return;
}
@ -320,13 +320,13 @@ async fn run_hooks(
async fn cleanup_sandbox(
hook_runner: Option<Arc<HookRunner>>,
sandbox: Arc<dyn fabro_agent::Sandbox>,
run_id: &str,
run_id: &fabro_types::RunId,
workflow_name: &str,
preserve: bool,
) -> std::result::Result<(), String> {
let hook_ctx = HookContext::new(
HookEvent::SandboxCleanup,
run_id.to_string(),
*run_id,
workflow_name.to_string(),
);
run_hooks(hook_runner.as_deref(), &hook_ctx, Arc::clone(&sandbox)).await;
@ -441,17 +441,22 @@ mod tests {
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_types::{RunId, fixtures};
use super::*;
use crate::pipeline::types::Retroed;
use crate::run_options::RunOptions;
fn test_run_id() -> RunId {
fixtures::RUN_1
}
fn test_run_options(run_dir: &std::path::Path) -> RunOptions {
RunOptions {
settings: FabroSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: "run-test".to_string(),
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
@ -469,7 +474,7 @@ mod tests {
std::fs::create_dir_all(&run_dir).unwrap();
let inner_store = InMemoryStore::default()
.create_run(
"run-test",
&test_run_id(),
Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)
@ -496,7 +501,7 @@ mod tests {
retroed,
&FinalizeOptions {
run_dir: run_dir.clone(),
run_id: "run-test".to_string(),
run_id: test_run_id(),
run_store: Arc::clone(&run_store),
workflow_name: "test".to_string(),
hook_runner: None,

View file

@ -401,7 +401,7 @@ pub async fn initialize(
options.run_options.git = Some(GitCheckpointOptions {
base_sha: Some(plan.base_sha.clone()),
run_branch: Some(plan.branch_name.clone()),
meta_branch: Some(MetadataStore::branch_name(&options.run_id)),
meta_branch: Some(MetadataStore::branch_name(&options.run_id.to_string())),
});
}
@ -530,7 +530,10 @@ pub async fn initialize(
.and_then(|g| g.run_branch.as_ref())
.is_some();
if !has_run_branch {
match sandbox.setup_git_for_run(&options.run_options.run_id).await {
match sandbox
.setup_git_for_run(&options.run_options.run_id.to_string())
.await
{
Ok(Some(info)) => {
let base_sha = options
.run_options
@ -542,7 +545,9 @@ pub async fn initialize(
options.run_options.git = Some(GitCheckpointOptions {
base_sha,
run_branch: Some(info.run_branch.clone()),
meta_branch: Some(MetadataStore::branch_name(&options.run_options.run_id)),
meta_branch: Some(MetadataStore::branch_name(
&options.run_options.run_id.to_string(),
)),
});
if options.run_options.base_branch.is_none() {
options.run_options.base_branch = info.base_branch;
@ -653,12 +658,17 @@ mod tests {
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::InMemoryStore;
use fabro_types::{RunId, fixtures};
use super::*;
use crate::pipeline::types::InitOptions;
use crate::records::RunRecord;
use crate::run_options::RunOptions;
fn test_run_id() -> RunId {
fixtures::RUN_1
}
fn simple_graph() -> (Graph, String) {
let source = r#"digraph test {
start [shape=Mdiamond];
@ -688,7 +698,7 @@ mod tests {
settings: FabroSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: "run-test".to_string(),
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
@ -706,7 +716,7 @@ mod tests {
vec![],
run_dir.to_path_buf(),
RunRecord {
run_id: "run-test".to_string(),
run_id: test_run_id(),
created_at: Utc::now(),
settings: FabroSettings::default(),
graph,
@ -731,12 +741,12 @@ mod tests {
let initialized = initialize(
persisted,
InitOptions {
run_id: "run-test".to_string(),
run_id: test_run_id(),
run_store: {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
let inner = store
.create_run(
"test-run",
&test_run_id(),
chrono::Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)
@ -809,12 +819,12 @@ mod tests {
let initialized = initialize(
persisted,
InitOptions {
run_id: "run-test".to_string(),
run_id: test_run_id(),
run_store: {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
let inner = store
.create_run(
"test-run",
&test_run_id(),
chrono::Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)

View file

@ -96,6 +96,7 @@ mod tests {
use chrono::Utc;
use fabro_config::FabroSettings;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_types::fixtures;
use super::*;
use crate::records::RunRecord;
@ -146,7 +147,7 @@ mod tests {
fn sample_record(graph: Graph) -> RunRecord {
RunRecord {
run_id: "run-123".to_string(),
run_id: fixtures::RUN_1,
created_at: Utc::now(),
settings: FabroSettings {
dry_run: Some(true),

View file

@ -634,6 +634,7 @@ mod tests {
use fabro_retro::retro::{
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
};
use fabro_types::fixtures;
use futures::stream;
struct MockProvider {
@ -763,7 +764,7 @@ mod tests {
fn make_test_retro() -> Retro {
Retro {
run_id: "test-run".to_string(),
run_id: fixtures::RUN_1,
workflow_name: "implement".to_string(),
goal: "Fix the bug".to_string(),
timestamp: Utc::now(),
@ -866,7 +867,7 @@ mod tests {
#[test]
fn format_retro_section_empty_stats() {
let retro = Retro {
run_id: "test".to_string(),
run_id: fixtures::RUN_2,
workflow_name: "test".to_string(),
goal: "test".to_string(),
timestamp: Utc::now(),

View file

@ -41,7 +41,7 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option<Retro> {
}
};
let mut retro = derive_retro(
&options.run_id,
options.run_id,
&options.workflow_name,
&options.goal,
completed_stages,
@ -174,6 +174,7 @@ mod tests {
use fabro_config::FabroSettings;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_types::{RunId, fixtures};
use super::*;
use crate::context::Context;
@ -182,6 +183,10 @@ mod tests {
use crate::records::{Checkpoint, CheckpointExt};
use crate::run_options::RunOptions;
fn test_run_id() -> RunId {
fixtures::RUN_1
}
fn write_checkpoint(run_dir: &std::path::Path) -> Checkpoint {
let context = Context::new();
context.set("response.work", serde_json::json!("done"));
@ -208,7 +213,7 @@ mod tests {
) -> Arc<dyn fabro_store::RunStore> {
let inner = InMemoryStore::default()
.create_run(
"run-test",
&test_run_id(),
Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)
@ -226,7 +231,7 @@ mod tests {
settings: FabroSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: "run-test".to_string(),
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
@ -266,7 +271,7 @@ mod tests {
let retroed = retro(
executed,
&RetroOptions {
run_id: "run-test".to_string(),
run_id: test_run_id(),
run_store: test_run_store(&run_dir, &checkpoint).await,
workflow_name: "test".to_string(),
goal: "Ship it".to_string(),
@ -303,7 +308,7 @@ mod tests {
let retro = run_retro(
&RetroOptions {
run_id: "run-test".to_string(),
run_id: test_run_id(),
run_store: test_run_store(&run_dir, &checkpoint).await,
workflow_name: "test".to_string(),
goal: "Ship it".to_string(),

View file

@ -12,6 +12,7 @@ use fabro_mcp::config::McpServerConfig;
use fabro_model::FallbackTarget;
use fabro_sandbox::SandboxSpec;
use fabro_store::RunStore;
use fabro_types::RunId;
use fabro_validate::Diagnostic;
use crate::context::Context;
@ -230,7 +231,7 @@ pub struct DevcontainerSpec {
}
pub struct InitOptions {
pub run_id: String,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub dry_run: bool,
pub emitter: Arc<EventEmitter>,
@ -304,7 +305,7 @@ pub struct Retroed {
/// Output of the FINALIZE phase.
#[non_exhaustive]
pub struct Concluded {
pub run_id: String,
pub run_id: RunId,
pub outcome: Result<Outcome, FabroError>,
pub conclusion: Conclusion,
pub pushed_branch: Option<String>,
@ -316,7 +317,7 @@ pub struct Concluded {
/// Output of the PULL_REQUEST phase.
#[non_exhaustive]
pub struct Finalized {
pub run_id: String,
pub run_id: RunId,
pub outcome: Result<Outcome, FabroError>,
pub conclusion: Conclusion,
pub pushed_branch: Option<String>,
@ -331,7 +332,7 @@ pub struct TransformOptions {
/// Options for the RETRO phase.
pub struct RetroOptions {
pub run_id: String,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub workflow_name: String,
pub goal: String,
@ -349,7 +350,7 @@ pub struct RetroOptions {
/// Options for the FINALIZE phase.
pub struct FinalizeOptions {
pub run_dir: PathBuf,
pub run_id: String,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub workflow_name: String,
pub hook_runner: Option<Arc<HookRunner>>,

View file

@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use fabro_store::{ListRunsQuery, Store};
use fabro_types::RunId;
use serde::Serialize;
use crate::records::{
@ -13,7 +14,7 @@ use crate::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt, StatusRe
#[derive(Debug, Clone, Serialize)]
pub struct RunInfo {
pub run_id: String,
pub run_id: RunId,
pub dir_name: String,
pub workflow_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -116,7 +117,12 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
let mtime = mtime_dt.map(|dt| dt.to_rfc3339()).unwrap_or_default();
let run_id = std::fs::read_to_string(path.join("id.txt"))
.map_or_else(|_| dir_name.clone(), |s| s.trim().to_string());
.ok()
.and_then(|s| parse_run_id(&s))
.or_else(|| parse_run_id(&dir_name));
let Some(run_id) = run_id else {
continue;
};
let status_info = read_status(&path);
let is_orphan = matches!(status_info.status, RunStatus::Dead);
@ -151,9 +157,9 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
}
pub async fn scan_runs_combined(store: &dyn Store, base: &Path) -> Result<Vec<RunInfo>> {
let mut runs_by_id: HashMap<String, RunInfo> = scan_runs(base)?
let mut runs_by_id: HashMap<RunId, RunInfo> = scan_runs(base)?
.into_iter()
.map(|run| (run.run_id.clone(), run))
.map(|run| (run.run_id, run))
.collect();
if let Ok(store_runs) = store.list_runs(&ListRunsQuery::default()).await {
@ -184,7 +190,7 @@ pub async fn scan_runs_combined(store: &dyn Store, base: &Path) -> Result<Vec<Ru
None
};
runs_by_id.insert(
summary.run_id.clone(),
summary.run_id,
RunInfo {
run_id: summary.run_id,
dir_name,
@ -307,14 +313,14 @@ pub fn find_run_by_prefix(base: &Path, prefix: &str) -> Result<PathBuf> {
let runs = scan_runs(base).context("Failed to scan runs")?;
let matches: Vec<_> = runs
.iter()
.filter(|run| run.run_id.starts_with(prefix))
.filter(|run| run_id_matches(run.run_id, prefix))
.collect();
match matches.len() {
0 => bail!("No run found matching prefix '{prefix}'"),
1 => Ok(matches[0].path.clone()),
count => {
let ids: Vec<&str> = matches.iter().map(|run| run.run_id.as_str()).collect();
let ids: Vec<String> = matches.iter().map(|run| run.run_id.to_string()).collect();
bail!(
"Ambiguous prefix '{prefix}': {count} runs match: {}",
ids.join(", ")
@ -328,13 +334,16 @@ pub fn resolve_run(base: &Path, identifier: &str) -> Result<RunInfo> {
let id_matches: Vec<_> = runs
.iter()
.filter(|run| run.run_id.starts_with(identifier))
.filter(|run| run_id_matches(run.run_id, identifier))
.collect();
match id_matches.len() {
1 => return Ok(id_matches[0].clone()),
count if count > 1 => {
let ids: Vec<&str> = id_matches.iter().map(|run| run.run_id.as_str()).collect();
let ids: Vec<String> = id_matches
.iter()
.map(|run| run.run_id.to_string())
.collect();
bail!(
"Ambiguous prefix '{identifier}': {count} runs match: {}",
ids.join(", ")
@ -374,13 +383,16 @@ pub async fn resolve_run_combined(
let id_matches: Vec<_> = runs
.iter()
.filter(|run| run.run_id.starts_with(identifier))
.filter(|run| run_id_matches(run.run_id, identifier))
.collect();
match id_matches.len() {
1 => return Ok(id_matches[0].clone()),
count if count > 1 => {
let ids: Vec<&str> = id_matches.iter().map(|run| run.run_id.as_str()).collect();
let ids: Vec<String> = id_matches
.iter()
.map(|run| run.run_id.to_string())
.collect();
bail!(
"Ambiguous prefix '{identifier}': {count} runs match: {}",
ids.join(", ")
@ -412,3 +424,12 @@ pub async fn resolve_run_combined(
fn collapse_separators(s: &str) -> String {
s.chars().filter(|c| *c != '-' && *c != '_').collect()
}
fn parse_run_id(value: &str) -> Option<RunId> {
let value = value.trim();
(!value.is_empty()).then_some(value)?.parse().ok()
}
fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
run_id.to_string().starts_with(prefix)
}

View file

@ -5,6 +5,7 @@ use std::sync::atomic::AtomicBool;
use fabro_config::FabroSettings;
use fabro_config::run::PullRequestSettings;
use fabro_types::RunId;
/// Git checkpoint options for a workflow run.
#[derive(Clone)]
@ -21,7 +22,7 @@ pub struct RunOptions {
pub run_dir: PathBuf,
pub cancel_token: Option<Arc<AtomicBool>>,
/// Unique identifier for this workflow run.
pub run_id: String,
pub run_id: RunId,
/// User-defined key-value labels for this run.
pub labels: HashMap<String, String>,
/// Workflow directory slug (e.g. "smoke" from `fabro/workflows/smoke/`).

View file

@ -2,6 +2,7 @@ use std::path::Path;
use fabro_agent::Sandbox;
use fabro_git_storage::trailerlink::{self, Trailer};
use fabro_types::RunId;
use crate::asset_snapshot;
use crate::git::{GitAuthor, blocking_push_with_timeout, push_ref};
@ -10,7 +11,7 @@ use fabro_sandbox::daytona::detect_repo_info;
/// Captured git state for a workflow run, shared with handlers.
#[derive(Debug, Clone)]
pub struct GitState {
pub run_id: String,
pub run_id: RunId,
pub base_sha: String,
pub run_branch: Option<String>,
pub meta_branch: Option<String>,

View file

@ -4,6 +4,8 @@
//! Run with: `cargo test --package arc-workflows -- --ignored daytona`
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
@ -14,6 +16,7 @@ use fabro_llm::provider::Provider;
use fabro_sandbox::SandboxRecordExt;
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig};
use fabro_store::RuntimeState;
use fabro_types::RunId;
use fabro_workflows::artifact::sync_artifacts_to_env;
use fabro_workflows::context::Context;
use fabro_workflows::error::FabroError;
@ -25,6 +28,13 @@ use fabro_workflows::outcome::{Outcome, OutcomeExt, StageStatus};
use fabro_workflows::records::{Checkpoint, CheckpointExt};
use fabro_workflows::run_options::{GitCheckpointOptions, RunOptions};
use fabro_workflows::test_support::WorkflowRunner;
use ulid::Ulid;
fn test_run_id(label: &str) -> RunId {
let mut hasher = DefaultHasher::new();
label.hash(&mut hasher);
RunId::from(Ulid(u128::from(hasher.finish())))
}
async fn create_env() -> DaytonaSandbox {
let creds = load_github_app_credentials();
@ -394,7 +404,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
settings: FabroSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: "test-run".into(),
run_id: test_run_id("test-run"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -473,7 +483,7 @@ impl Handler for FileWriterHandler {
/// Set up git inside a Daytona sandbox for checkpoint commits.
/// Returns (run_id, base_sha, branch_name) on success.
async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (String, String, String) {
async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (RunId, String, String) {
// Get current HEAD as base SHA
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
@ -486,7 +496,7 @@ async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (String, String, String) {
);
let base_sha = sha_result.stdout.trim().to_string();
let run_id = ulid::Ulid::new().to_string();
let run_id = RunId::from(Ulid::new());
let branch_name = format!("fabro/run/{run_id}");
let checkout_cmd = format!("git checkout -b {branch_name}");
@ -584,7 +594,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
settings: FabroSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: "git-cp-test".into(),
run_id: test_run_id("git-cp-test"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -769,7 +779,7 @@ async fn daytona_parallel_git_branching_e2e() {
settings: FabroSettings::default(),
run_dir: run_tmp.path().to_path_buf(),
cancel_token: None,
run_id: run_id.clone(),
run_id,
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -1138,13 +1148,13 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let meta_branch = MetadataStore::branch_name(&run_id);
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone());
let run_options = RunOptions {
settings: FabroSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: run_id.clone(),
run_id,
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -1164,7 +1174,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
assert_eq!(outcome.status, StageStatus::Success);
// Assert shadow branch on host has checkpoint data
let checkpoint = MetadataStore::read_checkpoint(host_repo.path(), &run_id)
let checkpoint = MetadataStore::read_checkpoint(host_repo.path(), &run_id.to_string())
.expect("read_checkpoint should not error")
.expect("shadow branch should contain checkpoint data");
assert!(
@ -1288,7 +1298,7 @@ async fn daytona_asset_collection() {
},
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: "asset-test-daytona".into(),
run_id: test_run_id("asset-test-daytona"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -1533,7 +1543,7 @@ async fn daytona_git_push_run_branch_to_origin() {
settings: FabroSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: run_id.clone(),
run_id,
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,

File diff suppressed because it is too large Load diff