kanade_shared/wire/command.rs
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::Staleness;
5use crate::manifest::{CheckHint, CollectHint, EmitConfig};
6
7#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
8pub struct Command {
9 pub id: String,
10 pub version: String,
11 pub request_id: String,
12 /// v0.29 / Issue #19: the deployment / scheduler-fire UUID this
13 /// Command belongs to. Forwarded into `ExecResult.exec_id` by the
14 /// agent so the projector can attribute results back to the
15 /// originating `executions` row. `None` for ad-hoc `kanade run`
16 /// (no deployment row exists). Pre-v0.29 wire used the field name
17 /// `job_id` for this same value — `serde(alias)` keeps old
18 /// publishes in STREAM_EXEC decodable across the upgrade window.
19 #[serde(alias = "job_id")]
20 pub exec_id: Option<String>,
21 pub shell: Shell,
22 /// Inline script body, OR empty when [`script_object`] is set.
23 /// Mutually exclusive with `script_object` at the wire level —
24 /// backend builders fill one or the other (never both) and the
25 /// agent's resolver picks the populated one. Pre-v0.43 wire
26 /// always carries this populated.
27 ///
28 /// [`script_object`]: Self::script_object
29 pub script: String,
30 /// SPEC §2.4.1 / yukimemi/kanade#210: Object Store reference
31 /// (`<name>/<version>` key into `OBJECT_SCRIPTS`). When set,
32 /// the agent fetches the body via `script_cache` and verifies
33 /// its sha256 against [`script_object_sha256`] before launching.
34 /// `None` ⇒ inline `script` carries the body (legacy + the
35 /// majority of jobs).
36 ///
37 /// [`script_object_sha256`]: Self::script_object_sha256
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub script_object: Option<String>,
40 /// Hex-encoded sha256 of the bytes the operator approved at
41 /// Command-build time. Required when [`script_object`] is set;
42 /// the agent treats a mismatch on fetch as "operator
43 /// re-uploaded the script between exec submission and agent
44 /// fire" and aborts the run rather than silently executing the
45 /// new bytes. Pre-v0.43 wire omits this; the resolver path
46 /// requires both fields to be `Some`.
47 ///
48 /// [`script_object`]: Self::script_object
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub script_object_sha256: Option<String>,
51 pub timeout_secs: u64,
52 pub jitter_secs: Option<u64>,
53 /// Which (token, session) combination the agent should launch the
54 /// child process under (v0.21). Defaults to [`RunAs::System`] for
55 /// back-compat with pre-v0.21 backends that don't send this field.
56 #[serde(default)]
57 pub run_as: RunAs,
58 /// Working directory for the spawned child (v0.21.1). `None` ⇒
59 /// inherit the agent's cwd. Pre-v0.21.1 wire payloads omit this
60 /// field and parse fine via `#[serde(default)]`.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub cwd: Option<String>,
63 /// Absolute time after which the agent should refuse to run
64 /// this Command (v0.22). Set by the scheduler from
65 /// `Schedule.starting_deadline` (humantime) measured against
66 /// the cron tick time. `None` ⇒ no deadline, run whenever
67 /// received (default for ad-hoc `kanade exec` + back-compat
68 /// for pre-v0.22 wire). The agent stamps a synthetic
69 /// `ExecResult { exit_code: 125, stderr: "skipped: deadline
70 /// expired ..." }` when it skips, so the operator sees the
71 /// outcome on the Results / Dashboard pages instead of silence.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub deadline_at: Option<DateTime<Utc>>,
74 /// v0.26: Manifest-declared Layer 2 staleness policy
75 /// (see SPEC.md §2.6.2). Forwarded from `Manifest.staleness` so
76 /// the agent can evaluate it at fire time without re-fetching the
77 /// Manifest from `BUCKET_JOBS`. Pre-v0.26 wire omits this and
78 /// `#[serde(default)]` falls back to `Staleness::Cached`, matching
79 /// pre-v0.26 behaviour (silently use cached KV values).
80 #[serde(default)]
81 pub staleness: Staleness,
82 /// Issue #246: forwarded from `Manifest.emit` so the agent
83 /// doesn't have to re-fetch the manifest at fire time. When
84 /// `Some` and `EmitKind::Events`, the agent parses script
85 /// stdout as NDJSON `ObsEvent` and publishes each line on
86 /// `obs.<pc_id>`. Pre-#246 wire omits this; the `#[serde(default)]`
87 /// fallback to `None` preserves prior behaviour (stdout flows
88 /// to `ExecResult` unchanged).
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub emit: Option<EmitConfig>,
91 /// #290: forwarded from `Manifest.check` so the agent can build a
92 /// KLP Health-tab [`Check`](crate::ipc::state::Check) from the
93 /// job's stdout without re-fetching the Manifest. When `Some`, the
94 /// agent reads the `status_field` / `detail_field` values out of
95 /// the stdout JSON object after a successful run and caches the
96 /// result into `StateSnapshot.checks`. Pre-#290 wire omits this;
97 /// `#[serde(default)]` → `None` preserves prior behaviour.
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub check: Option<CheckHint>,
100 /// #219: forwarded from `Manifest.collect` so the agent can bundle
101 /// the script's listed files without re-fetching the Manifest. When
102 /// `Some`, the agent — after a successful run — reads the
103 /// `files_field` path array out of the stdout JSON object, zips those
104 /// files (capped at `max_size`), uploads the archive to
105 /// `OBJECT_COLLECTIONS`, and records the key in
106 /// [`ExecResult::collect_object`](super::ExecResult::collect_object).
107 /// Pre-#219 wire omits this; `#[serde(default)]` → `None` preserves
108 /// prior behaviour.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub collect: Option<CollectHint>,
111 /// #418 Phase 4: lowered from `Schedule.on_failure.retry` by the
112 /// command builders (backend `exec_manifest` + the agent's local
113 /// scheduler). When `Some`, the agent re-runs the script
114 /// in-process on a non-zero exit / timeout, up to `max` extra
115 /// attempts with `backoff_secs` between them, before publishing
116 /// the final outcome. `None` (default) ⇒ no retry, the historical
117 /// behaviour and what ad-hoc `kanade run` / `kanade exec` use.
118 /// Pre-Phase-4 wire omits this; `#[serde(default)]` → `None`.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub retry: Option<RetrySpec>,
121 /// Job-generic post-step hook lowered from `Manifest.finalize`. When
122 /// `Some` and the main script exits cleanly, the agent runs this hook
123 /// after the collect step (injecting `KANADE_COLLECT_RESULT` for a
124 /// `collect:` job) so the operator can delete / move / notify.
125 /// Best-effort — a finalize failure is logged, never published as the
126 /// run's outcome. Pre-finalize wire omits this; `#[serde(default)]` →
127 /// `None`.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub finalize: Option<FinalizeCommand>,
130}
131
132/// Lowered, engine-vocabulary form of [`crate::manifest::FinalizeSpec`]
133/// — the post-step hook stamped onto a [`Command`]. The operator-facing
134/// humantime `timeout` is reduced to whole seconds at build time
135/// (mirrors `timeout_secs`), and the manifest `ExecuteShell` to the wire
136/// [`Shell`], so the agent's fire path does no parsing.
137#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
138pub struct FinalizeCommand {
139 pub shell: Shell,
140 /// Inline script body (inline-only in P1).
141 pub script: String,
142 pub timeout_secs: u64,
143 #[serde(default)]
144 pub run_as: RunAs,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub cwd: Option<String>,
147 /// #965: for a `collect:` job, run this hook once per uploaded
148 /// bundle (single-bundle `KANADE_COLLECT_RESULT`) as each bundle
149 /// uploads, instead of once after the whole set — so an interrupted
150 /// collect still cleans up the days it managed to ship. `false`
151 /// (default, pre-#965 wire) keeps the one-call-after-all contract.
152 #[serde(default)]
153 pub on_each_bundle: bool,
154}
155
156/// Lowered, engine-vocabulary form of [`crate::manifest::Retry`] — a
157/// fixed-backoff retry policy stamped onto a [`Command`]. The
158/// operator-facing humantime `backoff` is reduced to whole seconds at
159/// build time (mirrors how `jitter_secs` / `timeout_secs` are
160/// pre-lowered) so the agent's fire path does no humantime parsing.
161#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
162pub struct RetrySpec {
163 /// Max additional attempts after the first failure (1..=10,
164 /// enforced by `Schedule::validate`).
165 pub max: u32,
166 /// Seconds slept between attempts.
167 pub backoff_secs: u64,
168}
169
170#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
171#[serde(rename_all = "lowercase")]
172pub enum Shell {
173 Powershell,
174 Cmd,
175}
176
177/// **Token + session combination** the agent uses to spawn a job's
178/// child process. Two orthogonal axes — *whose privileges* and *which
179/// session* — collapse into three meaningful combinations:
180///
181/// | variant | session | privileges | GUI |
182/// |--------------------|------------------------|-------------|-----|
183/// | `System` (default) | Session 0 (services) | LocalSystem | ❌ |
184/// | `User` | active console session | logged-in user (UAC-filtered when admin) | ✅ |
185/// | `SystemGui` | active console session | LocalSystem | ✅ |
186///
187/// `SystemGui` is the "PsExec `-i -s`" pattern: the agent duplicates
188/// its own SYSTEM token and rewrites `TokenSessionId` to the user's
189/// console session, then launches with that hybrid token — useful
190/// when an installer needs admin power *and* needs the user to see
191/// its UI.
192#[derive(
193 Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
194)]
195#[serde(rename_all = "snake_case")]
196pub enum RunAs {
197 /// LocalSystem privileges in Session 0. No GUI. Historical
198 /// default — every pre-v0.21 job ran this way.
199 #[default]
200 System,
201 /// The currently-logged-in console user's identity, in their
202 /// session. Can write HKCU / %APPDATA% / show GUI to the user.
203 /// Privileges are whatever the user has (admin users get the
204 /// UAC-filtered limited token, not the elevated one).
205 User,
206 /// LocalSystem privileges in the user's session — admin power
207 /// with GUI visibility. Niche but real (force-restart dialogs,
208 /// admin installers with progress UI).
209 SystemGui,
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn sample_command() -> Command {
217 Command {
218 id: "echo-test".into(),
219 version: "1.0.0".into(),
220 request_id: "req-1".into(),
221 exec_id: Some("dep-1".into()),
222 shell: Shell::Powershell,
223 script: "echo hi".into(),
224 script_object: None,
225 script_object_sha256: None,
226 timeout_secs: 30,
227 jitter_secs: Some(5),
228 run_as: RunAs::System,
229 cwd: None,
230 deadline_at: None,
231 staleness: Staleness::Cached,
232 emit: None,
233 check: None,
234 collect: None,
235 retry: None,
236 finalize: None,
237 }
238 }
239
240 #[test]
241 fn shell_serialises_lowercase() {
242 let json = serde_json::to_string(&Shell::Powershell).unwrap();
243 assert_eq!(json, "\"powershell\"");
244 let json = serde_json::to_string(&Shell::Cmd).unwrap();
245 assert_eq!(json, "\"cmd\"");
246 }
247
248 #[test]
249 fn run_as_serialises_snake_case() {
250 for (mode, expected) in [
251 (RunAs::System, "\"system\""),
252 (RunAs::User, "\"user\""),
253 (RunAs::SystemGui, "\"system_gui\""),
254 ] {
255 let json = serde_json::to_string(&mode).unwrap();
256 assert_eq!(json, expected, "serialise {mode:?}");
257 let back: RunAs = serde_json::from_str(expected).unwrap();
258 assert_eq!(back, mode, "round-trip {expected}");
259 }
260 }
261
262 #[test]
263 fn run_as_defaults_to_system() {
264 assert_eq!(RunAs::default(), RunAs::System);
265 }
266
267 #[test]
268 fn command_round_trips_through_json() {
269 let orig = sample_command();
270 let json = serde_json::to_string(&orig).expect("encode");
271 let decoded: Command = serde_json::from_str(&json).expect("decode");
272 assert_eq!(decoded.id, orig.id);
273 assert_eq!(decoded.version, orig.version);
274 assert_eq!(decoded.request_id, orig.request_id);
275 assert_eq!(decoded.exec_id, orig.exec_id);
276 assert_eq!(decoded.shell, orig.shell);
277 assert_eq!(decoded.script, orig.script);
278 assert_eq!(decoded.timeout_secs, orig.timeout_secs);
279 assert_eq!(decoded.jitter_secs, orig.jitter_secs);
280 assert_eq!(decoded.run_as, orig.run_as);
281 }
282
283 #[test]
284 fn command_round_trips_each_run_as_variant() {
285 for mode in [RunAs::System, RunAs::User, RunAs::SystemGui] {
286 let cmd = Command {
287 run_as: mode,
288 ..sample_command()
289 };
290 let json = serde_json::to_string(&cmd).unwrap();
291 let back: Command = serde_json::from_str(&json).unwrap();
292 assert_eq!(back.run_as, mode);
293 }
294 }
295
296 #[test]
297 fn command_accepts_missing_optional_fields() {
298 let json = r#"{
299 "id": "x",
300 "version": "1.0.0",
301 "request_id": "r",
302 "shell": "cmd",
303 "script": "echo",
304 "timeout_secs": 5
305 }"#;
306 let cmd: Command = serde_json::from_str(json).expect("decode");
307 assert!(cmd.exec_id.is_none());
308 assert!(cmd.jitter_secs.is_none());
309 assert_eq!(cmd.shell, Shell::Cmd);
310 // Pre-v0.21 wire payloads omit run_as → falls back to System.
311 assert_eq!(cmd.run_as, RunAs::System);
312 // Pre-v0.21.1 omit cwd → None (= inherit agent cwd).
313 assert!(cmd.cwd.is_none());
314 // Pre-v0.22 omit deadline_at → None (= no deadline).
315 assert!(cmd.deadline_at.is_none());
316 // Pre-v0.43 wire omits both script_object fields — agent
317 // falls back to the inline `script` body.
318 assert!(cmd.script_object.is_none());
319 assert!(cmd.script_object_sha256.is_none());
320 }
321
322 #[test]
323 fn command_round_trips_script_object_fields() {
324 // yukimemi/kanade#210: backend builds Commands carrying an
325 // OBJECT_SCRIPTS reference + the operator-approved digest;
326 // agent resolves on fetch. Both fields must survive a JSON
327 // round-trip with the same shape.
328 let cmd = Command {
329 script: String::new(),
330 script_object: Some("cleanup-disk-temp/1.0.1".into()),
331 script_object_sha256: Some(
332 "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
333 ),
334 ..sample_command()
335 };
336 let json = serde_json::to_string(&cmd).expect("encode");
337 let back: Command = serde_json::from_str(&json).expect("decode");
338 assert_eq!(back.script, "");
339 assert_eq!(
340 back.script_object.as_deref(),
341 Some("cleanup-disk-temp/1.0.1")
342 );
343 assert_eq!(
344 back.script_object_sha256.as_deref(),
345 Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
346 );
347 }
348
349 #[test]
350 fn command_decodes_legacy_job_id_field_as_exec_id() {
351 // v0.29 / Issue #19: Commands sitting in STREAM_EXEC published
352 // by a pre-v0.29 backend still carry the field named `job_id`.
353 // The `#[serde(alias = "job_id")]` on `exec_id` keeps them
354 // decodable through the upgrade window so the agent doesn't
355 // start dropping replays on first boot of a new binary.
356 let json = r#"{
357 "id": "x",
358 "version": "1.0.0",
359 "request_id": "r",
360 "job_id": "legacy-exec-uuid",
361 "shell": "powershell",
362 "script": "echo",
363 "timeout_secs": 5
364 }"#;
365 let cmd: Command = serde_json::from_str(json).expect("decode legacy");
366 assert_eq!(cmd.exec_id.as_deref(), Some("legacy-exec-uuid"));
367 }
368
369 #[test]
370 fn command_deadline_at_round_trips() {
371 use chrono::TimeZone;
372 let deadline = Utc.with_ymd_and_hms(2026, 5, 18, 9, 30, 0).unwrap();
373 let cmd = Command {
374 deadline_at: Some(deadline),
375 ..sample_command()
376 };
377 let json = serde_json::to_string(&cmd).unwrap();
378 let back: Command = serde_json::from_str(&json).unwrap();
379 assert_eq!(back.deadline_at, Some(deadline));
380 }
381
382 #[test]
383 fn command_retry_round_trips() {
384 // #418 Phase 4: a stamped retry policy must survive the wire
385 // so the agent can apply it on a live publish or a STREAM_EXEC
386 // replay.
387 let cmd = Command {
388 retry: Some(RetrySpec {
389 max: 3,
390 backoff_secs: 600,
391 }),
392 ..sample_command()
393 };
394 let json = serde_json::to_string(&cmd).unwrap();
395 let back: Command = serde_json::from_str(&json).unwrap();
396 assert_eq!(
397 back.retry,
398 Some(RetrySpec {
399 max: 3,
400 backoff_secs: 600
401 })
402 );
403 }
404
405 #[test]
406 fn command_omits_retry_when_absent() {
407 // skip_serializing_if keeps the field off the wire for the
408 // common (no-retry) case, and pre-Phase-4 payloads that never
409 // had it still decode (serde default → None).
410 let json = serde_json::to_string(&sample_command()).unwrap();
411 assert!(
412 !json.contains("retry"),
413 "retry must not appear when None: {json}"
414 );
415 }
416
417 #[test]
418 fn command_collect_round_trips_and_omits_when_absent() {
419 // #219: `collect` is off the wire when None (skip_serializing_if),
420 // so pre-#219 readers don't trip over it...
421 let json = serde_json::to_string(&sample_command()).unwrap();
422 assert!(
423 !json.contains("collect"),
424 "collect must be absent when None: {json}"
425 );
426 // ...and a forwarded CollectHint survives the round-trip.
427 let cmd = Command {
428 collect: Some(CollectHint {
429 name: "diag".into(),
430 description: Some("logs".into()),
431 max_size: Some("50MB".into()),
432 files_field: "files".into(),
433 }),
434 ..sample_command()
435 };
436 let back: Command = serde_json::from_str(&serde_json::to_string(&cmd).unwrap()).unwrap();
437 let c = back.collect.expect("collect survived round-trip");
438 assert_eq!(c.name, "diag");
439 assert_eq!(c.max_size.as_deref(), Some("50MB"));
440 assert_eq!(c.files_field, "files");
441 }
442
443 #[test]
444 fn command_finalize_round_trips_and_omits_when_absent() {
445 // Off the wire when None (skip_serializing_if), so pre-finalize
446 // readers don't trip over it...
447 let json = serde_json::to_string(&sample_command()).unwrap();
448 assert!(
449 !json.contains("finalize"),
450 "finalize must be absent when None: {json}"
451 );
452 // ...and a forwarded FinalizeCommand survives the round-trip.
453 let cmd = Command {
454 finalize: Some(FinalizeCommand {
455 shell: Shell::Powershell,
456 script: "Remove-Item $env:FILE".into(),
457 timeout_secs: 30,
458 run_as: RunAs::System,
459 cwd: None,
460 on_each_bundle: false,
461 }),
462 ..sample_command()
463 };
464 let back: Command = serde_json::from_str(&serde_json::to_string(&cmd).unwrap()).unwrap();
465 let f = back.finalize.expect("finalize survived round-trip");
466 assert_eq!(f.shell, Shell::Powershell);
467 assert_eq!(f.script, "Remove-Item $env:FILE");
468 assert_eq!(f.timeout_secs, 30);
469 assert_eq!(f.run_as, RunAs::System);
470 }
471}