Skip to main content

tachyon_types/
commands.rs

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/// Run an Ansible playbook. Creates a new `Run` entity with status `Queued`;
13/// the admission step in tachyon-server promotes it to `Running` once it holds
14/// a lease on every resource it targets, and the run watcher then executes it.
15#[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
33/// Validate and construct a queued run without publishing it.
34///
35/// A domain command can use this to batch the run atomically with related state
36/// transitions. Pulse-cluster uses it to record every cancellation request and
37/// its queued teardown together, so a client crash cannot leave a half-abort.
38pub fn prepare_playbook_run(
39    ctx: &CommandContext,
40    command: RunPlaybook,
41) -> Result<Run, CommandError> {
42    // Confirm the playbook exists.
43    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    // Derive the resource set from the limit against the current inventory,
53    // so the queue can serialize on shared clusters. No inventory (e.g. it
54    // failed to discover) means no derivable resources → the run leases
55    // nothing and is admitted immediately.
56    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        // Attribution of the LAUNCHING connection. Stamped here from the command's
73        // request context — #[myko_client_id] emit-stamping only covers direct client
74        // SET events, never a handler's server-side emit (lv-67c8). None = an
75        // internal dispatch (saga/startup) with no originating connection.
76        client_id: ctx.client_id().map(|c| c.to_string()),
77        claimed_by: None, // set by ClaimRun when a runner wins the run
78        id: RunId::from(uuid::Uuid::new_v4().to_string()),
79    })
80}
81
82/// Request cancellation of a playbook execution.
83///
84/// A queued or not-yet-claimed run can become terminal immediately because no
85/// executor exists. A claimed `Running` run stays non-terminal and keeps its
86/// lease until the runner has terminated and reaped the playbook process group;
87/// `FinishRun` is the acknowledgement that completes it as `Cancelled`.
88/// Repeated requests and requests for an already-terminal run are idempotent.
89#[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(&current, 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    // Only an in-flight run can be cancelled; a finished run stays as it is.
117    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    // Queued runs and Running runs that have not been claimed cannot have
126    // spawned a playbook. They need no runner acknowledgement.
127    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/// Claim a run for execution — the compare-and-swap that makes exactly ONE runner
136/// own each run. Succeeds only while the run is `Running` and UNCLAIMED; commands
137/// execute serialized in the cell, so the check-and-set is atomic and two racing
138/// runners cannot both win. A loser treats the error as "not mine, skip" — the
139/// claim is the execution gate, not a failure.
140///
141/// Idempotent for the winner: re-claiming a run you already hold succeeds (a
142/// subscription re-fire after winning must not error).
143#[myko_command(RunId)]
144pub struct ClaimRun {
145    pub run_id: RunId,
146    /// Stable runner identity (one per runner instance/host). A restarted runner
147    /// with the same id uses it to recognize — and fail out — runs it left behind.
148    pub runner_id: String,
149}
150
151impl CommandHandler for ClaimRun {
152    // Returns the RunId (not unit) so the CALLER CAN AWAIT the result — a unit
153    // command's response never populates the client's result cell, which made the
154    // runner's claim-await time out on every claim that actually landed.
155    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 &current.claimed_by {
177            Some(holder) if *holder == self.runner_id => return Ok(self.run_id), // already ours
178            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/// Pin a playbook to the sidebar's "Pinned" section. Idempotent: re-pinning
199/// an already-pinned playbook is a no-op. Refuses to overwrite a locked
200/// (config-defined) pin.
201#[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/// Append one line of playbook output to a run. Issued by the out-of-process
237/// runner as it streams `ansible-playbook` output back to the cell (the
238/// in-process runner writes `OutputLine`s directly via `ctx`). `seq` is the
239/// runner's monotonic per-run counter across both stdout+stderr, so the UI can
240/// order lines and jump to a parsed failure's source line.
241#[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/// Mark a run finished, reported by the runner only after `ansible-playbook`
264/// and its process group have exited and output has drained.
265///
266/// The cell decides the terminal status: a cancellation request wins;
267/// otherwise exit code 0 -> `Completed`, anything else -> `Failed`.
268/// `exit_code` is `None` when the process was killed by a signal or never
269/// spawned.
270#[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(&current, 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    // A concurrent cancel wins, but terminal Cancelled is written only now:
298    // this transition is the executor's proof that nothing is still running.
299    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/// Declare the playbooks a runner has available. The runner scans its
314/// `playbook_dir` and reports each as a `Playbook` entity (upsert by id).
315/// Discovery lives where the playbook files are - the runner - not the cell,
316/// so `RunPlaybook`'s existence check has something to resolve against.
317#[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/// Record an EXTERNALLY-executed (CLI) op as a terminal `Run`.
332///
333/// Mutating cluster ops are run via `ansible-playbook` on the control node, so they
334/// never dispatch through the cell and the runs wall never sees them. `RecordRun`
335/// lets the deploy-side callback self-report the op at completion: it creates a
336/// `Run` ALREADY TERMINAL (Completed/Failed/Cancelled). The runner executes every
337/// `Running` run (it keys purely on `status == Running`, with no executor/owner
338/// field to tell a self-declared run from a dispatched one), so a terminal run is
339/// invisible to it — no double-execution, no `RunStatus` change. The wall renders
340/// it like any other run (it reads `GetAllRuns`, source-agnostic); captured stdout
341/// streams on via `AppendRunOutput(run_id, …)`.
342///
343/// Terminal-only BY CONTRACT: `Running`/`Queued` are rejected fail-loud — a
344/// non-terminal self-declared run WOULD be picked up and re-executed by the runner.
345/// Records hold no lease (`resources: []`): a completed op is a record, not a work
346/// item. Lives here (not the cell) so every myko consumer — the cell handler AND
347/// the deploy-side recorder client — shares ONE definition; no hand-synced contract.
348#[myko_command(RunId)]
349pub struct RecordRun {
350    /// The playbook that was run (e.g. `ue-cluster-redeploy`).
351    pub playbook_id: String,
352    /// Terminal outcome ONLY — `completed` | `failed` | `cancelled`. `running` /
353    /// `queued` are rejected: a non-terminal self-declared run would be executed
354    /// by the runner.
355    pub status: RunStatus,
356    /// RFC3339 — when the external op started.
357    pub started_at: String,
358    /// RFC3339 — when it finished (a record is created post-hoc, so always known).
359    pub finished_at: String,
360    /// Process exit code, when captured.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub exit_code: Option<i32>,
363    /// The op's vars (put operator / cluster-def context here for the "ran via CLI"
364    /// attribution; the actor's WS identity is separately server-stamped as client_id).
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub extra_vars: Option<Value>,
367    /// The ansible `--limit`, when scoped.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub limit: Option<String>,
370    /// Per-host ok/changed/failed/… recap, when the caller parsed the PLAY RECAP.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub host_stats: Option<HashMap<String, HostStats>>,
373    /// ATTACH mode: the id of an EXISTING dispatched Run to enrich with this
374    /// execution report (host_stats, limit) instead of minting a new record.
375    ///
376    /// This is the recap-decoupling fix: the Run LIFECYCLE (status, exit_code,
377    /// finished_at) is owned by the runner via `FinishRun`; the EXECUTION DETAIL
378    /// (per-host recap) is owned by whoever ran ansible and holds the stats object —
379    /// the deploy-side callback. Before this field the callback could only mint a
380    /// duplicate record or stay silent, so every cell-dispatched run recorded an
381    /// empty recap. The runner advertises the id to the playbook process as
382    /// `TACHYON_RUN_ID`; the callback passes it back here.
383    ///
384    /// Attach does NOT touch lifecycle fields and is accepted while the Run is
385    /// still `Running` (ansible stats fire before the process exits).
386    #[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        // ATTACH mode: enrich the named EXISTING run with the execution report.
393        // Lifecycle fields (status / exit_code / finished_at) belong to the runner's
394        // `FinishRun` and are deliberately untouched, so the two writers never race
395        // over the same fact. Accepted while the run is still Running — ansible's
396        // stats callback fires before the process exits.
397        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        // MINT mode (no run_id): a deploy-side self-report of an operator-run op.
420        // Terminal-only. A Running/Queued self-declared run would be grabbed and
421        // re-executed by the runner (it watches status == Running); refuse it so a
422        // record can never trigger a real op.
423        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            // A completed record holds no lease — it is not a work item.
449            resources: Vec::new(),
450            host_stats: self.host_stats.unwrap_or_default(),
451            // The RECORDING connection (callback/CLI), stamped from the request context —
452            // handler emits are never #[myko_client_id]-stamped (lv-67c8).
453            client_id: ctx.client_id().map(|c| c.to_string()),
454            claimed_by: None, // a terminal record is not a work item; nothing claims it
455        };
456        ctx.emit_set(&run)?;
457        Ok(id)
458    }
459}
460
461#[cfg(test)]
462mod record_run_tests {
463    use super::*;
464
465    /// Wire compat: a pre-0.6 payload (no runId) must still deserialize with
466    /// run_id None — the mint path — so old callers keep working unchanged.
467    #[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}