Skip to main content

tachyon_types/
commands.rs

1use myko_macros::myko_command;
2use myko::command::{CommandContext, CommandError, CommandHandler};
3use serde_json::Value;
4
5use crate::{
6    derive_resources, GetAllInventorys, GetPinsByIds, GetPlaybooksByIds, GetRunsByIds, OutputLine,
7    OutputLineId, OutputStream, Pin, PinId, Playbook, PlaybookId, Run, RunId, RunStatus,
8};
9
10/// Run an Ansible playbook. Creates a new `Run` entity with status `Queued`;
11/// the admission step in tachyon-server promotes it to `Running` once it holds
12/// a lease on every resource it targets, and the run watcher then executes it.
13#[myko_command]
14pub struct RunPlaybook {
15    pub playbook_id: PlaybookId,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub extra_vars: Option<Value>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub limit: Option<String>,
20}
21
22impl CommandHandler for RunPlaybook {
23    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
24        // Confirm the playbook exists.
25        ctx.exec_query_first(GetPlaybooksByIds {
26            ids: vec![self.playbook_id.clone()],
27        })?
28        .ok_or_else(|| CommandError {
29            tx: ctx.tx().to_string(),
30            command_id: ctx.command_id.to_string(),
31            message: format!("Playbook {} not found", self.playbook_id.0),
32        })?;
33
34        // Derive the resource set from the limit against the current inventory,
35        // so the queue can serialize on shared clusters. No inventory (e.g. it
36        // failed to discover) means no derivable resources → the run leases
37        // nothing and is admitted immediately.
38        let resources = match ctx.exec_query(GetAllInventorys {})?.into_iter().next() {
39            Some(inv) => derive_resources(self.limit.as_deref(), &inv),
40            None => Vec::new(),
41        };
42
43        let run_id = RunId::from(uuid::Uuid::new_v4().to_string());
44        let now = chrono::Utc::now().to_rfc3339();
45        let run = Run {
46            playbook_id: self.playbook_id,
47            status: RunStatus::Queued,
48            started_at: now,
49            finished_at: None,
50            exit_code: None,
51            extra_vars: self.extra_vars,
52            limit: self.limit,
53            resources,
54            host_stats: Default::default(),
55            client_id: None, // server-stamps the launching connection on emit
56            id: run_id,
57        };
58        ctx.emit_set(&run)?;
59        Ok(())
60    }
61}
62
63/// Cancel a playbook execution. Works on a `Queued` run (drops it from the
64/// queue before it ever starts) or a `Running` one (signals the child process).
65/// No-op if the run has already finished.
66#[myko_command]
67pub struct CancelRun {
68    pub run_id: RunId,
69}
70
71impl CommandHandler for CancelRun {
72    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
73        let current = ctx
74            .exec_query_first(GetRunsByIds {
75                ids: vec![self.run_id.clone()],
76            })?
77            .ok_or_else(|| CommandError {
78                tx: ctx.tx().to_string(),
79                command_id: ctx.command_id.to_string(),
80                message: format!("Run {} not found", self.run_id.0),
81            })?;
82
83        // Only an in-flight run (queued or running) can be cancelled; a
84        // finished run stays as it is. A queued run carries no PID, so the
85        // watcher's cancel handler simply finds nothing to signal.
86        if !matches!(current.status, RunStatus::Queued | RunStatus::Running) {
87            return Ok(());
88        }
89
90        let mut updated = (*current).clone();
91        updated.status = RunStatus::Cancelled;
92        ctx.emit_set(&updated)?;
93        Ok(())
94    }
95}
96
97/// Pin a playbook to the sidebar's "Pinned" section. Idempotent: re-pinning
98/// an already-pinned playbook is a no-op. Refuses to overwrite a locked
99/// (config-defined) pin.
100#[myko_command]
101pub struct PinPlaybook {
102    pub playbook_id: PlaybookId,
103}
104
105impl CommandHandler for PinPlaybook {
106    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
107        ctx.exec_query_first(GetPlaybooksByIds {
108            ids: vec![self.playbook_id.clone()],
109        })?
110        .ok_or_else(|| CommandError {
111            tx: ctx.tx().to_string(),
112            command_id: ctx.command_id.to_string(),
113            message: format!("Playbook {} not found", self.playbook_id.0),
114        })?;
115
116        let pin_id = PinId::from(self.playbook_id.0.clone());
117        if let Some(existing) = ctx.exec_query_first(GetPinsByIds {
118            ids: vec![pin_id.clone()],
119        })? {
120            if existing.locked {
121                return Ok(());
122            }
123        }
124
125        let pin = Pin {
126            playbook_id: self.playbook_id,
127            locked: false,
128            id: pin_id,
129        };
130        ctx.emit_set(&pin)?;
131        Ok(())
132    }
133}
134
135/// Append one line of playbook output to a run. Issued by the out-of-process
136/// runner as it streams `ansible-playbook` output back to the cell (the
137/// in-process runner writes `OutputLine`s directly via `ctx`). `seq` is the
138/// runner's monotonic per-run counter across both stdout+stderr, so the UI can
139/// order lines and jump to a parsed failure's source line.
140#[myko_command]
141pub struct ReportRunOutput {
142    pub run_id: RunId,
143    pub stream: OutputStream,
144    pub line: String,
145    pub seq: u64,
146}
147
148impl CommandHandler for ReportRunOutput {
149    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
150        let entity = OutputLine {
151            id: OutputLineId::from(format!("{}:{}", self.run_id.0, self.seq)),
152            run_id: self.run_id,
153            stream: self.stream,
154            line: self.line,
155            seq: self.seq,
156        };
157        ctx.emit_set(&entity)?;
158        Ok(())
159    }
160}
161
162/// Mark a run finished, reported by the runner when `ansible-playbook` exits.
163/// The cell decides the terminal status: a run already `Cancelled` (a cancel
164/// landed mid-run) stays cancelled; otherwise exit code 0 -> `Completed`,
165/// anything else -> `Failed`. `exit_code` is `None` when the process was killed
166/// by a signal or never spawned.
167#[myko_command]
168pub struct ReportRunFinished {
169    pub run_id: RunId,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub exit_code: Option<i32>,
172}
173
174impl CommandHandler for ReportRunFinished {
175    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
176        let current = ctx
177            .exec_query_first(GetRunsByIds {
178                ids: vec![self.run_id.clone()],
179            })?
180            .ok_or_else(|| CommandError {
181                tx: ctx.tx().to_string(),
182                command_id: ctx.command_id.to_string(),
183                message: format!("Run {} not found", self.run_id.0),
184            })?;
185
186        let mut updated = (*current).clone();
187        // A concurrent cancel wins; otherwise derive terminal status from exit.
188        if updated.status != RunStatus::Cancelled {
189            updated.status = if self.exit_code == Some(0) {
190                RunStatus::Completed
191            } else {
192                RunStatus::Failed
193            };
194        }
195        updated.exit_code = self.exit_code;
196        updated.finished_at = Some(chrono::Utc::now().to_rfc3339());
197        ctx.emit_set(&updated)?;
198        Ok(())
199    }
200}
201
202/// Declare the playbooks a runner has available. The runner scans its
203/// `playbook_dir` and reports each as a `Playbook` entity (upsert by id).
204/// Discovery lives where the playbook files are - the runner - not the cell,
205/// so `RunPlaybook`'s existence check has something to resolve against.
206#[myko_command]
207pub struct ReportPlaybooks {
208    pub playbooks: Vec<Playbook>,
209}
210
211impl CommandHandler for ReportPlaybooks {
212    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
213        for pb in self.playbooks {
214            ctx.emit_set(&pb)?;
215        }
216        Ok(())
217    }
218}