fn result_file_path(artifact_root: &Path, task_id: &str) -> PathBuf {
artifact_root.join("runtime").join("results").join(format!("{task_id}.md"))
}
#[derive(Clone, Copy)]
struct ResultInvocation<'a> {
state: &'a str,
visit_count: u64,
identity: Option<&'a str>,
}
impl<'a> ResultInvocation<'a> {
fn whole_task() -> Self {
Self { state: "", visit_count: 1, identity: None }
}
}
fn result_relative_path(task_id: &str, invocation: ResultInvocation<'_>) -> String {
match invocation.identity {
Some(identity) => format!(
"runtime/results/{task_id}/{}/{}/{identity}.md",
invocation.state, invocation.visit_count
),
None => format!("runtime/results/{task_id}.md"),
}
}
fn invocation_result_file_path(
artifact_root: &Path,
task_id: &str,
invocation: ResultInvocation<'_>,
) -> PathBuf {
artifact_root.join(result_relative_path(task_id, invocation))
}
fn fanout_result_identity(
state_def: Option<&rhei_validator::StateDef>,
target: Option<&ExecutionTarget>,
model: Option<&str>,
) -> Option<String> {
let state_def = state_def?;
if state_def.program.is_some() {
return None;
}
if !state_def.all_targets.is_empty() {
return target.map(|target| target.slug());
}
if !state_def.all_models.is_empty() {
return model.map(slugify_target_value);
}
None
}
fn fanout_result_fragments(
artifact_root: &Path,
task_id: &str,
state_name: &str,
visit_count: u64,
state_def: &rhei_validator::StateDef,
invocations: &[ResolvedAgent],
) -> Vec<(String, PathBuf)> {
invocations
.iter()
.filter_map(|resolved| {
let identity = fanout_result_identity(
Some(state_def),
resolved.target.as_ref(),
resolved.model.as_deref(),
)?;
let path = invocation_result_file_path(
artifact_root,
task_id,
ResultInvocation {
state: state_name,
visit_count,
identity: Some(&identity),
},
);
Some((identity, path))
})
.collect()
}
fn merge_fanout_result_fragments(
artifact_root: &Path,
task_id: &str,
state_name: &str,
visit_count: u64,
state_def: &rhei_validator::StateDef,
invocations: &[ResolvedAgent],
) -> MietteResult<bool> {
let fragments =
fanout_result_fragments(artifact_root, task_id, state_name, visit_count, state_def, invocations);
if fragments.is_empty() {
return Ok(false);
}
let mut merged = String::new();
let mut missing: Vec<String> = Vec::new();
for (identity, path) in fragments {
let content = fs::read_to_string(&path).unwrap_or_default();
if content.trim().is_empty() {
missing.push(format!("{identity} ({})", path.display()));
continue;
}
let trimmed = content.trim();
let body = trimmed.strip_prefix("## Result").map(str::trim_start).unwrap_or(trimmed);
merged.push_str(&format!("## Result \u{2014} {identity}\n\n{body}\n\n"));
}
if !missing.is_empty() {
return Err(miette!(
help = format!(
"each invocation of a fanned-out state writes its own result, and the ticket's \
result is the merge of them. Rerun to let the missing invocation(s) write, or \
write the file(s) named above."
),
"Task {} cannot finish from '{}': {} of its fan-out invocation(s) wrote no result.\n\
Missing: {}",
task_id,
state_name,
missing.len(),
missing.join(", ")
));
}
let destination = result_file_path(artifact_root, task_id);
let mut existing = fs::read_to_string(&destination).unwrap_or_default();
if existing.contains(&merged) {
return Ok(true);
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|err| file_io_report(parent, "failed to create runtime/results", err))?;
}
if !existing.trim().is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
let combined =
if existing.trim().is_empty() { merged } else { format!("{existing}{merged}") };
fs::write(&destination, combined)
.map_err(|err| file_io_report(&destination, "failed to merge fanout results", err))?;
Ok(true)
}
fn absolute_result_file_path(artifact_root: &Path, task_id: &str) -> PathBuf {
let path = result_file_path(artifact_root, task_id);
std::path::absolute(&path).unwrap_or(path)
}
fn absolute_invocation_result_file_path(
artifact_root: &Path,
task_id: &str,
invocation: ResultInvocation<'_>,
) -> PathBuf {
let path = invocation_result_file_path(artifact_root, task_id, invocation);
std::path::absolute(&path).unwrap_or(path)
}
fn file_has_content(path: &Path) -> bool {
fs::read_to_string(path).map(|content| !content.trim().is_empty()).unwrap_or(false)
}
fn task_result_is_present(artifact_root: &Path, task_id: &str) -> bool {
file_has_content(&result_file_path(artifact_root, task_id))
}
fn fanout_result_fragments_exist(artifact_root: &Path, task_id: &str) -> bool {
let root = artifact_root.join("runtime").join("results").join(task_id);
fs::read_dir(&root).map(|mut entries| entries.next().is_some()).unwrap_or(false)
}
fn ensure_terminal_result_available(
machine: &rhei_validator::StateMachine,
artifact_root: &Path,
qualified_id: &str,
from: &str,
to: &str,
carried_message: Option<&str>,
plan_path: &Path,
) -> MietteResult<()> {
if !machine.states.get(to).map(|def| def.terminal).unwrap_or(false) {
return Ok(());
}
if carried_message.is_some_and(|message| !message.trim().is_empty()) {
return Ok(());
}
if task_result_is_present(artifact_root, qualified_id) {
return Ok(());
}
let relative = format!("runtime/results/{qualified_id}.md");
let plan = plan_arg_for_help(plan_path);
let fragments = if fanout_result_fragments_exist(artifact_root, qualified_id) {
format!(
" This ticket has fan-out result fragments under runtime/results/{qualified_id}/; \
`rhei run` merges those into {relative} when it takes the edge, and a manual \
finish carries its own --result."
)
} else {
String::new()
};
Err(miette!(
help = format!(
"a final state records why the ticket ended there. Pass it on the move: \
rhei transition {plan} --task {qualified_id} --from {from} --to {to} \
--result \"<what happened>\" \
(rhei complete {plan} --task {qualified_id} --result \"<what happened>\" for the \
everyday finish), or write {relative} before the move.{fragments}"
),
"Task {} cannot enter terminal state '{}' without a result.\n\
Expected a non-empty result file at: {}",
qualified_id,
to,
result_file_path(artifact_root, qualified_id).display()
))
}