fn state_machine_flag(state_machine_path: Option<&Path>) -> String {
state_machine_path
.map(|path| format!(" --state-machine={}", shell_quote(&path.display().to_string())))
.unwrap_or_default()
}
fn supervisor_next_step(
rhei: &rhei_core::ast::Rhei,
machines: &rhei_validator::MachineSet,
supervisor: &TaskId,
plan_arg: &str,
state_machine_path: Option<&Path>,
) -> Option<String> {
let mut all = Vec::new();
collect_plan_tasks(&rhei.tasks, &mut all);
let task = all.iter().copied().find(|task| &task.id == supervisor)?;
if let Some(assignee) = task.assignee.as_deref() {
return Some(format!(
" Task {supervisor} is the ticket to work and {assignee} holds it; hand it back \
with: rhei release {plan_arg} --task {supervisor}"
));
}
let index = task_index(&all);
let claimable = subtree_admits_to_ready_set(
task,
&index,
machines,
rhei.metadata.as_ref(),
&HashSet::new(),
);
if !claimable {
return None;
}
Some(format!(
" Work the supervisor instead: rhei{} next {plan_arg} --task {supervisor}",
state_machine_flag(state_machine_path)
))
}
fn transition_command_lines(
task: &rhei_core::ast::Task,
state_name: &str,
machine: &rhei_validator::StateMachine,
metadata: Option<&Metadata>,
plan_arg: &str,
state_machine_path: Option<&Path>,
) -> Vec<String> {
let state_machine_arg = state_machine_flag(state_machine_path);
let from_arg = shell_quote(state_name);
machine
.transitions()
.iter()
.filter(|rule| rule.from.0 == state_name || rule.from.0 == "*")
.filter(|rule| {
task_profile_allows_state(
machine,
task.kind.as_str(),
task.profile_level(),
&rule.to.0,
)
})
.filter(|rule| {
transition_rule_is_applicable(
rule,
machine,
metadata,
&task.id,
Some(task),
state_name,
task.state.as_str(),
)
.unwrap_or(false)
})
.map(|rule| {
let to_arg = shell_quote(&rule.to.0);
format!(
" rhei{} transition {} --task {} --from={} --to={}",
state_machine_arg, plan_arg, task.id, from_arg, to_arg
)
})
.collect()
}
fn diagnose_no_claimable(
rhei: &rhei_core::ast::Rhei,
machines: &rhei_validator::MachineSet,
plan_path: &Path,
state_machine_path: Option<&Path>,
scope: &RheiScope,
) -> String {
let mut project = Vec::new();
collect_plan_tasks(&rhei.tasks, &mut project);
let state_map = plan_state_map(&project, machines);
let all: Vec<&rhei_core::ast::Task> = project
.iter()
.copied()
.filter(|task| task_in_rhei_scope(scope, &task.id.to_string()))
.collect();
let scope_suffix = match scope {
Some(_) => format!(" in the --rhei scope ({})", scope_label(scope)),
None => String::new(),
};
if all.is_empty() {
return match scope {
Some(_) => {
format!("no tickets are ready to claim{scope_suffix} (no tickets in scope)")
}
None if rhei_core::workspace::is_panta_project(plan_path) => {
format!(
"no tickets are ready to claim — the project has none yet: {}",
add_a_rhei_hint()
)
}
None => "no tickets are ready to claim — this rhei has none yet".to_string(),
};
}
let non_terminal: Vec<&rhei_core::ast::Task> = all
.iter()
.copied()
.filter(|t| !is_terminal_state(t.state.as_str(), machines.for_task(&t.id)))
.collect();
if non_terminal.is_empty() {
return match scope {
Some(_) => format!(
"Scope complete. All {} task(s){scope_suffix} are in terminal states.",
all.len()
),
None => format!("Plan complete. All {} task(s) are in terminal states.", all.len()),
};
}
let non_terminal_workable: Vec<&rhei_core::ast::Task> = non_terminal
.iter()
.copied()
.filter(|task| descendants_are_terminal(task, machines))
.collect();
let priors_satisfied = |task: &rhei_core::ast::Task| -> bool {
task.prior.iter().all(|dep_id| {
state_map
.get(dep_id)
.map(|s| dependency_is_satisfied(s, machines.for_task(dep_id)))
.unwrap_or(false)
})
};
let gating_ready: Vec<&rhei_core::ast::Task> = non_terminal_workable
.iter()
.copied()
.filter(|task| {
let machine = machines.for_task(&task.id);
let state = normalized_state_name(task.state.as_str(), machine);
machine.states.get(&state).map(|def| def.gating).unwrap_or(false)
&& priors_satisfied(task)
})
.collect();
if !gating_ready.is_empty() {
let items: Vec<String> = gating_ready
.iter()
.take(3)
.map(|task| {
let state =
normalized_state_name(task.state.as_str(), machines.for_task(&task.id));
format!("Task {} ({})", task.id, state)
})
.collect();
let suffix = if gating_ready.len() > 3 {
format!(" (+{} more)", gating_ready.len() - 3)
} else {
String::new()
};
return format!(
"Blocked: {} task(s) waiting on human action: {}{}.",
gating_ready.len(),
items.join(", "),
suffix
);
}
let assigned_ready: Vec<&rhei_core::ast::Task> = non_terminal_workable
.iter()
.copied()
.filter(|t| {
let machine = machines.for_task(&t.id);
let s = normalized_state_name(t.state.as_str(), machine);
let gating = machine.states.get(&s).map(|def| def.gating).unwrap_or(false);
!gating && t.assignee.is_some() && priors_satisfied(t)
})
.collect();
if !assigned_ready.is_empty() {
let items: Vec<String> = assigned_ready
.iter()
.take(3)
.map(|task| {
let state =
normalized_state_name(task.state.as_str(), machines.for_task(&task.id));
let assignee = task.assignee.as_deref().unwrap_or("unknown");
format!("Task {} ({}, assignee {})", task.id, state, assignee)
})
.collect();
let suffix = if assigned_ready.len() > 3 {
format!(" (+{} more)", assigned_ready.len() - 3)
} else {
String::new()
};
return format!(
"No tasks available to claim{}. {} task(s) are currently in progress: {}{}.",
scope_suffix,
assigned_ready.len(),
items.join(", "),
suffix
);
}
let held: Vec<(&rhei_core::ast::Task, TaskId, String)> = non_terminal
.iter()
.copied()
.filter_map(|task| {
held_by_supervisor(task, rhei, machines)
.map(|hold| (task, hold.supervisor, hold.state))
})
.collect();
if !held.is_empty() {
let items: Vec<String> = held
.iter()
.take(3)
.map(|(task, supervisor, state)| {
format!("Task {} held by supervisor Task {} ({})", task.id, supervisor, state)
})
.collect();
let suffix =
if held.len() > 3 { format!(" (+{} more)", held.len() - 3) } else { String::new() };
let mut supervisors: Vec<&TaskId> = Vec::new();
for (_, supervisor, _) in &held {
if !supervisors.contains(&supervisor) {
supervisors.push(supervisor);
}
}
let plan_arg = shell_quote(&plan_path.display().to_string());
let next_step = supervisors
.into_iter()
.find_map(|id| {
supervisor_next_step(rhei, machines, id, &plan_arg, state_machine_path)
})
.unwrap_or_default();
return format!(
"no tickets are ready to claim{}: {} ticket(s) held by a supervisor: {}{}.{}",
scope_suffix,
held.len(),
items.join(", "),
suffix,
next_step
);
}
let ready_non_initial: Vec<&rhei_core::ast::Task> = non_terminal_workable
.iter()
.copied()
.filter(|t| {
let machine = machines.for_task(&t.id);
let s = normalized_state_name(t.state.as_str(), machine);
let gating = machine.states.get(&s).map(|def| def.gating).unwrap_or(false);
!gating && !task_is_in_initial_state(t, &s, machine) && priors_satisfied(t)
})
.collect();
if let Some(task) = ready_non_initial.first() {
let machine = machines.for_task(&task.id);
let state_name = normalized_state_name(task.state.as_str(), machine);
let plan_arg = shell_quote(&plan_path.display().to_string());
let normalized_metadata = ensure_current_state_visit_count(
rhei.metadata.as_ref(),
&task.id,
&state_name,
task.state.as_str(),
machine,
);
let metadata_for_checks = normalized_metadata.as_ref().or(rhei.metadata.as_ref());
let commands = transition_command_lines(
task,
&state_name,
machine,
metadata_for_checks,
&plan_arg,
state_machine_path,
);
let guidance = if commands.is_empty() {
"No outgoing transitions are currently applicable for this state.".to_string()
} else {
format!("Available transitions:\n{}", commands.join("\n"))
};
return format!(
"No tasks can be auto-claimed: Task {} is mid-workflow in state '{}'. \
Pick one of its outgoing transitions explicitly.\n{}",
task.id, state_name, guidance
);
}
let blocked: Vec<&rhei_core::ast::Task> =
non_terminal_workable.iter().copied().filter(|t| !priors_satisfied(t)).collect();
if !blocked.is_empty() {
let ids: Vec<String> = blocked
.iter()
.take(3)
.map(|task| {
if let Some(prior) = first_blocking_prior(task, &state_map, machines, scope) {
format!("Task {} waiting on {}", task.id, prior)
} else {
format!("Task {}", task.id)
}
})
.collect();
let suffix = if blocked.len() > 3 {
format!(" (+{} more)", blocked.len() - 3)
} else {
String::new()
};
return format!(
"no tickets are ready to claim{}: {} ticket(s) blocked by incomplete prerequisites: {}{}.",
scope_suffix,
blocked.len(),
ids.join(", "),
suffix
);
}
format!("no tickets are ready to claim{scope_suffix}")
}