use std::collections::HashMap;
use myko_macros::myko_command;
use myko::command::{CommandContext, CommandError, CommandHandler};
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]
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<(), 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 resources = match ctx.exec_query(GetAllInventorys {})?.into_iter().next() {
Some(inv) => derive_resources(self.limit.as_deref(), &inv),
None => Vec::new(),
};
let run_id = RunId::from(uuid::Uuid::new_v4().to_string());
let now = chrono::Utc::now().to_rfc3339();
let run = Run {
playbook_id: self.playbook_id,
status: RunStatus::Queued,
started_at: now,
finished_at: None,
exit_code: None,
extra_vars: self.extra_vars,
limit: self.limit,
resources,
host_stats: Default::default(),
client_id: ctx.client_id().map(|c| c.to_string()),
claimed_by: None, id: run_id,
};
ctx.emit_set(&run)?;
Ok(())
}
}
#[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),
})?;
if !matches!(current.status, RunStatus::Queued | RunStatus::Running) {
return Ok(());
}
let mut updated = (*current).clone();
updated.status = RunStatus::Cancelled;
ctx.emit_set(&updated)?;
Ok(())
}
}
#[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 mut updated = (*current).clone();
if updated.status != RunStatus::Cancelled {
updated.status = if self.exit_code == Some(0) {
RunStatus::Completed
} else {
RunStatus::Failed
};
}
updated.exit_code = self.exit_code;
updated.finished_at = Some(chrono::Utc::now().to_rfc3339());
ctx.emit_set(&updated)?;
Ok(())
}
}
#[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,
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);
}
}