use std::collections::{BTreeMap, BTreeSet};
use std::sync::mpsc::Sender;
use onevcs::SessionRequest;
use crate::engine::{self, Message, Settlement};
use crate::executor::{DispatchRequest, Executor, WorkspaceSpec};
use crate::graph::NodeStatus;
use crate::plan::{Node, NodeKind, Step};
pub const PR_AUTHOR_PERSONA: &str = "pr-author";
pub fn execute(
executor: &dyn Executor,
run: &str,
round: u64,
node: &Node,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> Settlement {
let Some(request) = crate::vcs::request_for(node) else {
return Settlement {
detail: Some("a lifecycle node needs a repo".into()),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("invalid-node"))
};
};
let declared_steps = node.steps.is_some();
let steps = match ordered_steps(node) {
Ok(steps) => steps,
Err(reason) => {
return Settlement {
detail: Some(reason),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("invalid-node"))
}
}
};
let mut session: Option<String> = None;
let mut branch: Option<String> = node.branch.clone();
let mut completed: Vec<String> = node
.resume
.as_ref()
.map(|resume| resume.completed_steps.clone())
.unwrap_or_default();
for step in &steps {
if declared_steps && completed.iter().any(|id| id == &step.id) {
continue;
}
if step.kind == NodeKind::Human {
return Settlement {
branch,
completed_steps: completed,
..Settlement::plain(&node.id, NodeStatus::Waiting, None)
};
}
if step.expects_no_diff {
continue;
}
let request = SessionRequest {
branch: branch.clone().or_else(|| request.branch.clone()),
..request.clone()
};
let build = || DispatchRequest {
graph: engine::node_graph(step.agent_graph.as_ref().or(node.agent_graph.as_ref())),
task: step.rendered_task(node.context.as_deref()),
labels: engine::dispatch_labels(
run,
round,
&node.id,
declared_steps.then_some(step.id.as_str()),
step.persona.as_deref(),
),
workspace: WorkspaceSpec::VcsSession(request.clone()),
cancel: cancel.clone(),
};
let drained = engine::attempt(executor, &node.id, engine::Role::Worker, cancel, tx, &build);
session = drained.session.or(session);
branch = drained.branch.or(branch);
if drained.settlement.status != NodeStatus::Done {
relay_session_events(tx, session.as_deref());
close(session.as_deref());
return Settlement {
branch,
completed_steps: completed,
..drained.settlement
};
}
if declared_steps {
completed.push(step.id.clone());
}
}
let Some(token) = session else {
return Settlement {
branch,
..Settlement::plain(&node.id, NodeStatus::Done, Some("no-changes"))
};
};
publish(executor, run, round, node, cancel, tx, &token, branch)
}
#[allow(
clippy::too_many_arguments,
reason = "publication needs the dispatch context (executor, run, round, node, cancel, \
stream) as well as what the steps left behind (the session token and its \
branch); the first six are the node's own dispatch identity and bundling \
them would only move the same list one indirection away"
)]
fn publish(
executor: &dyn Executor,
run: &str,
round: u64,
node: &Node,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
token: &str,
branch: Option<String>,
) -> Settlement {
let title = node
.title
.clone()
.unwrap_or_else(|| draft_title(executor, run, round, node, cancel, tx));
let published = crate::vcs::publish(token, node.merge_policy, Some(&title));
relay_session_events(tx, Some(token));
close(Some(token));
match published {
Ok(published) => {
let labels =
engine::dispatch_labels(run, round, &node.id, None, node.persona.as_deref());
let branch_name = branch.clone().unwrap_or_default();
let _ = tx.send(Message::Event(Box::new(crate::vcs::published_event(
&published,
&branch_name,
&labels,
))));
Settlement {
branch,
change_url: published.url.clone(),
outcome: published.outcome.clone().or(Some("published".into())),
..Settlement::plain(&node.id, NodeStatus::Done, None)
}
}
Err(error) => Settlement {
branch,
detail: Some(error.to_string()),
..Settlement::plain(&node.id, NodeStatus::Failed, Some("publication-failed"))
},
}
}
fn draft_title(
executor: &dyn Executor,
run: &str,
round: u64,
node: &Node,
cancel: &crate::executor::CancellationToken,
tx: &Sender<Message>,
) -> String {
let fallback = deterministic_title(node);
let Some(request) = crate::vcs::request_for(node) else {
return fallback;
};
let dispatch = executor.dispatch(DispatchRequest {
graph: engine::node_graph(None),
task: format!(
"Read this branch's diff and write the change request's title and body, \
following the repository's own template. The task this branch delivered:\n\n{}",
node.rendered_task()
),
labels: engine::dispatch_labels(run, round, &node.id, None, Some(PR_AUTHOR_PERSONA)),
workspace: WorkspaceSpec::VcsSession(SessionRequest {
branch: node.branch.clone().or(request.branch.clone()),
..request
}),
cancel: cancel.clone(),
});
let Ok(mut handle) = dispatch else {
return fallback;
};
let mut drafted = None;
for envelope in handle.events() {
let Ok(envelope) = envelope else { continue };
if let Some(title) = envelope.payload.get("title").and_then(|v| v.as_str()) {
drafted = Some(title.to_string());
}
let _ = tx.send(Message::Event(Box::new(envelope)));
}
match handle.wait() {
Ok(outcome) if outcome.succeeded => drafted.unwrap_or(fallback),
_ => fallback,
}
}
pub fn deterministic_title(node: &Node) -> String {
node.title
.clone()
.unwrap_or_else(|| format!("chore: {}", node.id))
}
fn relay_session_events(tx: &Sender<Message>, token: Option<&str>) {
let Some(token) = token else { return };
for envelope in crate::vcs::events(token) {
let _ = tx.send(Message::Event(Box::new(envelope)));
}
}
fn close(token: Option<&str>) {
if let Some(token) = token {
let _ = crate::vcs::session_close(token);
}
}
pub fn ordered_steps(node: &Node) -> std::result::Result<Vec<Step>, String> {
let Some(steps) = &node.steps else {
return Ok(vec![Step {
id: node.id.clone(),
kind: node.kind,
task: node.task.clone(),
persona: node.persona.clone(),
deps: Vec::new(),
done_when: node.done_when.clone(),
max_turns: node.max_turns,
expects_no_diff: node.expects_no_diff,
executor: node.executor.clone(),
agent_graph: node.agent_graph.clone(),
}]);
};
let by_id: BTreeMap<&str, &Step> = steps.iter().map(|s| (s.id.as_str(), s)).collect();
let mut settled: BTreeSet<&str> = BTreeSet::new();
let mut order: Vec<Step> = Vec::new();
while order.len() < steps.len() {
let mut progressed = false;
for step in steps {
if settled.contains(step.id.as_str()) {
continue;
}
if step
.deps
.iter()
.all(|dep| settled.contains(dep.as_str()) || !by_id.contains_key(dep.as_str()))
{
settled.insert(step.id.as_str());
order.push(step.clone());
progressed = true;
}
}
if !progressed {
return Err(format!(
"node '{}': its steps have a dependency cycle",
node.id
));
}
}
Ok(order)
}
#[cfg(test)]
mod tests {
use super::*;
fn step(id: &str, deps: &[&str]) -> Step {
Step {
id: id.into(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
deps: deps.iter().map(|d| (*d).to_string()).collect(),
..Step::default()
}
}
fn lifecycle(steps: Option<Vec<Step>>) -> Node {
Node {
id: "service".into(),
repo: Some("owner/repo".into()),
persona: steps.is_none().then(|| "engineer".into()),
task: steps.is_none().then(|| "## What\nship".into()),
steps,
..Node::default()
}
}
#[test]
fn steps_run_serially_in_topological_order() {
let node = lifecycle(Some(vec![
step("publish", &["review"]),
step("implement", &[]),
step("review", &["implement"]),
]));
let order: Vec<String> = ordered_steps(&node)
.expect("the steps order")
.into_iter()
.map(|s| s.id)
.collect();
assert_eq!(order, vec!["implement", "review", "publish"]);
}
#[test]
fn steps_with_a_cycle_are_reported_rather_than_run_in_some_order() {
let node = lifecycle(Some(vec![step("a", &["b"]), step("b", &["a"])]));
let message = ordered_steps(&node).unwrap_err();
assert!(message.contains("dependency cycle"), "{message}");
}
#[test]
fn a_lifecycle_node_with_no_steps_is_one_implicit_step() {
let node = lifecycle(None);
let steps = ordered_steps(&node).expect("one implicit step");
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].id, "service");
assert_eq!(steps[0].persona.as_deref(), Some("engineer"));
}
#[test]
fn a_deterministic_title_is_the_same_every_time() {
let node = lifecycle(None);
assert_eq!(deterministic_title(&node), "chore: service");
let titled = Node {
title: Some("feat: ship the thing".into()),
..node
};
assert_eq!(deterministic_title(&titled), "feat: ship the thing");
}
#[test]
fn a_step_ordering_ignores_a_dependency_on_something_outside_the_node() {
let node = lifecycle(Some(vec![step("only", &["elsewhere"])]));
let steps = ordered_steps(&node).expect("an outside reference does not deadlock");
assert_eq!(steps.len(), 1);
}
}