use argonauts_core::api::ApiError;
use argonauts_core::app::{AppEvent, ChoiceKind, ViewData};
use argonauts_core::events::approval::{AnswerAction, ApprovalPacket};
use argonauts_core::events::task::{TaskEvent, TaskState};
use argonauts_core::events::{ApprovalEvent, RunEvent, RunEventKind, SessionEvent};
use argonauts_core::names::{AgentId, ApprovalId, ModelRef, RoleName};
use argonauts_core::settings::crew::CrewRole;
use argonauts_core::{NoteLevel, RunId, TaskId};
use super::views::run_events;
use super::{Handled, NO_APPROVALS, on_screen};
use crate::choices;
use crate::controller::runs::{RunChoice, RunsState};
use crate::controller::{Ctx, Effect, ScopeRequest};
use crate::views;
pub(crate) async fn reassign(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
task: TaskId,
to: ModelRef,
) -> Handled {
let starts_over = worker_of(&run_events(ctx, runs, &run), &task).as_ref() == Some(&to);
match ctx
.services
.conductor
.reassign(&run, &task, to.clone())
.await
{
Ok(()) => {
let name = ctx.names.worker(&to);
let said = if starts_over {
format!("Starting {task} over with {name}, with its work saved and a note of it.")
} else {
format!("Moving {task} to {name}, with its work saved and a fallback note.")
};
ctx.notice(NoteLevel::Info, said);
Ok(Vec::new())
}
Err(ApiError::Refused(why)) if starts_over => {
ctx.notice(NoteLevel::Warn, why);
Ok(Vec::new())
}
Err(ApiError::Refused(rule)) => {
confirm_override(ctx, runs, run, task, to, rule);
Ok(Vec::new())
}
Err(err) => super::refused(ctx, err, || format!("Couldn't reassign {task}")),
}
}
fn worker_of(events: &[RunEvent], task: &TaskId) -> Option<ModelRef> {
events
.iter()
.rev()
.filter(|event| event.task_id.as_ref() == Some(task))
.find_map(|event| match &event.kind {
RunEventKind::Task(TaskEvent::Try { worker, .. }) => Some(worker.clone()),
RunEventKind::Task(TaskEvent::Reassigned { to, .. }) => Some(to.clone()),
_ => None,
})
}
fn confirm_override(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
task: TaskId,
to: ModelRef,
rule: String,
) {
let name = ctx.names.worker(&to);
if ctx.services.approvals.is_none() {
ctx.notice(
NoteLevel::Warn,
format!("{name} can't take {task}: {rule}. {NO_APPROVALS}"),
);
return;
}
let id = ctx.next_choice_id();
let choice = choices::app_choice(
id.clone(),
ChoiceKind::ConfirmSafetySetting,
format!("Give {task} to {name} anyway?"),
vec![
format!("{name} breaks a rule of the task's role: {rule}."),
"Overriding it applies to this task only, and the run's log records it.".to_owned(),
],
vec![
choices::option('y', "Override the rule", AnswerAction::Approve, false),
choices::option(
'n',
"Keep the task where it is",
AnswerAction::Cancel,
false,
),
],
);
runs.choices.insert(
id,
RunChoice::OverrideRule {
run,
task,
to,
rule,
},
);
ctx.send(AppEvent::Choice { choice });
}
pub(crate) async fn slash_reassign(
ctx: &mut Ctx,
runs: &mut RunsState,
task: Option<TaskId>,
from: Option<AgentId>,
to: ModelRef,
) -> Handled {
let Some(run) = on_screen(ctx, runs) else {
return Ok(Vec::new());
};
let tasks = match (task, from) {
(Some(task), _) => vec![task],
(None, Some(agent_id)) => {
let summary = match ctx.services.conductor.status(&run).await {
Ok(summary) => summary,
Err(err) => {
return super::refused(ctx, err, || "Couldn't read the run's tasks".to_owned());
}
};
let tasks: Vec<TaskId> = summary
.tasks
.into_iter()
.filter(|brief| {
let moves = brief
.worker
.as_ref()
.is_some_and(|worker| worker.agent_id() == agent_id && *worker != to);
moves && !matches!(brief.state, TaskState::Merged | TaskState::Cancelled)
})
.map(|brief| brief.task)
.collect();
if tasks.is_empty() {
let name = ctx.names.cli(&agent_id.0);
ctx.notice(
NoteLevel::Info,
format!("{run} has no open task to move from {name}."),
);
}
tasks
}
(None, None) => {
ctx.notice(
NoteLevel::Info,
"Name a task or an agent: /reassign <task> <worker>, or /reassign --from <agent> \
<worker>.",
);
return Ok(Vec::new());
}
};
let mut effects = Vec::new();
for task in tasks {
effects.extend(reassign(ctx, runs, run.clone(), task, to.clone()).await?);
}
Ok(effects)
}
pub(crate) async fn remember_pick(
ctx: &mut Ctx,
runs: &mut RunsState,
run: RunId,
id: ApprovalId,
) -> Handled {
remember(ctx, runs, &run, &id, None)
}
pub(super) fn remember(
ctx: &mut Ctx,
runs: &mut RunsState,
run: &RunId,
id: &ApprovalId,
pick: Option<ModelRef>,
) -> Handled {
let events = run_events(ctx, runs, run);
let Some(rule) = fallback_rule(&events, id, pick) else {
ctx.notice(
NoteLevel::Info,
format!("{id} has no fallback pick to remember."),
);
return Ok(Vec::new());
};
Ok(vec![Effect::AskScope(Box::new(rule))])
}
pub(super) fn fallback_rule(
events: &[RunEvent],
id: &ApprovalId,
pick: Option<ModelRef>,
) -> Option<ScopeRequest> {
let plan = events.iter().find_map(|event| match &event.kind {
RunEventKind::Approval(ApprovalEvent::Opened(opened)) if &opened.id == id => {
match &opened.packet {
ApprovalPacket::Fallback(plan) => Some(plan),
_ => None,
}
}
_ => None,
})?;
let pick = pick.or_else(|| recorded_pick(events, id))?;
let role = stopped_role(events, &plan.task, &plan.stopped);
let key = format!(
"fallback.{}.replace.{}.with",
CrewRole::fallback_of(role).as_str(),
plan.stopped.agent_id()
);
let value = toml::Value::Array(vec![toml::Value::String(pick.to_string())]).to_string();
Some(ScopeRequest {
key,
value,
command: "fallback".to_owned(),
})
}
fn recorded_pick(events: &[RunEvent], id: &ApprovalId) -> Option<ModelRef> {
events.iter().rev().find_map(|event| match &event.kind {
RunEventKind::Approval(ApprovalEvent::Decided(decided)) if &decided.id == id => {
match &decided.action {
AnswerAction::Candidate { worker } => Some(worker.clone()),
_ => None,
}
}
_ => None,
})
}
fn stopped_role(events: &[RunEvent], task: &TaskId, stopped: &ModelRef) -> RoleName {
let sessions = events.iter().rev().filter_map(|event| match &event.kind {
RunEventKind::Session(SessionEvent::Started(started))
if event.task_id.as_ref() == Some(task) =>
{
Some(started)
}
_ => None,
});
let mut any = None;
for started in sessions {
if &started.worker == stopped {
return started.role;
}
any.get_or_insert(started.role);
}
any.unwrap_or(RoleName::Worker)
}
pub(crate) async fn checkpoints(
ctx: &mut Ctx,
runs: &mut RunsState,
task: Option<TaskId>,
) -> Handled {
let Some(run) = on_screen(ctx, runs) else {
return Ok(Vec::new());
};
let events = run_events(ctx, runs, &run);
let checkpoints = views::checkpoint_rows(&events, task.as_ref());
if checkpoints.is_empty() {
let text = match &task {
Some(task) => format!("{task} has no fallback checkpoints to go back to."),
None => format!("No task of {run} has a fallback checkpoint to go back to."),
};
ctx.notice(NoteLevel::Info, text);
return Ok(Vec::new());
}
ctx.send(AppEvent::View {
data: Box::new(ViewData::Checkpoints {
run,
task,
checkpoints,
}),
});
Ok(Vec::new())
}
pub(crate) async fn rewind(
ctx: &mut Ctx,
runs: &mut RunsState,
task: TaskId,
checkpoint: u32,
) -> Handled {
let Some(run) = on_screen(ctx, runs) else {
return Ok(Vec::new());
};
let id = ctx.next_choice_id();
let choice = choices::app_choice(
id.clone(),
ChoiceKind::ConfirmRewind,
format!("Rewind {task} to fallback-{checkpoint}?"),
vec![
format!("Its branch goes back to argonauts/{run}/{task}/fallback-{checkpoint}."),
format!(
"The work after it stays on argonauts/{run}/{task}-replaced-{checkpoint}, so \
nothing is lost."
),
],
choices::options(ChoiceKind::ConfirmRewind),
);
runs.choices.insert(
id,
RunChoice::Rewind {
run,
task,
checkpoint,
},
);
ctx.send(AppEvent::Choice { choice });
Ok(Vec::new())
}