1use std::collections::HashMap;
2
3use myko::command::{CommandContext, CommandError, CommandHandler};
4use myko_macros::myko_command;
5use serde_json::Value;
6
7use crate::{
8 derive_resources, GetAllInventorys, GetPinsByIds, GetPlaybooksByIds, GetRunsByIds, HostStats,
9 OutputLine, OutputLineId, OutputStream, Pin, PinId, Playbook, PlaybookId, Run, RunId, RunStatus,
10};
11
12#[myko_command(RunId)]
16pub struct RunPlaybook {
17 pub playbook_id: PlaybookId,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub extra_vars: Option<Value>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub limit: Option<String>,
22}
23
24impl CommandHandler for RunPlaybook {
25 fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
26 let run = prepare_playbook_run(&ctx, self)?;
27 let run_id = run.id.clone();
28 ctx.emit_set(&run)?;
29 Ok(run_id)
30 }
31}
32
33pub fn prepare_playbook_run(
39 ctx: &CommandContext,
40 command: RunPlaybook,
41) -> Result<Run, CommandError> {
42 ctx.exec_query_first(GetPlaybooksByIds {
44 ids: vec![command.playbook_id.clone()],
45 })?
46 .ok_or_else(|| CommandError {
47 tx: ctx.tx().to_string(),
48 command_id: ctx.command_id.to_string(),
49 message: format!("Playbook {} not found", command.playbook_id.0),
50 })?;
51
52 let resources = match ctx.exec_query(GetAllInventorys {})?.into_iter().next() {
57 Some(inv) => derive_resources(command.limit.as_deref(), &inv),
58 None => Vec::new(),
59 };
60
61 Ok(Run {
62 playbook_id: command.playbook_id,
63 status: RunStatus::Queued,
64 started_at: chrono::Utc::now().to_rfc3339(),
65 cancel_requested_at: None,
66 finished_at: None,
67 exit_code: None,
68 extra_vars: command.extra_vars,
69 limit: command.limit,
70 resources,
71 host_stats: Default::default(),
72 client_id: ctx.client_id().map(|c| c.to_string()),
77 claimed_by: None, id: RunId::from(uuid::Uuid::new_v4().to_string()),
79 })
80}
81
82#[myko_command]
90pub struct CancelRun {
91 pub run_id: RunId,
92}
93
94impl CommandHandler for CancelRun {
95 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
96 let current = ctx
97 .exec_query_first(GetRunsByIds {
98 ids: vec![self.run_id.clone()],
99 })?
100 .ok_or_else(|| CommandError {
101 tx: ctx.tx().to_string(),
102 command_id: ctx.command_id.to_string(),
103 message: format!("Run {} not found", self.run_id.0),
104 })?;
105
106 let Some(updated) = request_cancel_transition(¤t, chrono::Utc::now().to_rfc3339())
107 else {
108 return Ok(());
109 };
110 ctx.emit_set(&updated)?;
111 Ok(())
112 }
113}
114
115pub fn request_cancel_transition(current: &Run, requested_at: String) -> Option<Run> {
116 if !matches!(current.status, RunStatus::Queued | RunStatus::Running) {
118 return None;
119 }
120
121 let mut updated = current.clone();
122 let requested_at = updated.cancel_requested_at.clone().unwrap_or(requested_at);
123 updated.cancel_requested_at = Some(requested_at.clone());
124
125 if updated.status == RunStatus::Queued || updated.claimed_by.is_none() {
128 updated.status = RunStatus::Cancelled;
129 updated.finished_at = Some(requested_at);
130 updated.exit_code = None;
131 }
132 Some(updated)
133}
134
135#[myko_command(RunId)]
144pub struct ClaimRun {
145 pub run_id: RunId,
146 pub runner_id: String,
149}
150
151impl CommandHandler for ClaimRun {
152 fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
156 let current = ctx
157 .exec_query_first(GetRunsByIds {
158 ids: vec![self.run_id.clone()],
159 })?
160 .ok_or_else(|| CommandError {
161 tx: ctx.tx().to_string(),
162 command_id: ctx.command_id.to_string(),
163 message: format!("Run {} not found", self.run_id.0),
164 })?;
165
166 if current.status != RunStatus::Running {
167 return Err(CommandError {
168 tx: ctx.tx().to_string(),
169 command_id: ctx.command_id.to_string(),
170 message: format!(
171 "ClaimRun: run {} is {:?}, not Running — nothing to execute",
172 self.run_id.0, current.status
173 ),
174 });
175 }
176 match ¤t.claimed_by {
177 Some(holder) if *holder == self.runner_id => return Ok(self.run_id), Some(holder) => {
179 return Err(CommandError {
180 tx: ctx.tx().to_string(),
181 command_id: ctx.command_id.to_string(),
182 message: format!(
183 "ClaimRun: run {} already claimed by '{holder}'",
184 self.run_id.0
185 ),
186 });
187 }
188 None => {}
189 }
190
191 let mut updated = (*current).clone();
192 updated.claimed_by = Some(self.runner_id);
193 ctx.emit_set(&updated)?;
194 Ok(self.run_id)
195 }
196}
197
198#[myko_command]
202pub struct PinPlaybook {
203 pub playbook_id: PlaybookId,
204}
205
206impl CommandHandler for PinPlaybook {
207 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
208 ctx.exec_query_first(GetPlaybooksByIds {
209 ids: vec![self.playbook_id.clone()],
210 })?
211 .ok_or_else(|| CommandError {
212 tx: ctx.tx().to_string(),
213 command_id: ctx.command_id.to_string(),
214 message: format!("Playbook {} not found", self.playbook_id.0),
215 })?;
216
217 let pin_id = PinId::from(self.playbook_id.0.clone());
218 if let Some(existing) = ctx.exec_query_first(GetPinsByIds {
219 ids: vec![pin_id.clone()],
220 })? {
221 if existing.locked {
222 return Ok(());
223 }
224 }
225
226 let pin = Pin {
227 playbook_id: self.playbook_id,
228 locked: false,
229 id: pin_id,
230 };
231 ctx.emit_set(&pin)?;
232 Ok(())
233 }
234}
235
236#[myko_command]
242pub struct AppendRunOutput {
243 pub run_id: RunId,
244 pub stream: OutputStream,
245 pub line: String,
246 pub seq: u64,
247}
248
249impl CommandHandler for AppendRunOutput {
250 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
251 let entity = OutputLine {
252 id: OutputLineId::from(format!("{}:{}", self.run_id.0, self.seq)),
253 run_id: self.run_id,
254 stream: self.stream,
255 line: self.line,
256 seq: self.seq,
257 };
258 ctx.emit_set(&entity)?;
259 Ok(())
260 }
261}
262
263#[myko_command]
271pub struct FinishRun {
272 pub run_id: RunId,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub exit_code: Option<i32>,
275}
276
277impl CommandHandler for FinishRun {
278 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
279 let current = ctx
280 .exec_query_first(GetRunsByIds {
281 ids: vec![self.run_id.clone()],
282 })?
283 .ok_or_else(|| CommandError {
284 tx: ctx.tx().to_string(),
285 command_id: ctx.command_id.to_string(),
286 message: format!("Run {} not found", self.run_id.0),
287 })?;
288
289 let updated = finish_transition(¤t, self.exit_code, chrono::Utc::now().to_rfc3339());
290 ctx.emit_set(&updated)?;
291 Ok(())
292 }
293}
294
295fn finish_transition(current: &Run, exit_code: Option<i32>, finished_at: String) -> Run {
296 let mut updated = current.clone();
297 if updated.cancel_requested_at.is_some() || updated.status == RunStatus::Cancelled {
300 updated.status = RunStatus::Cancelled;
301 } else {
302 updated.status = if exit_code == Some(0) {
303 RunStatus::Completed
304 } else {
305 RunStatus::Failed
306 };
307 }
308 updated.exit_code = exit_code;
309 updated.finished_at = Some(finished_at);
310 updated
311}
312
313#[myko_command]
318pub struct DeclarePlaybooks {
319 pub playbooks: Vec<Playbook>,
320}
321
322impl CommandHandler for DeclarePlaybooks {
323 fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
324 for pb in self.playbooks {
325 ctx.emit_set(&pb)?;
326 }
327 Ok(())
328 }
329}
330
331#[myko_command(RunId)]
349pub struct RecordRun {
350 pub playbook_id: String,
352 pub status: RunStatus,
356 pub started_at: String,
358 pub finished_at: String,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub exit_code: Option<i32>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub extra_vars: Option<Value>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub limit: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub host_stats: Option<HashMap<String, HostStats>>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub run_id: Option<RunId>,
388}
389
390impl CommandHandler for RecordRun {
391 fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
392 if let Some(rid) = self.run_id {
398 let current = ctx
399 .exec_query_first(GetRunsByIds { ids: vec![rid.clone()] })?
400 .ok_or_else(|| CommandError {
401 tx: ctx.tx().to_string(),
402 command_id: ctx.command_id.to_string(),
403 message: format!(
404 "RecordRun attach: run {} not found — TACHYON_RUN_ID names a run this cell does not have",
405 rid.0
406 ),
407 })?;
408 let mut updated = (*current).clone();
409 if let Some(hs) = self.host_stats {
410 updated.host_stats = hs;
411 }
412 if updated.limit.is_none() {
413 updated.limit = self.limit;
414 }
415 ctx.emit_set(&updated)?;
416 return Ok(rid);
417 }
418
419 if !matches!(
424 self.status,
425 RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
426 ) {
427 return Err(CommandError {
428 tx: ctx.tx().to_string(),
429 command_id: ctx.command_id.to_string(),
430 message: format!(
431 "RecordRun records COMPLETED external ops — status must be completed/failed/cancelled, not {:?} (a non-terminal run would be double-executed by the runner)",
432 self.status
433 ),
434 });
435 }
436
437 let id = RunId::from(uuid::Uuid::new_v4().to_string());
438 let run = Run {
439 id: id.clone(),
440 playbook_id: PlaybookId::from(self.playbook_id),
441 status: self.status,
442 started_at: self.started_at,
443 cancel_requested_at: None,
444 finished_at: Some(self.finished_at),
445 exit_code: self.exit_code,
446 extra_vars: self.extra_vars,
447 limit: self.limit,
448 resources: Vec::new(),
450 host_stats: self.host_stats.unwrap_or_default(),
451 client_id: ctx.client_id().map(|c| c.to_string()),
454 claimed_by: None, };
456 ctx.emit_set(&run)?;
457 Ok(id)
458 }
459}
460
461#[cfg(test)]
462mod record_run_tests {
463 use super::*;
464
465 #[test]
468 fn record_run_without_run_id_is_mint_shape() {
469 let old: RecordRun = serde_json::from_str(
470 r#"{"playbookId":"ue-cluster-up","status":"completed",
471 "startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z"}"#,
472 )
473 .unwrap();
474 assert!(old.run_id.is_none());
475 assert!(old.host_stats.is_none());
476 }
477
478 #[test]
479 fn record_run_with_run_id_is_attach_shape() {
480 let attach: RecordRun = serde_json::from_str(
481 r#"{"playbookId":"ue-cluster-up","status":"completed",
482 "startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z",
483 "runId":"abc-123",
484 "hostStats":{"render-01":{"ok":5,"changed":1,"unreachable":0,"failed":0,"skipped":0,"rescued":0,"ignored":0}}}"#,
485 )
486 .unwrap();
487 assert_eq!(attach.run_id.as_ref().map(|r| r.0.as_ref()), Some("abc-123"));
488 assert_eq!(attach.host_stats.unwrap()["render-01"].ok, 5);
489 }
490}
491
492#[cfg(test)]
493mod cancellation_tests {
494 use super::{finish_transition, request_cancel_transition};
495 use crate::{PlaybookId, Run, RunId, RunStatus};
496
497 fn run(status: RunStatus, claimed_by: Option<&str>) -> Run {
498 Run {
499 id: RunId::from("run-1".to_string()),
500 playbook_id: PlaybookId::from("playbook".to_string()),
501 status,
502 started_at: "start".into(),
503 cancel_requested_at: None,
504 finished_at: None,
505 exit_code: None,
506 extra_vars: None,
507 limit: None,
508 resources: vec!["render-01".into()],
509 host_stats: Default::default(),
510 client_id: None,
511 claimed_by: claimed_by.map(str::to_owned),
512 }
513 }
514
515 #[test]
516 fn claimed_running_cancel_stays_nonterminal_until_finish_ack() {
517 let requested =
518 request_cancel_transition(&run(RunStatus::Running, Some("runner")), "cancel-at".into())
519 .expect("running run is cancellable");
520 assert_eq!(requested.status, RunStatus::Running);
521 assert_eq!(requested.cancel_requested_at.as_deref(), Some("cancel-at"));
522 assert!(requested.finished_at.is_none());
523
524 let finished = finish_transition(&requested, None, "finished-at".into());
525 assert_eq!(finished.status, RunStatus::Cancelled);
526 assert_eq!(finished.finished_at.as_deref(), Some("finished-at"));
527 }
528
529 #[test]
530 fn queued_and_unclaimed_running_cancel_immediately() {
531 for initial in [run(RunStatus::Queued, None), run(RunStatus::Running, None)] {
532 let cancelled =
533 request_cancel_transition(&initial, "cancel-at".into()).expect("in-flight run");
534 assert_eq!(cancelled.status, RunStatus::Cancelled);
535 assert_eq!(cancelled.finished_at.as_deref(), Some("cancel-at"));
536 }
537 }
538
539 #[test]
540 fn repeated_cancel_is_idempotent_and_terminal_cancel_is_untouched() {
541 let first =
542 request_cancel_transition(&run(RunStatus::Running, Some("runner")), "first".into())
543 .unwrap();
544 let second = request_cancel_transition(&first, "second".into()).unwrap();
545 assert_eq!(second.cancel_requested_at.as_deref(), Some("first"));
546
547 let terminal = finish_transition(&second, None, "done".into());
548 assert!(request_cancel_transition(&terminal, "later".into()).is_none());
549 }
550
551 #[test]
552 fn finish_without_cancel_uses_exit_code() {
553 assert_eq!(
554 finish_transition(
555 &run(RunStatus::Running, Some("runner")),
556 Some(0),
557 "done".into()
558 )
559 .status,
560 RunStatus::Completed
561 );
562 assert_eq!(
563 finish_transition(
564 &run(RunStatus::Running, Some("runner")),
565 Some(2),
566 "done".into()
567 )
568 .status,
569 RunStatus::Failed
570 );
571 }
572}