use std::collections::HashMap;
use myko::command::{CommandContext, CommandError, CommandHandler};
use myko_macros::myko_command;
use serde_json::Value;
use crate::{
derive_resources, GetAllInventorys, GetPinsByIds, GetPlaybooksByIds, GetRunsByIds, HostStats,
OutputLine, OutputLineId, OutputStream, Pin, PinId, Playbook, PlaybookId, Run, RunId, RunStatus,
};
#[myko_command(RunId)]
pub struct RunPlaybook {
pub playbook_id: PlaybookId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_vars: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<String>,
}
impl CommandHandler for RunPlaybook {
fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
let run = prepare_playbook_run(&ctx, self)?;
let run_id = run.id.clone();
ctx.emit_set(&run)?;
Ok(run_id)
}
}
pub fn prepare_playbook_run(
ctx: &CommandContext,
command: RunPlaybook,
) -> Result<Run, CommandError> {
ctx.exec_query_first(GetPlaybooksByIds {
ids: vec![command.playbook_id.clone()],
})?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!("Playbook {} not found", command.playbook_id.0),
})?;
let resources = match ctx.exec_query(GetAllInventorys {})?.into_iter().next() {
Some(inv) => derive_resources(command.limit.as_deref(), &inv),
None => Vec::new(),
};
Ok(Run {
playbook_id: command.playbook_id,
status: RunStatus::Queued,
started_at: chrono::Utc::now().to_rfc3339(),
cancel_requested_at: None,
finished_at: None,
exit_code: None,
extra_vars: command.extra_vars,
limit: command.limit,
resources,
host_stats: Default::default(),
client_id: ctx.client_id().map(|c| c.to_string()),
claimed_by: None, id: RunId::from(uuid::Uuid::new_v4().to_string()),
})
}
#[myko_command]
pub struct CancelRun {
pub run_id: RunId,
}
impl CommandHandler for CancelRun {
fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
let current = ctx
.exec_query_first(GetRunsByIds {
ids: vec![self.run_id.clone()],
})?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!("Run {} not found", self.run_id.0),
})?;
let Some(updated) = request_cancel_transition(¤t, chrono::Utc::now().to_rfc3339())
else {
return Ok(());
};
ctx.emit_set(&updated)?;
Ok(())
}
}
pub fn request_cancel_transition(current: &Run, requested_at: String) -> Option<Run> {
if !matches!(current.status, RunStatus::Queued | RunStatus::Running) {
return None;
}
let mut updated = current.clone();
let requested_at = updated.cancel_requested_at.clone().unwrap_or(requested_at);
updated.cancel_requested_at = Some(requested_at.clone());
if updated.status == RunStatus::Queued || updated.claimed_by.is_none() {
updated.status = RunStatus::Cancelled;
updated.finished_at = Some(requested_at);
updated.exit_code = None;
}
Some(updated)
}
#[myko_command(RunId)]
pub struct ClaimRun {
pub run_id: RunId,
pub runner_id: String,
}
impl CommandHandler for ClaimRun {
fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
let current = ctx
.exec_query_first(GetRunsByIds {
ids: vec![self.run_id.clone()],
})?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!("Run {} not found", self.run_id.0),
})?;
if current.status != RunStatus::Running {
return Err(CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!(
"ClaimRun: run {} is {:?}, not Running — nothing to execute",
self.run_id.0, current.status
),
});
}
match ¤t.claimed_by {
Some(holder) if *holder == self.runner_id => return Ok(self.run_id), Some(holder) => {
return Err(CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!(
"ClaimRun: run {} already claimed by '{holder}'",
self.run_id.0
),
});
}
None => {}
}
let mut updated = (*current).clone();
updated.claimed_by = Some(self.runner_id);
ctx.emit_set(&updated)?;
Ok(self.run_id)
}
}
#[myko_command]
pub struct PinPlaybook {
pub playbook_id: PlaybookId,
}
impl CommandHandler for PinPlaybook {
fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
ctx.exec_query_first(GetPlaybooksByIds {
ids: vec![self.playbook_id.clone()],
})?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!("Playbook {} not found", self.playbook_id.0),
})?;
let pin_id = PinId::from(self.playbook_id.0.clone());
if let Some(existing) = ctx.exec_query_first(GetPinsByIds {
ids: vec![pin_id.clone()],
})? {
if existing.locked {
return Ok(());
}
}
let pin = Pin {
playbook_id: self.playbook_id,
locked: false,
id: pin_id,
};
ctx.emit_set(&pin)?;
Ok(())
}
}
#[myko_command]
pub struct AppendRunOutput {
pub run_id: RunId,
pub stream: OutputStream,
pub line: String,
pub seq: u64,
}
impl CommandHandler for AppendRunOutput {
fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
let entity = OutputLine {
id: OutputLineId::from(format!("{}:{}", self.run_id.0, self.seq)),
run_id: self.run_id,
stream: self.stream,
line: self.line,
seq: self.seq,
};
ctx.emit_set(&entity)?;
Ok(())
}
}
#[myko_command]
pub struct FinishRun {
pub run_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
impl CommandHandler for FinishRun {
fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
let current = ctx
.exec_query_first(GetRunsByIds {
ids: vec![self.run_id.clone()],
})?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!("Run {} not found", self.run_id.0),
})?;
let updated = finish_transition(¤t, self.exit_code, chrono::Utc::now().to_rfc3339());
ctx.emit_set(&updated)?;
Ok(())
}
}
fn finish_transition(current: &Run, exit_code: Option<i32>, finished_at: String) -> Run {
let mut updated = current.clone();
if updated.cancel_requested_at.is_some() || updated.status == RunStatus::Cancelled {
updated.status = RunStatus::Cancelled;
} else {
updated.status = if exit_code == Some(0) {
RunStatus::Completed
} else {
RunStatus::Failed
};
}
updated.exit_code = exit_code;
updated.finished_at = Some(finished_at);
updated
}
#[myko_command]
pub struct DeclarePlaybooks {
pub playbooks: Vec<Playbook>,
}
impl CommandHandler for DeclarePlaybooks {
fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
for pb in self.playbooks {
ctx.emit_set(&pb)?;
}
Ok(())
}
}
#[myko_command(RunId)]
pub struct RecordRun {
pub playbook_id: String,
pub status: RunStatus,
pub started_at: String,
pub finished_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_vars: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_stats: Option<HashMap<String, HostStats>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<RunId>,
}
impl CommandHandler for RecordRun {
fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
if let Some(rid) = self.run_id {
let current = ctx
.exec_query_first(GetRunsByIds { ids: vec![rid.clone()] })?
.ok_or_else(|| CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!(
"RecordRun attach: run {} not found — TACHYON_RUN_ID names a run this cell does not have",
rid.0
),
})?;
let mut updated = (*current).clone();
if let Some(hs) = self.host_stats {
updated.host_stats = hs;
}
if updated.limit.is_none() {
updated.limit = self.limit;
}
ctx.emit_set(&updated)?;
return Ok(rid);
}
if !matches!(
self.status,
RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
) {
return Err(CommandError {
tx: ctx.tx().to_string(),
command_id: ctx.command_id.to_string(),
message: format!(
"RecordRun records COMPLETED external ops — status must be completed/failed/cancelled, not {:?} (a non-terminal run would be double-executed by the runner)",
self.status
),
});
}
let id = RunId::from(uuid::Uuid::new_v4().to_string());
let run = Run {
id: id.clone(),
playbook_id: PlaybookId::from(self.playbook_id),
status: self.status,
started_at: self.started_at,
cancel_requested_at: None,
finished_at: Some(self.finished_at),
exit_code: self.exit_code,
extra_vars: self.extra_vars,
limit: self.limit,
resources: Vec::new(),
host_stats: self.host_stats.unwrap_or_default(),
client_id: ctx.client_id().map(|c| c.to_string()),
claimed_by: None, };
ctx.emit_set(&run)?;
Ok(id)
}
}
#[cfg(test)]
mod record_run_tests {
use super::*;
#[test]
fn record_run_without_run_id_is_mint_shape() {
let old: RecordRun = serde_json::from_str(
r#"{"playbookId":"ue-cluster-up","status":"completed",
"startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z"}"#,
)
.unwrap();
assert!(old.run_id.is_none());
assert!(old.host_stats.is_none());
}
#[test]
fn record_run_with_run_id_is_attach_shape() {
let attach: RecordRun = serde_json::from_str(
r#"{"playbookId":"ue-cluster-up","status":"completed",
"startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z",
"runId":"abc-123",
"hostStats":{"render-01":{"ok":5,"changed":1,"unreachable":0,"failed":0,"skipped":0,"rescued":0,"ignored":0}}}"#,
)
.unwrap();
assert_eq!(attach.run_id.as_ref().map(|r| r.0.as_ref()), Some("abc-123"));
assert_eq!(attach.host_stats.unwrap()["render-01"].ok, 5);
}
}
#[cfg(test)]
mod cancellation_tests {
use super::{finish_transition, request_cancel_transition};
use crate::{PlaybookId, Run, RunId, RunStatus};
fn run(status: RunStatus, claimed_by: Option<&str>) -> Run {
Run {
id: RunId::from("run-1".to_string()),
playbook_id: PlaybookId::from("playbook".to_string()),
status,
started_at: "start".into(),
cancel_requested_at: None,
finished_at: None,
exit_code: None,
extra_vars: None,
limit: None,
resources: vec!["render-01".into()],
host_stats: Default::default(),
client_id: None,
claimed_by: claimed_by.map(str::to_owned),
}
}
#[test]
fn claimed_running_cancel_stays_nonterminal_until_finish_ack() {
let requested =
request_cancel_transition(&run(RunStatus::Running, Some("runner")), "cancel-at".into())
.expect("running run is cancellable");
assert_eq!(requested.status, RunStatus::Running);
assert_eq!(requested.cancel_requested_at.as_deref(), Some("cancel-at"));
assert!(requested.finished_at.is_none());
let finished = finish_transition(&requested, None, "finished-at".into());
assert_eq!(finished.status, RunStatus::Cancelled);
assert_eq!(finished.finished_at.as_deref(), Some("finished-at"));
}
#[test]
fn queued_and_unclaimed_running_cancel_immediately() {
for initial in [run(RunStatus::Queued, None), run(RunStatus::Running, None)] {
let cancelled =
request_cancel_transition(&initial, "cancel-at".into()).expect("in-flight run");
assert_eq!(cancelled.status, RunStatus::Cancelled);
assert_eq!(cancelled.finished_at.as_deref(), Some("cancel-at"));
}
}
#[test]
fn repeated_cancel_is_idempotent_and_terminal_cancel_is_untouched() {
let first =
request_cancel_transition(&run(RunStatus::Running, Some("runner")), "first".into())
.unwrap();
let second = request_cancel_transition(&first, "second".into()).unwrap();
assert_eq!(second.cancel_requested_at.as_deref(), Some("first"));
let terminal = finish_transition(&second, None, "done".into());
assert!(request_cancel_transition(&terminal, "later".into()).is_none());
}
#[test]
fn finish_without_cancel_uses_exit_code() {
assert_eq!(
finish_transition(
&run(RunStatus::Running, Some("runner")),
Some(0),
"done".into()
)
.status,
RunStatus::Completed
);
assert_eq!(
finish_transition(
&run(RunStatus::Running, Some("runner")),
Some(2),
"done".into()
)
.status,
RunStatus::Failed
);
}
}