struct TransitionTaskInfo {
task: rhei_core::ast::Task,
level: u8,
ancestors: Vec<rhei_core::ast::Task>,
}
fn task_profile_allows_state(
machine: &rhei_validator::StateMachine,
kind: &str,
level: u8,
state: &str,
) -> bool {
machine
.profile_for_node(kind, level)
.is_none_or(|profile| profile.allowed.iter().any(|allowed| allowed == state))
}
fn plan_arg_for_help(plan_path: &Path) -> String {
let shown = std::env::current_dir()
.ok()
.and_then(|cwd| cwd.canonicalize().ok())
.and_then(|cwd| plan_path.strip_prefix(cwd).ok())
.map(|relative| {
if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative.to_path_buf()
}
})
.unwrap_or_else(|| plan_path.to_path_buf());
shell_quote(&shown.display().to_string())
}
fn ensure_descendants_terminal_for_terminal_entry(
machine: &rhei_validator::StateMachine,
task: &rhei_core::ast::Task,
local_id: &str,
qualified_id: &str,
to: &str,
plan_path: &Path,
) -> MietteResult<()> {
if task.children.is_empty()
|| !machine.states.get(to).map(|def| def.terminal).unwrap_or(false)
{
return Ok(());
}
let prefix = qualified_id.strip_suffix(local_id).unwrap_or("");
let open = non_terminal_descendants(task, machine, prefix);
if open.is_empty() {
return Ok(());
}
let plan = plan_arg_for_help(plan_path);
Err(miette!(
help = format!(
"a parent is finished after its subtree is, and nothing finishes it on its \
children's behalf. See the open work with: rhei list {plan} --non-terminal, \
then claim it with: rhei next {plan}"
),
"Task {} cannot enter terminal state '{}' while descendant tasks remain non-terminal.\n\
Offending descendants: {}",
qualified_id,
to,
open.join(", ")
))
}
fn ensure_task_profile_allows_state(
machine: &rhei_validator::StateMachine,
task_id_str: &str,
kind: &str,
level: u8,
state: &str,
) -> MietteResult<()> {
let Some(profile) = machine.profile_for_node(kind, level) else {
return Ok(());
};
if profile.allowed.iter().any(|allowed| allowed == state) {
return Ok(());
}
Err(miette!(
help = "the task's node profile restricts which states it may enter. Change the task's profile, or widen the profile in the state machine.",
"Task {} cannot enter state '{}': state is not allowed by its resolved profile. Profile allows: [{}]",
task_id_str,
state,
profile.allowed.join(", ")
))
}
#[allow(clippy::too_many_arguments)]
fn execute_transition_with_origin(
files: TransitionFiles<'_>,
callback_paths: &CallbackPaths,
machine: &rhei_validator::StateMachine,
task_id_str: &str,
from: &str,
to: &str,
no_callbacks: bool,
origin: TransitionOrigin,
) -> MietteResult<String> {
let task_file = files.task_file;
let metadata_file = files.metadata_file;
let workspace_root = execution_workspace_root(&callback_paths.plan_path);
let settings = load_merged_settings(&workspace_root)?;
if !machine.is_valid_state(from) {
let allowed = machine.allowed_states().collect::<Vec<_>>().join(", ");
return Err(miette!(
help = unknown_state_help(),
"'{}' is not a valid state. Allowed: [{}]", from, allowed
));
}
if !machine.is_valid_state(to) {
let allowed = machine.allowed_states().collect::<Vec<_>>().join(", ");
return Err(miette!(
help = unknown_state_help(),
"'{}' is not a valid state. Allowed: [{}]", to, allowed
));
}
let metadata_handle = fs::File::open(metadata_file)
.map_err(|err| file_io_report(metadata_file, "failed to open plan file", err))?;
metadata_handle
.lock_exclusive()
.map_err(|err| file_io_report(metadata_file, "failed to acquire file lock", err))?;
let task_handle = if task_file == metadata_file {
None
} else {
let handle = fs::File::open(task_file)
.map_err(|err| file_io_report(task_file, "failed to open plan file", err))?;
handle
.lock_exclusive()
.map_err(|err| file_io_report(task_file, "failed to acquire file lock", err))?;
Some(handle)
};
let metadata_raw = fs::read_to_string(metadata_file)
.map_err(|err| file_io_report(metadata_file, "failed to read plan file", err))?;
let task_raw = if task_file == metadata_file {
metadata_raw.clone()
} else {
fs::read_to_string(task_file)
.map_err(|err| file_io_report(task_file, "failed to read plan file", err))?
};
let target_id = parse_task_id(task_id_str);
let metadata_key = parse_task_id(files.metadata_id);
let manifest = if task_file == metadata_file {
None
} else {
Some(parse_metadata_manifest(metadata_file, &metadata_raw)?)
};
let task_info = find_task_transition_info(
&task_raw,
task_file,
manifest.as_ref().map(|index| &index.structure),
&target_id,
task_id_str,
)?;
let current_state_raw = task_info.task.state.clone();
let current_state = normalized_state_name(¤t_state_raw, machine);
let metadata = if task_file == metadata_file {
rhei_core::parse(&metadata_raw)
.map_err(|err| {
miette!(
help = plan_authoring_help(),
"failed to parse plan for transition metadata: {}", err.message
)
})?
.metadata
} else {
manifest.and_then(|index| index.metadata)
};
if current_state != from {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(miette!(
help = task_moved_help(),
"conflict: Task {} is in state '{}', expected '{}'",
files.artifact_id,
current_state_raw,
from
));
}
if let Err(err) = ensure_task_profile_allows_state(
machine,
files.artifact_id,
&task_info.task.kind,
task_info.level,
to,
) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(err);
}
let matching_rule =
machine.transitions().iter().find(|rule| rule.from.0 == from && rule.to.0 == to).or_else(
|| machine.transitions().iter().find(|rule| rule.from.0 == "*" && rule.to.0 == to),
);
let Some(matching_rule) = matching_rule else {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(miette!(
help = "the machine declares no such edge. List the edges with: rhei states",
"transition from '{}' to '{}' is not allowed by the state machine",
from,
to
));
};
let normalized_metadata = ensure_current_state_visit_count(
metadata.as_ref(),
&metadata_key,
from,
¤t_state_raw,
machine,
);
let metadata_for_checks = normalized_metadata.as_ref().or(metadata.as_ref());
if !transition_rule_is_applicable(
matching_rule,
machine,
metadata_for_checks,
&metadata_key,
Some(&task_info.task),
from,
¤t_state_raw,
)? {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
let reason = describe_blocked_transition(
matching_rule,
machine,
metadata_for_checks,
&metadata_key,
from,
¤t_state_raw,
);
let alternatives = applicable_alternatives(
machine,
metadata_for_checks,
&metadata_key,
Some(&task_info.task),
from,
¤t_state_raw,
);
let suffix = if alternatives.is_empty() {
"No other transitions from this state are currently applicable.".to_string()
} else {
format!(
"Currently applicable transitions from '{}': {}.",
from,
alternatives.join(", ")
)
};
return Err(miette!(
help = "the edge exists but its condition is unmet. Inspect the machine with: rhei states",
"transition from '{}' to '{}' is not currently applicable: {}. {}",
from,
to,
reason,
suffix
));
}
if let Err(err) = ensure_descendants_terminal_for_terminal_entry(
machine,
&task_info.task,
task_id_str,
files.artifact_id,
to,
&callback_paths.plan_path,
) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(err);
}
let from_state_def = machine
.states
.get(from)
.ok_or_else(|| miette!(
help = internal_error_help(),
"state '{}' missing from loaded machine", from
))?;
let from_invocations = resolve_agent_invocations_for_task(
machine,
from,
&settings,
&default_run_options(),
Some(&task_info.task),
)
.unwrap_or_default();
let callback_contexts = callback_contexts_for_state(from_state_def, &from_invocations);
let plan_for_context = rhei_core::parse(&metadata_raw).ok();
let mut transition_data: serde_json::Value = origin
.seed_data
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
let mut redirect_next_state: Option<String> = None;
if !no_callbacks {
if let Some(ref cb) = matching_rule.on_leave {
let executor = ShellCallbackExecutor;
for (model, agent) in callback_contexts {
let context_json = build_transition_context_json(
plan_for_context.as_ref(),
&callback_paths.plan_path,
task_id_str,
files.artifact_id,
from,
to,
origin.triggered_by.unwrap_or("user"),
&transition_data,
&callback_paths.working_dir,
);
let ctx = CallbackContext {
task_id: files.artifact_id,
task_id_local: task_id_str,
from_state: from,
to_state: to,
plan_path: &callback_paths.plan_path,
callback_cwd: &callback_paths.working_dir,
model,
agent,
context_json: Some(&context_json),
};
let result = executor.execute(cb, &ctx).map_err(|e| miette!(
help = state_machine_help(),
"{e}"
))?;
if !result.success {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
let message = result
.error
.clone()
.unwrap_or_else(|| "transition rejected by callback".to_string());
return Err(miette!(
help = callback_command_help(),
"on_leave callback '{}' rejected the transition: {message}",
cb.0
));
}
if let Some(data) = result.data.as_ref() {
merge_transition_data(&mut transition_data, data);
}
if let Some(redirect) = result.next_state.clone() {
if redirect_next_state.is_none() {
redirect_next_state = Some(redirect);
}
}
}
}
}
let (effective_to, effective_rule) = if let Some(redirect) = redirect_next_state.as_deref() {
if redirect == to {
(to.to_string(), matching_rule)
} else if !machine.is_valid_state(redirect) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(miette!(
help = callback_command_help(),
"on_leave callback redirected to unknown state '{}'", redirect
));
} else if let Err(err) = ensure_task_profile_allows_state(
machine,
files.artifact_id,
&task_info.task.kind,
task_info.level,
redirect,
) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(err);
} else if let Some(rule) =
machine.transitions().iter().find(|r| r.from.0 == from && r.to.0 == redirect).or_else(
|| machine.transitions().iter().find(|r| r.from.0 == "*" && r.to.0 == redirect),
)
{
(redirect.to_string(), rule)
} else {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(miette!(
help = callback_command_help(),
"on_leave callback redirected to '{}', but no transition from '{}' to '{}' is declared",
redirect,
from,
redirect
));
}
} else {
(to.to_string(), matching_rule)
};
let to = effective_to.as_str();
let matching_rule = effective_rule;
if let Err(err) = ensure_descendants_terminal_for_terminal_entry(
machine,
&task_info.task,
task_id_str,
files.artifact_id,
to,
&callback_paths.plan_path,
) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(err);
}
let to_state_def = machine
.states
.get(to)
.ok_or_else(|| miette!(
help = internal_error_help(),
"state '{}' missing from loaded machine", to
))?;
let mut updated_metadata =
update_metadata_for_transition(metadata_for_checks, &metadata_key, to, machine)
.or_else(|| normalized_metadata.clone());
if from_state_def.poll.is_some() && to != from {
updated_metadata = clear_poll_state_metadata(
updated_metadata.as_ref().or(metadata_for_checks),
&metadata_key,
from,
);
}
let from_visit_count = Some(render_visit_count(
metadata_for_checks,
&metadata_key,
from,
¤t_state_raw,
machine,
));
let to_visit_count = updated_metadata
.as_ref()
.map(|meta| task_visit_count(Some(meta), &metadata_key, to))
.filter(|count| *count > 0);
if let Some(next) = supervision_after_transition(
updated_metadata.as_ref().or(metadata_for_checks),
machine,
&task_info,
files,
&metadata_key,
(task_id_str, from, to),
to_visit_count.unwrap_or(1),
) {
updated_metadata = Some(next);
}
let cancelling = rhei_validator::is_cancelled_state_name(&normalized_state_name(to, machine));
if !origin.skip_source_outputs && !cancelling {
ensure_state_outputs_exist_for_transition(
files.artifact_root,
Some(&task_info.task),
files.artifact_id,
from,
from_state_def,
from_visit_count,
machine,
&settings,
machine.states.get(to).map(|def| def.terminal).unwrap_or(false),
)?;
}
ensure_state_inputs_exist_for_transition(
files.artifact_root,
Some(&task_info.task),
files.artifact_id,
to,
to_state_def,
to_visit_count,
machine,
&settings,
&format!("Task {} cannot enter state {}.", files.artifact_id, to),
)?;
let recorded_message = origin.result_message.clone().or_else(|| {
let lands_terminal = machine.states.get(to).map(|def| def.terminal).unwrap_or(false);
if lands_terminal && !task_result_is_present(files.artifact_root, files.artifact_id) {
origin.terminal_result_fallback.clone()
} else {
None
}
});
if let Err(err) = ensure_terminal_result_available(
machine,
files.artifact_root,
files.artifact_id,
from,
to,
recorded_message.as_deref(),
&callback_paths.plan_path,
) {
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
return Err(err);
}
let rendered_to_state = format_task_state_value(to, to_visit_count, machine);
let ends_visit = transition_ends_supervisor_visit(machine, from, to);
let metadata_raw_updated = if task_file == metadata_file {
let new_task_raw =
rewrite_task_for_transition(&task_raw, task_id_str, &rendered_to_state, ends_visit)?;
if let Some(updated_metadata) = updated_metadata.as_ref() {
rewrite_frontmatter(&new_task_raw, updated_metadata)?
} else {
new_task_raw
}
} else if let Some(updated_metadata) = updated_metadata.as_ref() {
rewrite_frontmatter(&metadata_raw, updated_metadata)?
} else {
metadata_raw.clone()
};
let task_raw_updated = if task_file == metadata_file {
None
} else {
Some(rewrite_task_for_transition(
&task_raw,
task_id_str,
&rendered_to_state,
ends_visit,
)?)
};
write_file_atomic(metadata_file, &metadata_raw_updated)?;
if let Some(ref task_raw_updated) = task_raw_updated {
write_file_atomic(task_file, task_raw_updated)?;
}
let triggered_by = origin.triggered_by.unwrap_or(if redirect_next_state.is_some() {
"callback"
} else {
"user"
});
let on_enter_context_json = build_transition_context_json(
plan_for_context.as_ref(),
&callback_paths.plan_path,
task_id_str,
files.artifact_id,
from,
to,
triggered_by,
&transition_data,
&callback_paths.working_dir,
);
let callback_ctx = CallbackContext {
task_id: files.artifact_id,
task_id_local: task_id_str,
from_state: from,
to_state: to,
plan_path: &callback_paths.plan_path,
callback_cwd: &callback_paths.working_dir,
model: None,
agent: None,
context_json: Some(&on_enter_context_json),
};
if !no_callbacks {
if let Some(ref cb) = matching_rule.on_enter {
let executor = ShellCallbackExecutor;
let result = executor.execute(cb, &callback_ctx).map_err(|e| miette!(
help = state_machine_help(),
"{e}"
))?;
if !result.success {
let rollback_err = write_file_atomic(metadata_file, &metadata_raw).err();
let task_rollback_err = if task_raw_updated.is_some() {
write_file_atomic(task_file, &task_raw).err()
} else {
None
};
if let Some(task_handle) = &task_handle {
let _ = fs2::FileExt::unlock(task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
let message =
result.error.clone().unwrap_or_else(|| "on_enter callback failed".to_string());
if rollback_err.is_some() || task_rollback_err.is_some() {
return Err(miette!(
help = callback_command_help(),
"on_enter callback '{}' failed ({message}); rollback also failed — plan file may be inconsistent",
cb.0
));
}
return Err(miette!(
help = callback_command_help(),
"on_enter callback '{}' failed: {message}", cb.0
));
}
}
}
let record = record_transition_result(
files.artifact_root,
files.task_file,
task_id_str,
machine,
files.artifact_id,
from,
to,
recorded_message.as_deref(),
);
if let Some(task_handle) = task_handle {
let _ = fs2::FileExt::unlock(&task_handle);
}
let _ = fs2::FileExt::unlock(&metadata_handle);
record?;
Ok(to.to_string())
}
fn rewrite_task_for_transition(
raw: &str,
local_id: &str,
rendered_to_state: &str,
ends_supervisor_visit: bool,
) -> MietteResult<String> {
let rewritten = rewrite_task_state(raw, local_id, rendered_to_state)?;
if !ends_supervisor_visit {
return Ok(rewritten);
}
Ok(without_task_assignee(&rewritten, local_id).0)
}
fn find_task_transition_info(
raw: &str,
file_path: &Path,
workspace_structure: Option<&rhei_core::ast::Structure>,
target_id: &TaskId,
task_id_str: &str,
) -> MietteResult<TransitionTaskInfo> {
if let Ok(rhei) = rhei_core::parse(raw) {
if let Some(task) = find_task_by_id(&rhei.tasks, target_id) {
return Ok(TransitionTaskInfo {
task: task.clone(),
level: task.id.depth() as u8,
ancestors: ancestor_chain(&rhei.tasks, target_id)
.into_iter()
.cloned()
.collect(),
});
}
}
let workspace_tasks = match workspace_structure {
Some(structure) => rhei_core::parser::parse_workspace_tasks_with_structure(raw, structure),
None => rhei_core::parser::parse_workspace_tasks(raw),
};
if let Ok(tasks) = workspace_tasks {
if let Some(task) = find_task_by_id(&tasks, target_id) {
return Ok(TransitionTaskInfo {
task: task.clone(),
level: task.id.depth() as u8,
ancestors: ancestor_chain(&tasks, target_id).into_iter().cloned().collect(),
});
}
}
Err(miette!(
help = task_id_help(),
"task '{}' not found in {}", task_id_str, file_path.display()
))
}