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 ¤t.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}