Skip to main content

tachyon_types/
commands.rs

1use std::collections::HashMap;
2
3use myko_macros::myko_command;
4use myko::command::{CommandContext, CommandError, CommandHandler};
5use serde_json::Value;
6
7use crate::{
8    derive_resources, GetAllInventorys, GetPinsByIds, GetPlaybooksByIds, GetRunsByIds,
9    HostStats, OutputLine, OutputLineId, OutputStream, Pin, PinId, Playbook, PlaybookId, Run, RunId,
10    RunStatus,
11};
12
13/// Run an Ansible playbook. Creates a new `Run` entity with status `Queued`;
14/// the admission step in tachyon-server promotes it to `Running` once it holds
15/// a lease on every resource it targets, and the run watcher then executes it.
16#[myko_command]
17pub struct RunPlaybook {
18    pub playbook_id: PlaybookId,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub extra_vars: Option<Value>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub limit: Option<String>,
23}
24
25impl CommandHandler for RunPlaybook {
26    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
27        // Confirm the playbook exists.
28        ctx.exec_query_first(GetPlaybooksByIds {
29            ids: vec![self.playbook_id.clone()],
30        })?
31        .ok_or_else(|| CommandError {
32            tx: ctx.tx().to_string(),
33            command_id: ctx.command_id.to_string(),
34            message: format!("Playbook {} not found", self.playbook_id.0),
35        })?;
36
37        // Derive the resource set from the limit against the current inventory,
38        // so the queue can serialize on shared clusters. No inventory (e.g. it
39        // failed to discover) means no derivable resources → the run leases
40        // nothing and is admitted immediately.
41        let resources = match ctx.exec_query(GetAllInventorys {})?.into_iter().next() {
42            Some(inv) => derive_resources(self.limit.as_deref(), &inv),
43            None => Vec::new(),
44        };
45
46        let run_id = RunId::from(uuid::Uuid::new_v4().to_string());
47        let now = chrono::Utc::now().to_rfc3339();
48        let run = Run {
49            playbook_id: self.playbook_id,
50            status: RunStatus::Queued,
51            started_at: now,
52            finished_at: None,
53            exit_code: None,
54            extra_vars: self.extra_vars,
55            limit: self.limit,
56            resources,
57            host_stats: Default::default(),
58            // Attribution of the LAUNCHING connection. Stamped here from the command's
59            // request context — #[myko_client_id] emit-stamping only covers direct client
60            // SET events, never a handler's server-side emit (lv-67c8). None = an
61            // internal dispatch (saga/startup) with no originating connection.
62            client_id: ctx.client_id().map(|c| c.to_string()),
63            claimed_by: None, // set by ClaimRun when a runner wins the run
64            id: run_id,
65        };
66        ctx.emit_set(&run)?;
67        Ok(())
68    }
69}
70
71/// Cancel a playbook execution. Works on a `Queued` run (drops it from the
72/// queue before it ever starts) or a `Running` one (signals the child process).
73/// No-op if the run has already finished.
74#[myko_command]
75pub struct CancelRun {
76    pub run_id: RunId,
77}
78
79impl CommandHandler for CancelRun {
80    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
81        let current = ctx
82            .exec_query_first(GetRunsByIds {
83                ids: vec![self.run_id.clone()],
84            })?
85            .ok_or_else(|| CommandError {
86                tx: ctx.tx().to_string(),
87                command_id: ctx.command_id.to_string(),
88                message: format!("Run {} not found", self.run_id.0),
89            })?;
90
91        // Only an in-flight run (queued or running) can be cancelled; a
92        // finished run stays as it is. A queued run carries no PID, so the
93        // watcher's cancel handler simply finds nothing to signal.
94        if !matches!(current.status, RunStatus::Queued | RunStatus::Running) {
95            return Ok(());
96        }
97
98        let mut updated = (*current).clone();
99        updated.status = RunStatus::Cancelled;
100        ctx.emit_set(&updated)?;
101        Ok(())
102    }
103}
104
105/// Claim a run for execution — the compare-and-swap that makes exactly ONE runner
106/// own each run. Succeeds only while the run is `Running` and UNCLAIMED; commands
107/// execute serialized in the cell, so the check-and-set is atomic and two racing
108/// runners cannot both win. A loser treats the error as "not mine, skip" — the
109/// claim is the execution gate, not a failure.
110///
111/// Idempotent for the winner: re-claiming a run you already hold succeeds (a
112/// subscription re-fire after winning must not error).
113#[myko_command(RunId)]
114pub struct ClaimRun {
115    pub run_id: RunId,
116    /// Stable runner identity (one per runner instance/host). A restarted runner
117    /// with the same id uses it to recognize — and fail out — runs it left behind.
118    pub runner_id: String,
119}
120
121impl CommandHandler for ClaimRun {
122    // Returns the RunId (not unit) so the CALLER CAN AWAIT the result — a unit
123    // command's response never populates the client's result cell, which made the
124    // runner's claim-await time out on every claim that actually landed.
125    fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
126        let current = ctx
127            .exec_query_first(GetRunsByIds {
128                ids: vec![self.run_id.clone()],
129            })?
130            .ok_or_else(|| CommandError {
131                tx: ctx.tx().to_string(),
132                command_id: ctx.command_id.to_string(),
133                message: format!("Run {} not found", self.run_id.0),
134            })?;
135
136        if current.status != RunStatus::Running {
137            return Err(CommandError {
138                tx: ctx.tx().to_string(),
139                command_id: ctx.command_id.to_string(),
140                message: format!(
141                    "ClaimRun: run {} is {:?}, not Running — nothing to execute",
142                    self.run_id.0, current.status
143                ),
144            });
145        }
146        match &current.claimed_by {
147            Some(holder) if *holder == self.runner_id => return Ok(self.run_id), // already ours
148            Some(holder) => {
149                return Err(CommandError {
150                    tx: ctx.tx().to_string(),
151                    command_id: ctx.command_id.to_string(),
152                    message: format!(
153                        "ClaimRun: run {} already claimed by '{holder}'",
154                        self.run_id.0
155                    ),
156                });
157            }
158            None => {}
159        }
160
161        let mut updated = (*current).clone();
162        updated.claimed_by = Some(self.runner_id);
163        ctx.emit_set(&updated)?;
164        Ok(self.run_id)
165    }
166}
167
168/// Pin a playbook to the sidebar's "Pinned" section. Idempotent: re-pinning
169/// an already-pinned playbook is a no-op. Refuses to overwrite a locked
170/// (config-defined) pin.
171#[myko_command]
172pub struct PinPlaybook {
173    pub playbook_id: PlaybookId,
174}
175
176impl CommandHandler for PinPlaybook {
177    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
178        ctx.exec_query_first(GetPlaybooksByIds {
179            ids: vec![self.playbook_id.clone()],
180        })?
181        .ok_or_else(|| CommandError {
182            tx: ctx.tx().to_string(),
183            command_id: ctx.command_id.to_string(),
184            message: format!("Playbook {} not found", self.playbook_id.0),
185        })?;
186
187        let pin_id = PinId::from(self.playbook_id.0.clone());
188        if let Some(existing) = ctx.exec_query_first(GetPinsByIds {
189            ids: vec![pin_id.clone()],
190        })? {
191            if existing.locked {
192                return Ok(());
193            }
194        }
195
196        let pin = Pin {
197            playbook_id: self.playbook_id,
198            locked: false,
199            id: pin_id,
200        };
201        ctx.emit_set(&pin)?;
202        Ok(())
203    }
204}
205
206/// Append one line of playbook output to a run. Issued by the out-of-process
207/// runner as it streams `ansible-playbook` output back to the cell (the
208/// in-process runner writes `OutputLine`s directly via `ctx`). `seq` is the
209/// runner's monotonic per-run counter across both stdout+stderr, so the UI can
210/// order lines and jump to a parsed failure's source line.
211#[myko_command]
212pub struct AppendRunOutput {
213    pub run_id: RunId,
214    pub stream: OutputStream,
215    pub line: String,
216    pub seq: u64,
217}
218
219impl CommandHandler for AppendRunOutput {
220    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
221        let entity = OutputLine {
222            id: OutputLineId::from(format!("{}:{}", self.run_id.0, self.seq)),
223            run_id: self.run_id,
224            stream: self.stream,
225            line: self.line,
226            seq: self.seq,
227        };
228        ctx.emit_set(&entity)?;
229        Ok(())
230    }
231}
232
233/// Mark a run finished, reported by the runner when `ansible-playbook` exits.
234/// The cell decides the terminal status: a run already `Cancelled` (a cancel
235/// landed mid-run) stays cancelled; otherwise exit code 0 -> `Completed`,
236/// anything else -> `Failed`. `exit_code` is `None` when the process was killed
237/// by a signal or never spawned.
238#[myko_command]
239pub struct FinishRun {
240    pub run_id: RunId,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub exit_code: Option<i32>,
243}
244
245impl CommandHandler for FinishRun {
246    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
247        let current = ctx
248            .exec_query_first(GetRunsByIds {
249                ids: vec![self.run_id.clone()],
250            })?
251            .ok_or_else(|| CommandError {
252                tx: ctx.tx().to_string(),
253                command_id: ctx.command_id.to_string(),
254                message: format!("Run {} not found", self.run_id.0),
255            })?;
256
257        let mut updated = (*current).clone();
258        // A concurrent cancel wins; otherwise derive terminal status from exit.
259        if updated.status != RunStatus::Cancelled {
260            updated.status = if self.exit_code == Some(0) {
261                RunStatus::Completed
262            } else {
263                RunStatus::Failed
264            };
265        }
266        updated.exit_code = self.exit_code;
267        updated.finished_at = Some(chrono::Utc::now().to_rfc3339());
268        ctx.emit_set(&updated)?;
269        Ok(())
270    }
271}
272
273/// Declare the playbooks a runner has available. The runner scans its
274/// `playbook_dir` and reports each as a `Playbook` entity (upsert by id).
275/// Discovery lives where the playbook files are - the runner - not the cell,
276/// so `RunPlaybook`'s existence check has something to resolve against.
277#[myko_command]
278pub struct DeclarePlaybooks {
279    pub playbooks: Vec<Playbook>,
280}
281
282impl CommandHandler for DeclarePlaybooks {
283    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
284        for pb in self.playbooks {
285            ctx.emit_set(&pb)?;
286        }
287        Ok(())
288    }
289}
290
291/// Record an EXTERNALLY-executed (CLI) op as a terminal `Run`.
292///
293/// Mutating cluster ops are run via `ansible-playbook` on the control node, so they
294/// never dispatch through the cell and the runs wall never sees them. `RecordRun`
295/// lets the deploy-side callback self-report the op at completion: it creates a
296/// `Run` ALREADY TERMINAL (Completed/Failed/Cancelled). The runner executes every
297/// `Running` run (it keys purely on `status == Running`, with no executor/owner
298/// field to tell a self-declared run from a dispatched one), so a terminal run is
299/// invisible to it — no double-execution, no `RunStatus` change. The wall renders
300/// it like any other run (it reads `GetAllRuns`, source-agnostic); captured stdout
301/// streams on via `AppendRunOutput(run_id, …)`.
302///
303/// Terminal-only BY CONTRACT: `Running`/`Queued` are rejected fail-loud — a
304/// non-terminal self-declared run WOULD be picked up and re-executed by the runner.
305/// Records hold no lease (`resources: []`): a completed op is a record, not a work
306/// item. Lives here (not the cell) so every myko consumer — the cell handler AND
307/// the deploy-side recorder client — shares ONE definition; no hand-synced contract.
308#[myko_command(RunId)]
309pub struct RecordRun {
310    /// The playbook that was run (e.g. `ue-cluster-redeploy`).
311    pub playbook_id: String,
312    /// Terminal outcome ONLY — `completed` | `failed` | `cancelled`. `running` /
313    /// `queued` are rejected: a non-terminal self-declared run would be executed
314    /// by the runner.
315    pub status: RunStatus,
316    /// RFC3339 — when the external op started.
317    pub started_at: String,
318    /// RFC3339 — when it finished (a record is created post-hoc, so always known).
319    pub finished_at: String,
320    /// Process exit code, when captured.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub exit_code: Option<i32>,
323    /// The op's vars (put operator / cluster-def context here for the "ran via CLI"
324    /// attribution; the actor's WS identity is separately server-stamped as client_id).
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub extra_vars: Option<Value>,
327    /// The ansible `--limit`, when scoped.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub limit: Option<String>,
330    /// Per-host ok/changed/failed/… recap, when the caller parsed the PLAY RECAP.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub host_stats: Option<HashMap<String, HostStats>>,
333    /// ATTACH mode: the id of an EXISTING dispatched Run to enrich with this
334    /// execution report (host_stats, limit) instead of minting a new record.
335    ///
336    /// This is the recap-decoupling fix: the Run LIFECYCLE (status, exit_code,
337    /// finished_at) is owned by the runner via `FinishRun`; the EXECUTION DETAIL
338    /// (per-host recap) is owned by whoever ran ansible and holds the stats object —
339    /// the deploy-side callback. Before this field the callback could only mint a
340    /// duplicate record or stay silent, so every cell-dispatched run recorded an
341    /// empty recap. The runner advertises the id to the playbook process as
342    /// `TACHYON_RUN_ID`; the callback passes it back here.
343    ///
344    /// Attach does NOT touch lifecycle fields and is accepted while the Run is
345    /// still `Running` (ansible stats fire before the process exits).
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub run_id: Option<RunId>,
348}
349
350impl CommandHandler for RecordRun {
351    fn execute(self, ctx: CommandContext) -> Result<RunId, CommandError> {
352        // ATTACH mode: enrich the named EXISTING run with the execution report.
353        // Lifecycle fields (status / exit_code / finished_at) belong to the runner's
354        // `FinishRun` and are deliberately untouched, so the two writers never race
355        // over the same fact. Accepted while the run is still Running — ansible's
356        // stats callback fires before the process exits.
357        if let Some(rid) = self.run_id {
358            let current = ctx
359                .exec_query_first(GetRunsByIds { ids: vec![rid.clone()] })?
360                .ok_or_else(|| CommandError {
361                    tx: ctx.tx().to_string(),
362                    command_id: ctx.command_id.to_string(),
363                    message: format!(
364                        "RecordRun attach: run {} not found — TACHYON_RUN_ID names a run this cell does not have",
365                        rid.0
366                    ),
367                })?;
368            let mut updated = (*current).clone();
369            if let Some(hs) = self.host_stats {
370                updated.host_stats = hs;
371            }
372            if updated.limit.is_none() {
373                updated.limit = self.limit;
374            }
375            ctx.emit_set(&updated)?;
376            return Ok(rid);
377        }
378
379        // MINT mode (no run_id): a deploy-side self-report of an operator-run op.
380        // Terminal-only. A Running/Queued self-declared run would be grabbed and
381        // re-executed by the runner (it watches status == Running); refuse it so a
382        // record can never trigger a real op.
383        if !matches!(
384            self.status,
385            RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
386        ) {
387            return Err(CommandError {
388                tx: ctx.tx().to_string(),
389                command_id: ctx.command_id.to_string(),
390                message: format!(
391                    "RecordRun records COMPLETED external ops — status must be completed/failed/cancelled, not {:?} (a non-terminal run would be double-executed by the runner)",
392                    self.status
393                ),
394            });
395        }
396
397        let id = RunId::from(uuid::Uuid::new_v4().to_string());
398        let run = Run {
399            id: id.clone(),
400            playbook_id: PlaybookId::from(self.playbook_id),
401            status: self.status,
402            started_at: self.started_at,
403            finished_at: Some(self.finished_at),
404            exit_code: self.exit_code,
405            extra_vars: self.extra_vars,
406            limit: self.limit,
407            // A completed record holds no lease — it is not a work item.
408            resources: Vec::new(),
409            host_stats: self.host_stats.unwrap_or_default(),
410            // The RECORDING connection (callback/CLI), stamped from the request context —
411            // handler emits are never #[myko_client_id]-stamped (lv-67c8).
412            client_id: ctx.client_id().map(|c| c.to_string()),
413            claimed_by: None, // a terminal record is not a work item; nothing claims it
414        };
415        ctx.emit_set(&run)?;
416        Ok(id)
417    }
418}
419
420#[cfg(test)]
421mod record_run_tests {
422    use super::*;
423
424    /// Wire compat: a pre-0.6 payload (no runId) must still deserialize with
425    /// run_id None — the mint path — so old callers keep working unchanged.
426    #[test]
427    fn record_run_without_run_id_is_mint_shape() {
428        let old: RecordRun = serde_json::from_str(
429            r#"{"playbookId":"ue-cluster-up","status":"completed",
430                "startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z"}"#,
431        )
432        .unwrap();
433        assert!(old.run_id.is_none());
434        assert!(old.host_stats.is_none());
435    }
436
437    #[test]
438    fn record_run_with_run_id_is_attach_shape() {
439        let attach: RecordRun = serde_json::from_str(
440            r#"{"playbookId":"ue-cluster-up","status":"completed",
441                "startedAt":"2026-07-24T00:00:00Z","finishedAt":"2026-07-24T00:01:00Z",
442                "runId":"abc-123",
443                "hostStats":{"render-01":{"ok":5,"changed":1,"unreachable":0,"failed":0,"skipped":0,"rescued":0,"ignored":0}}}"#,
444        )
445        .unwrap();
446        assert_eq!(attach.run_id.as_ref().map(|r| r.0.as_ref()), Some("abc-123"));
447        assert_eq!(attach.host_stats.unwrap()["render-01"].ok, 5);
448    }
449}