use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use regex::Regex;
use super::types::{
DagNode, DagNodeDef, DagRun, DagRunDef, DagStatus, Direction, NodeStatus, RunKind,
};
pub fn status_tag(status: &NodeStatus) -> &'static str {
match status {
NodeStatus::Pending => "[wait]",
NodeStatus::Ready => "[ready]",
NodeStatus::Running => "[run]",
NodeStatus::Succeeded => "[done]",
NodeStatus::Failed => "[fail]",
NodeStatus::Skipped => "[skip]",
NodeStatus::Cancelled => "[cancel]",
}
}
pub fn node_status_label(status: &NodeStatus) -> &'static str {
match status {
NodeStatus::Pending => "pending",
NodeStatus::Ready => "ready",
NodeStatus::Running => "running",
NodeStatus::Succeeded => "succeeded",
NodeStatus::Failed => "failed",
NodeStatus::Skipped => "skipped",
NodeStatus::Cancelled => "cancelled",
}
}
pub fn dag_status_label(status: &DagStatus) -> &'static str {
match status {
DagStatus::Running => "running",
DagStatus::Completed => "completed",
DagStatus::Failed => "failed",
DagStatus::Cancelled => "cancelled",
}
}
pub fn deps_prefix(node: &DagNode) -> String {
if node.depends_on.is_empty() {
String::new()
} else {
format!("[{}] ", node.depends_on.join(","))
}
}
pub fn is_terminal(status: &NodeStatus) -> bool {
matches!(
status,
NodeStatus::Succeeded | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled
)
}
pub fn is_blocked(status: &NodeStatus) -> bool {
matches!(status, NodeStatus::Failed | NodeStatus::Cancelled)
}
pub fn fmt_dur(ms: i64) -> String {
if ms < 0 {
return "–".to_string();
}
let s = ms / 1000;
if s < 60 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let m = s / 60;
if m < 60 {
format!("{m}m{}s", s % 60)
} else {
format!("{}h{}m", m / 60, m % 60)
}
}
}
static ID_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_-]+$").unwrap());
pub fn validate_graph(nodes: &[DagNodeDef], known_agents: Option<&[String]>) -> Vec<String> {
let mut errors: Vec<String> = Vec::new();
let mut ids: HashSet<String> = HashSet::new();
for n in nodes {
if !ID_RE.is_match(&n.id) {
errors.push(format!(
"节点 id \"{}\" 非法: 仅允许字母数字和下划线 (mermaid 兼容)",
n.id
));
continue;
}
if ids.contains(&n.id) {
errors.push(format!("重复的节点 id \"{}\"", n.id));
}
ids.insert(n.id.clone());
if n.agent.is_empty() {
errors.push(format!("节点 \"{}\" 缺少 agent", n.id));
} else if let Some(agents) = known_agents {
if !agents.is_empty() && !agents.contains(&n.agent) {
errors.push(format!(
"节点 \"{}\" 引用了未知 subagent \"{}\" (可用: {} 或 \"none\")",
n.id,
n.agent,
agents.join(", ")
));
}
}
if n.task.trim().is_empty() {
errors.push(format!("节点 \"{}\" 缺少 task 描述", n.id));
}
if let Some(deps) = &n.depends_on {
for dep in deps {
if dep == &n.id {
errors.push(format!("节点 \"{}\" 不能依赖自己", n.id));
} else if !ids.contains(dep) && !nodes.iter().any(|x| &x.id == dep) {
errors.push(format!("节点 \"{}\" 依赖了不存在的节点 \"{}\"", n.id, dep));
}
}
}
}
let mut remaining: Vec<String> = Vec::new();
for n in nodes {
if !remaining.iter().any(|r| r == &n.id) {
remaining.push(n.id.clone());
}
}
let by_id: HashMap<&str, &DagNodeDef> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut changed = true;
while changed {
changed = false;
let mut i = 0;
while i < remaining.len() {
let id = &remaining[i];
let node = by_id
.get(id.as_str())
.expect("remaining ids come from nodes");
let deps_satisfied = node
.depends_on
.as_ref()
.is_none_or(|deps| deps.iter().all(|dep| !remaining.iter().any(|r| r == dep)));
if deps_satisfied {
remaining.remove(i);
changed = true;
} else {
i += 1;
}
}
}
if !remaining.is_empty() {
errors.push(format!("检测到依赖环: {}", remaining.join(", ")));
}
errors
}
pub fn build_run(def: &DagRunDef) -> DagRun {
let now = now_ms();
let nodes: Vec<DagNode> = def
.nodes
.iter()
.map(|n| DagNode {
id: n.id.clone(),
agent: n.agent.clone(),
task: n.task.clone(),
depends_on: n.depends_on.clone().unwrap_or_default(),
timeout: n.timeout,
cwd: n.cwd.clone(),
provider: n.provider.clone(),
model: n.model.clone(),
thinking: n.thinking.clone(),
max_iterations: n.max_iterations,
tools: n.tools.clone(),
status: NodeStatus::Pending,
job_id: None,
attempt: 0,
launch_gen: 0,
started_at: None,
completed_at: None,
error: None,
input_tokens: None,
output_tokens: None,
result: None,
output: None,
live_preview: None,
last_active_at: None,
})
.collect();
DagRun {
id: String::new(), name: def.name.clone(),
nodes,
status: DagStatus::Running,
kind: RunKind::Dag,
max_concurrency: def.max_concurrency.unwrap_or(10).max(1),
fail_fast: def.fail_fast.unwrap_or(false),
direction: def.direction.clone().unwrap_or(Direction::Td),
created_at: now,
session_id: None,
completed_at: None,
last_activity_at: now,
error: None,
}
}
pub(crate) fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
pub fn downstream_closure(nodes: &[DagNode], start_id: &str) -> Vec<String> {
let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
for n in nodes {
for dep in &n.depends_on {
dependents.entry(dep.as_str()).or_default().push(&n.id);
}
}
let mut seen: Vec<String> = Vec::new();
let mut stack = vec![start_id.to_string()];
while let Some(id) = stack.pop() {
if let Some(children) = dependents.get(id.as_str()) {
for child in children {
if !seen.iter().any(|s| s == child) {
seen.push((*child).to_string());
stack.push((*child).to_string());
}
}
}
}
seen
}
pub fn reconcile(run: &mut DagRun) {
let mut statuses: HashMap<String, NodeStatus> = run
.nodes
.iter()
.map(|n| (n.id.clone(), n.status.clone()))
.collect();
for node in &mut run.nodes {
if is_terminal(&node.status) || node.status == NodeStatus::Running {
continue;
}
let deps: Vec<&str> = node
.depends_on
.iter()
.filter(|id| statuses.contains_key(id.as_str()))
.map(|s| s.as_str())
.collect();
let blockers: Vec<&str> = deps
.iter()
.copied()
.filter(|id| is_blocked(statuses.get(*id).expect("deps filtered by presence")))
.collect();
if !blockers.is_empty() {
node.status = NodeStatus::Cancelled;
node.completed_at = Some(now_ms());
node.error = Some(format!("blocked by {}", blockers.join(", ")));
statuses.insert(node.id.clone(), NodeStatus::Cancelled);
continue;
}
let all_done = deps.iter().all(|id| {
matches!(
statuses.get(*id),
Some(NodeStatus::Succeeded) | Some(NodeStatus::Skipped)
)
});
node.status = if all_done {
NodeStatus::Ready
} else {
NodeStatus::Pending
};
statuses.insert(node.id.clone(), node.status.clone());
}
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("multiagent/graph/model");