use crate::config::{Action, Event, Workflow};
use crate::prompt::Error;
use crate::prompt::inbox::Epitaph;
use crate::template::GitRunner;
use std::path::Path;
const ABANDONED_REF_PREFIX: &str = "refs/lernie/abandoned/";
const NOTIFY_REF_PREFIX: &str = "refs/lernie/notify/";
pub(super) fn lifecycle_event(epitaph: Epitaph) -> Option<Event> {
match epitaph {
Epitaph::Stopped => Some(Event::BranchStopped),
Epitaph::FinalResponse | Epitaph::BudgetExhausted | Epitaph::Died => None,
}
}
pub(super) fn run_terminal_bindings(
workflow: &Workflow,
epitaph: Epitaph,
worktree: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<(), Error> {
let Some(event) = lifecycle_event(epitaph) else {
return Ok(());
};
run_event(workflow, event, worktree, agent_id, git)
}
pub(super) fn run_step_hook(
workflow: &Workflow,
event: Event,
worktree: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<(), Error> {
run_event(workflow, event, worktree, agent_id, git)
}
fn run_event(
workflow: &Workflow,
event: Event,
worktree: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<(), Error> {
for action in workflow.actions_for(event) {
execute(&action, event, worktree, agent_id, git)?;
}
Ok(())
}
pub(super) fn execute(
action: &Action,
event: Event,
worktree: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<(), Error> {
match action {
Action::MarkAbandoned => mark_ref(ABANDONED_REF_PREFIX, worktree, agent_id, git),
Action::NotifyUi => mark_ref(NOTIFY_REF_PREFIX, worktree, agent_id, git),
deferred @ (Action::Dispatch { .. }
| Action::GateReturnOn { .. }
| Action::DeliverResult
| Action::LandCompaction) => Err(Error::ActionUnsupported {
action: format!("{deferred:?}"),
event: event.as_str(),
}),
}
}
fn mark_ref(
prefix: &str,
worktree: &Path,
agent_id: &str,
git: &dyn GitRunner,
) -> Result<(), Error> {
let ref_name = format!("{prefix}{agent_id}");
git.run(worktree, &["update-ref", ref_name.as_str(), "HEAD"])
.map_err(|source| Error::Git {
op: "workflow mark update-ref",
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::io;
#[derive(Default)]
struct RecGit {
runs: RefCell<Vec<Vec<String>>>,
fail: bool,
}
impl GitRunner for RecGit {
fn run(&self, _dest: &Path, args: &[&str]) -> io::Result<()> {
self.runs
.borrow_mut()
.push(args.iter().map(|s| s.to_string()).collect());
if self.fail {
Err(io::Error::other("update-ref failed"))
} else {
Ok(())
}
}
fn run_capture(&self, _dest: &Path, _args: &[&str]) -> io::Result<String> {
unreachable!("interpreter marks never capture")
}
}
fn workflow(yaml: &str) -> Workflow {
Workflow::parse(yaml, Path::new("workflow.yaml")).unwrap()
}
#[test]
fn stopped_epitaph_names_branch_stopped_others_none() {
assert_eq!(
lifecycle_event(Epitaph::Stopped),
Some(Event::BranchStopped)
);
assert_eq!(lifecycle_event(Epitaph::FinalResponse), None);
assert_eq!(lifecycle_event(Epitaph::BudgetExhausted), None);
assert_eq!(lifecycle_event(Epitaph::Died), None);
}
#[test]
fn branch_stopped_runs_both_ref_marks_in_order() {
let w = workflow("events:\n branch_stopped:\n - mark_abandoned\n - notify_ui\n");
let git = RecGit::default();
run_terminal_bindings(&w, Epitaph::Stopped, Path::new("/wt"), "a-b", &git).unwrap();
let runs = git.runs.borrow();
assert_eq!(
runs[0],
vec!["update-ref", "refs/lernie/abandoned/a-b", "HEAD"]
);
assert_eq!(
runs[1],
vec!["update-ref", "refs/lernie/notify/a-b", "HEAD"]
);
}
#[test]
fn no_lifecycle_event_is_a_noop() {
let w = workflow("events:\n branch_stopped:\n - mark_abandoned\n");
let git = RecGit::default();
run_terminal_bindings(&w, Epitaph::FinalResponse, Path::new("/wt"), "a-b", &git).unwrap();
assert!(git.runs.borrow().is_empty());
}
#[test]
fn unbound_event_is_a_noop() {
let w = workflow("events: {}\n");
let git = RecGit::default();
run_terminal_bindings(&w, Epitaph::Stopped, Path::new("/wt"), "a-b", &git).unwrap();
assert!(git.runs.borrow().is_empty());
}
#[test]
fn unsupported_action_is_declined_loudly() {
let w = workflow("events:\n branch_stopped:\n - dispatch(worker)\n");
let git = RecGit::default();
let err =
run_terminal_bindings(&w, Epitaph::Stopped, Path::new("/wt"), "a-b", &git).unwrap_err();
match err {
Error::ActionUnsupported { action, event } => {
assert!(action.contains("Dispatch"), "got {action}");
assert_eq!(event, "branch_stopped");
}
other => panic!("expected ActionUnsupported, got {other:?}"),
}
}
#[test]
fn ref_mark_git_failure_is_surfaced() {
let w = workflow("events:\n branch_stopped:\n - mark_abandoned\n");
let git = RecGit {
fail: true,
..Default::default()
};
let err =
run_terminal_bindings(&w, Epitaph::Stopped, Path::new("/wt"), "a-b", &git).unwrap_err();
assert!(matches!(err, Error::Git { .. }), "got {err:?}");
}
}