Skip to main content

agent_exec/
schema.rs

1//! Shared output schema types for agent-exec v0.1.
2//!
3//! Stdout output is JSON by default; YAML when --yaml is set.
4//! Tracing logs go to stderr.
5//! Schema version is fixed at "0.1".
6
7use serde::{Deserialize, Serialize};
8use std::sync::atomic::{AtomicBool, Ordering};
9
10/// Global flag: when true, print YAML instead of JSON on stdout.
11static YAML_OUTPUT: AtomicBool = AtomicBool::new(false);
12
13/// Set the output format.  Call once from `main` before running any subcommand.
14pub fn set_yaml_output(yaml: bool) {
15    YAML_OUTPUT.store(yaml, Ordering::Relaxed);
16}
17
18pub const SCHEMA_VERSION: &str = "0.1";
19
20/// Serialize `value` and print to stdout in the selected format (JSON default, YAML with --yaml).
21///
22/// This is the single place where stdout output is written, ensuring the
23/// stdout-is-machine-readable contract is enforced uniformly across all response types.
24fn print_to_stdout(value: &impl Serialize) {
25    if YAML_OUTPUT.load(Ordering::Relaxed) {
26        print!(
27            "{}",
28            serde_yaml::to_string(value).expect("YAML serialization failed")
29        );
30    } else {
31        println!(
32            "{}",
33            serde_json::to_string(value).expect("JSON serialization failed")
34        );
35    }
36}
37
38/// Top-level envelope used for every successful response.
39#[derive(Debug, Serialize, Deserialize)]
40pub struct Response<T: Serialize> {
41    pub schema_version: &'static str,
42    pub ok: bool,
43    #[serde(rename = "type")]
44    pub kind: &'static str,
45    #[serde(flatten)]
46    pub data: T,
47}
48
49impl<T: Serialize> Response<T> {
50    pub fn new(kind: &'static str, data: T) -> Self {
51        Response {
52            schema_version: SCHEMA_VERSION,
53            ok: true,
54            kind,
55            data,
56        }
57    }
58
59    /// Serialize to a JSON string and print to stdout.
60    pub fn print(&self) {
61        print_to_stdout(self);
62    }
63}
64
65/// Top-level envelope for error responses.
66#[derive(Debug, Serialize, Deserialize)]
67pub struct ErrorResponse {
68    pub schema_version: &'static str,
69    pub ok: bool,
70    #[serde(rename = "type")]
71    pub kind: &'static str,
72    pub error: ErrorDetail,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct ErrorDetail {
77    pub code: String,
78    pub message: String,
79    /// Whether the caller may retry the same request and expect a different outcome.
80    pub retryable: bool,
81}
82
83impl ErrorResponse {
84    /// Create an error response.
85    ///
86    /// `retryable` should be `true` only when a transient condition (e.g. I/O
87    /// contention, temporary unavailability) caused the failure and the caller
88    /// is expected to succeed on a subsequent attempt without changing the
89    /// request.  Use `false` for permanent failures such as "job not found" or
90    /// internal logic errors.
91    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
92        ErrorResponse {
93            schema_version: SCHEMA_VERSION,
94            ok: false,
95            kind: "error",
96            error: ErrorDetail {
97                code: code.into(),
98                message: message.into(),
99                retryable,
100            },
101        }
102    }
103
104    pub fn print(&self) {
105        print_to_stdout(self);
106    }
107}
108
109// ---------- Command-specific response payloads ----------
110
111/// Response for `create` command.
112#[derive(Debug, Serialize, Deserialize)]
113pub struct CreateData {
114    pub job_id: String,
115    /// Always "created".
116    pub state: String,
117    /// Absolute path to stdout.log for this job.
118    pub stdout_log_path: String,
119    /// Absolute path to stderr.log for this job.
120    pub stderr_log_path: String,
121}
122
123/// Response for `run` command.
124#[derive(Debug, Serialize, Deserialize)]
125pub struct RunData {
126    pub job_id: String,
127    pub state: String,
128    /// Tags assigned to this job (always present; empty array when none).
129    #[serde(default)]
130    pub tags: Vec<String>,
131    /// Environment variables passed to the job, with masked values replaced by "***".
132    /// Omitted from JSON when empty.
133    #[serde(skip_serializing_if = "Vec::is_empty", default)]
134    pub env_vars: Vec<String>,
135    /// Present when `snapshot_after` elapsed before `run` returned.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub snapshot: Option<Snapshot>,
138    /// Absolute path to stdout.log for this job.
139    pub stdout_log_path: String,
140    /// Absolute path to stderr.log for this job.
141    pub stderr_log_path: String,
142    /// Milliseconds actually waited for snapshot (0 when snapshot_after=0).
143    pub waited_ms: u64,
144    /// Wall-clock milliseconds from run invocation start to JSON output.
145    pub elapsed_ms: u64,
146    /// Exit code of the process; present only when `--wait` is used and job has terminated.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub exit_code: Option<i32>,
149    /// RFC 3339 timestamp when the job finished; present only when `--wait` is used.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub finished_at: Option<String>,
152    /// Final log tail snapshot taken after job completion; present only when `--wait` is used.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub final_snapshot: Option<Snapshot>,
155}
156
157/// Response for `status` command.
158#[derive(Debug, Serialize, Deserialize)]
159pub struct StatusData {
160    pub job_id: String,
161    pub state: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub exit_code: Option<i32>,
164    /// RFC 3339 timestamp when the job was created (always present).
165    pub created_at: String,
166    /// RFC 3339 timestamp when the job started executing; absent for `created` state.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub started_at: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub finished_at: Option<String>,
171}
172
173/// Response for `tail` command.
174#[derive(Debug, Serialize, Deserialize)]
175pub struct TailData {
176    pub job_id: String,
177    pub stdout_tail: String,
178    pub stderr_tail: String,
179    /// True when the output was truncated by tail_lines or max_bytes constraints.
180    pub truncated: bool,
181    pub encoding: String,
182    /// Absolute path to stdout.log for this job.
183    pub stdout_log_path: String,
184    /// Absolute path to stderr.log for this job.
185    pub stderr_log_path: String,
186    /// Size of stdout.log in bytes at the time of the tail read (0 if file absent).
187    pub stdout_observed_bytes: u64,
188    /// Size of stderr.log in bytes at the time of the tail read (0 if file absent).
189    pub stderr_observed_bytes: u64,
190    /// UTF-8 byte length of the stdout_tail string included in this response.
191    pub stdout_included_bytes: u64,
192    /// UTF-8 byte length of the stderr_tail string included in this response.
193    pub stderr_included_bytes: u64,
194}
195
196/// Response for `wait` command.
197#[derive(Debug, Serialize, Deserialize)]
198pub struct WaitData {
199    pub job_id: String,
200    pub state: String,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub exit_code: Option<i32>,
203}
204
205/// Response for `kill` command.
206#[derive(Debug, Serialize, Deserialize)]
207pub struct KillData {
208    pub job_id: String,
209    pub signal: String,
210}
211
212/// Response for `schema` command.
213#[derive(Debug, Serialize, Deserialize)]
214pub struct SchemaData {
215    /// The JSON Schema format identifier (e.g. "json-schema-draft-07").
216    pub schema_format: String,
217    /// The JSON Schema document describing all CLI response types.
218    pub schema: serde_json::Value,
219    /// Timestamp when the schema file was last updated (RFC 3339).
220    pub generated_at: String,
221}
222
223/// Summary of a single job, included in `list` responses.
224#[derive(Debug, Serialize, Deserialize)]
225pub struct JobSummary {
226    pub job_id: String,
227    /// Job state: created | running | exited | killed | failed | unknown
228    pub state: String,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub exit_code: Option<i32>,
231    /// Creation timestamp from meta.json (RFC 3339).
232    pub created_at: String,
233    /// Execution start timestamp; absent for `created` state.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub started_at: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub finished_at: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub updated_at: Option<String>,
240    /// Tags assigned to this job (always present; empty array when none).
241    #[serde(default)]
242    pub tags: Vec<String>,
243}
244
245/// Response for `tag set` command.
246#[derive(Debug, Serialize, Deserialize)]
247pub struct TagSetData {
248    pub job_id: String,
249    /// The new deduplicated tag list as persisted to meta.json.
250    pub tags: Vec<String>,
251}
252
253/// Response for `list` command.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct ListData {
256    /// Resolved root directory path.
257    pub root: String,
258    /// Array of job summaries, sorted by started_at descending.
259    pub jobs: Vec<JobSummary>,
260    /// True when the result was truncated by --limit.
261    pub truncated: bool,
262    /// Number of directories skipped because they could not be read as jobs.
263    pub skipped: u64,
264}
265
266/// Per-job result entry in a `gc` response.
267#[derive(Debug, Serialize, Deserialize)]
268pub struct GcJobResult {
269    pub job_id: String,
270    /// Job state as reported from state.json: running | exited | killed | failed | unknown
271    pub state: String,
272    /// What GC did: "deleted" | "would_delete" | "skipped"
273    pub action: String,
274    /// Human-readable explanation for the action.
275    pub reason: String,
276    /// Byte size of the job directory (0 for skipped jobs where size is not computed).
277    pub bytes: u64,
278}
279
280/// Response for the `gc` command.
281#[derive(Debug, Serialize, Deserialize)]
282pub struct GcData {
283    /// Resolved root directory path.
284    pub root: String,
285    /// Whether this was a dry-run (no deletions performed).
286    pub dry_run: bool,
287    /// The effective retention window (e.g. "30d").
288    pub older_than: String,
289    /// How the retention window was determined: "default" or "flag".
290    pub older_than_source: String,
291    /// Number of job directories actually deleted (0 when dry_run=true).
292    pub deleted: u64,
293    /// Number of job directories skipped (running, unreadable, or too recent).
294    pub skipped: u64,
295    /// Total bytes freed (or would be freed in dry-run mode).
296    pub freed_bytes: u64,
297    /// Per-job details.
298    pub jobs: Vec<GcJobResult>,
299}
300
301/// Per-job result entry in a `delete` response.
302#[derive(Debug, Serialize, Deserialize)]
303pub struct DeleteJobResult {
304    pub job_id: String,
305    /// Job state as reported from state.json: created | running | exited | killed | failed | unknown
306    pub state: String,
307    /// What delete did: "deleted" | "would_delete" | "skipped"
308    pub action: String,
309    /// Human-readable explanation for the action.
310    pub reason: String,
311}
312
313/// Response for the `delete` command.
314#[derive(Debug, Serialize, Deserialize)]
315pub struct DeleteData {
316    /// Resolved root directory path.
317    pub root: String,
318    /// Whether this was a dry-run (no deletions performed).
319    pub dry_run: bool,
320    /// Number of job directories actually deleted (0 when dry_run=true).
321    pub deleted: u64,
322    /// Number of job directories skipped.
323    pub skipped: u64,
324    /// Per-job details.
325    pub jobs: Vec<DeleteJobResult>,
326}
327
328// ---------- install-skills response payload ----------
329
330/// Summary of a single installed skill, included in `install_skills` responses.
331#[derive(Debug, Serialize, Deserialize)]
332pub struct InstalledSkillSummary {
333    /// Skill name (directory name under `.agents/skills/`).
334    pub name: String,
335    /// Source type string used when the skill was installed (e.g. "self", "local").
336    pub source_type: String,
337    /// Absolute path to the installed skill directory.
338    pub path: String,
339}
340
341/// Response for `notify set` command.
342#[derive(Debug, Serialize, Deserialize)]
343pub struct NotifySetData {
344    pub job_id: String,
345    /// Updated notification configuration saved to meta.json.
346    pub notification: NotificationConfig,
347}
348
349/// Response for `install-skills` command.
350#[derive(Debug, Serialize, Deserialize)]
351pub struct InstallSkillsData {
352    /// List of installed skills.
353    pub skills: Vec<InstalledSkillSummary>,
354    /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
355    pub global: bool,
356    /// Absolute path to the `.skill-lock.json` file that was updated.
357    pub lock_file_path: String,
358}
359
360/// Snapshot of stdout/stderr tail at a point in time.
361#[derive(Debug, Serialize, Deserialize)]
362pub struct Snapshot {
363    pub stdout_tail: String,
364    pub stderr_tail: String,
365    /// True when the output was truncated by tail_lines or max_bytes constraints.
366    pub truncated: bool,
367    pub encoding: String,
368    /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
369    pub stdout_observed_bytes: u64,
370    /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
371    pub stderr_observed_bytes: u64,
372    /// UTF-8 byte length of the stdout_tail string included in this snapshot.
373    pub stdout_included_bytes: u64,
374    /// UTF-8 byte length of the stderr_tail string included in this snapshot.
375    pub stderr_included_bytes: u64,
376}
377
378// ---------- Notification / completion event models ----------
379
380/// Match type for output-match notification.
381#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
382#[serde(rename_all = "lowercase")]
383pub enum OutputMatchType {
384    #[default]
385    Contains,
386    Regex,
387}
388
389/// Stream selector for output-match notification.
390#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
391#[serde(rename_all = "lowercase")]
392pub enum OutputMatchStream {
393    Stdout,
394    Stderr,
395    #[default]
396    Either,
397}
398
399/// Configuration for output-match notifications.
400#[derive(Debug, Serialize, Deserialize, Clone)]
401pub struct OutputMatchConfig {
402    /// Pattern to match against output lines.
403    pub pattern: String,
404    /// Match type: contains (substring) or regex.
405    #[serde(default)]
406    pub match_type: OutputMatchType,
407    /// Which stream to match: stdout, stderr, or either.
408    #[serde(default)]
409    pub stream: OutputMatchStream,
410    /// Shell command string for command sink; executed via platform shell on match.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub command: Option<String>,
413    /// File path for NDJSON append sink.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub file: Option<String>,
416}
417
418/// Notification configuration persisted in meta.json.
419#[derive(Debug, Serialize, Deserialize, Clone)]
420pub struct NotificationConfig {
421    /// Shell command string for command sink; executed via platform shell on completion.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub notify_command: Option<String>,
424    /// File path for NDJSON append sink.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub notify_file: Option<String>,
427    /// Output-match notification configuration.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub on_output_match: Option<OutputMatchConfig>,
430}
431
432/// The `job.finished` event payload.
433#[derive(Debug, Serialize, Deserialize, Clone)]
434pub struct CompletionEvent {
435    pub schema_version: String,
436    pub event_type: String,
437    pub job_id: String,
438    pub state: String,
439    pub command: Vec<String>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub cwd: Option<String>,
442    pub started_at: String,
443    pub finished_at: String,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub duration_ms: Option<u64>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub exit_code: Option<i32>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub signal: Option<String>,
450    pub stdout_log_path: String,
451    pub stderr_log_path: String,
452}
453
454/// Delivery result for a single notification sink.
455#[derive(Debug, Serialize, Deserialize, Clone)]
456pub struct SinkDeliveryResult {
457    pub sink_type: String,
458    pub target: String,
459    pub success: bool,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462    pub attempted_at: String,
463}
464
465/// Persisted in `completion_event.json` after terminal state is reached.
466#[derive(Debug, Serialize, Deserialize, Clone)]
467pub struct CompletionEventRecord {
468    #[serde(flatten)]
469    pub event: CompletionEvent,
470    pub delivery_results: Vec<SinkDeliveryResult>,
471}
472
473/// The `job.output.matched` event payload.
474#[derive(Debug, Serialize, Deserialize, Clone)]
475pub struct OutputMatchEvent {
476    pub schema_version: String,
477    pub event_type: String,
478    pub job_id: String,
479    pub pattern: String,
480    pub match_type: String,
481    pub stream: String,
482    pub line: String,
483    pub stdout_log_path: String,
484    pub stderr_log_path: String,
485}
486
487/// Delivery record for a single output-match event; appended to `notification_events.ndjson`.
488#[derive(Debug, Serialize, Deserialize, Clone)]
489pub struct OutputMatchEventRecord {
490    #[serde(flatten)]
491    pub event: OutputMatchEvent,
492    pub delivery_results: Vec<SinkDeliveryResult>,
493}
494
495// ---------- Persisted job metadata / state ----------
496
497/// Nested `job` block within `meta.json`.
498#[derive(Debug, Serialize, Deserialize, Clone)]
499pub struct JobMetaJob {
500    pub id: String,
501}
502
503/// Persisted in `meta.json` at job creation time.
504///
505/// Structure:
506/// ```json
507/// {
508///   "job": { "id": "..." },
509///   "schema_version": "0.1",
510///   "command": [...],
511///   "created_at": "...",
512///   "root": "...",
513///   "env_keys": [...],
514///   "env_vars": [...],
515///   "mask": [...]
516/// }
517/// ```
518///
519/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
520/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***" (display only).
521/// `env_vars_runtime` stores the actual (unmasked) KEY=VALUE strings used at `start` time.
522///   For the `run` command, this field is empty (env vars are passed directly to the supervisor).
523///   For the `create`/`start` lifecycle, this field persists the real KEY=VALUE pairs so
524///   `start` can apply them without re-specifying CLI arguments.
525/// `mask` stores the list of keys whose values are masked in output/metadata views.
526/// `cwd` stores the effective working directory at job creation time (canonicalized).
527///
528/// For the `create`/`start` lifecycle, additional execution-definition fields are
529/// persisted so that `start` can launch the job without re-specifying CLI arguments.
530#[derive(Debug, Serialize, Deserialize, Clone)]
531pub struct JobMeta {
532    pub job: JobMetaJob,
533    pub schema_version: String,
534    pub command: Vec<String>,
535    pub created_at: String,
536    pub root: String,
537    /// Keys of environment variables provided at job creation time.
538    pub env_keys: Vec<String>,
539    /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
540    /// Used for display in JSON responses and metadata views only.
541    #[serde(skip_serializing_if = "Vec::is_empty", default)]
542    pub env_vars: Vec<String>,
543    /// Actual (unmasked) KEY=VALUE env var pairs persisted for `start` runtime use.
544    /// Only populated in the `create`/`start` lifecycle. For `run`, this is empty
545    /// because env vars are passed directly to the supervisor.
546    /// `--env` in the create/start lifecycle is treated as durable, non-secret configuration;
547    /// use `--env-file` for values that should never be written to disk.
548    #[serde(skip_serializing_if = "Vec::is_empty", default)]
549    pub env_vars_runtime: Vec<String>,
550    /// Keys whose values are masked in output.
551    #[serde(skip_serializing_if = "Vec::is_empty", default)]
552    pub mask: Vec<String>,
553    /// Effective working directory at job creation time (canonicalized absolute path).
554    /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
555    #[serde(skip_serializing_if = "Option::is_none", default)]
556    pub cwd: Option<String>,
557    /// Notification configuration (present only when --notify-command or --notify-file was used).
558    #[serde(skip_serializing_if = "Option::is_none", default)]
559    pub notification: Option<NotificationConfig>,
560    /// User-defined tags for grouping and filtering. Empty array when none.
561    #[serde(default)]
562    pub tags: Vec<String>,
563
564    // --- Execution-definition fields (persisted for create/start lifecycle) ---
565    /// Whether to inherit the current process environment at start time. Default: true.
566    #[serde(default = "default_inherit_env")]
567    pub inherit_env: bool,
568    /// Env-file paths to apply in order at start time (real values read from file on start).
569    #[serde(skip_serializing_if = "Vec::is_empty", default)]
570    pub env_files: Vec<String>,
571    /// Timeout in milliseconds; 0 = no timeout.
572    #[serde(default)]
573    pub timeout_ms: u64,
574    /// Milliseconds after SIGTERM before SIGKILL; 0 = immediate SIGKILL.
575    #[serde(default)]
576    pub kill_after_ms: u64,
577    /// Interval (ms) for state.json updated_at refresh; 0 = disabled.
578    #[serde(default)]
579    pub progress_every_ms: u64,
580    /// Resolved shell wrapper argv (e.g. ["sh", "-lc"]). None = resolved from config at start time.
581    #[serde(skip_serializing_if = "Option::is_none", default)]
582    pub shell_wrapper: Option<Vec<String>>,
583}
584
585fn default_inherit_env() -> bool {
586    true
587}
588
589impl JobMeta {
590    /// Convenience accessor: returns the job ID.
591    pub fn job_id(&self) -> &str {
592        &self.job.id
593    }
594}
595
596/// Nested `job` block within `state.json`.
597#[derive(Debug, Serialize, Deserialize, Clone)]
598pub struct JobStateJob {
599    pub id: String,
600    pub status: JobStatus,
601    /// RFC 3339 execution start timestamp; absent for jobs in `created` state.
602    #[serde(skip_serializing_if = "Option::is_none", default)]
603    pub started_at: Option<String>,
604}
605
606/// Nested `result` block within `state.json`.
607///
608/// Option fields are serialized as `null` (not omitted) so callers always
609/// see consistent keys regardless of job lifecycle stage.
610#[derive(Debug, Serialize, Deserialize, Clone)]
611pub struct JobStateResult {
612    /// `null` while running; set to exit code when process ends.
613    pub exit_code: Option<i32>,
614    /// `null` unless the process was killed by a signal.
615    pub signal: Option<String>,
616    /// `null` while running; set to elapsed milliseconds when process ends.
617    pub duration_ms: Option<u64>,
618}
619
620/// Persisted in `state.json`, updated as the job progresses.
621///
622/// Structure:
623/// ```json
624/// {
625///   "job": { "id": "...", "status": "running", "started_at": "..." },
626///   "result": { "exit_code": null, "signal": null, "duration_ms": null },
627///   "updated_at": "..."
628/// }
629/// ```
630///
631/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
632/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
633/// Option fields MUST be serialized as `null` (not omitted) so callers always
634/// see consistent keys regardless of job lifecycle stage.
635#[derive(Debug, Serialize, Deserialize, Clone)]
636pub struct JobState {
637    pub job: JobStateJob,
638    pub result: JobStateResult,
639    /// Process ID (not part of the public spec; omitted when not available).
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub pid: Option<u32>,
642    /// Finish time (not part of the nested result block; kept for internal use).
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub finished_at: Option<String>,
645    /// Last time this state was written to disk (RFC 3339).
646    pub updated_at: String,
647    /// Windows-only: name of the Job Object used to manage the process tree.
648    /// Present only when the supervisor successfully created and assigned a
649    /// named Job Object; absent on non-Windows platforms and when creation
650    /// fails (in which case tree management falls back to snapshot enumeration).
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub windows_job_name: Option<String>,
653}
654
655impl JobState {
656    /// Convenience accessor: returns the job ID.
657    pub fn job_id(&self) -> &str {
658        &self.job.id
659    }
660
661    /// Convenience accessor: returns the job status.
662    pub fn status(&self) -> &JobStatus {
663        &self.job.status
664    }
665
666    /// Convenience accessor: returns the started_at timestamp, if present.
667    pub fn started_at(&self) -> Option<&str> {
668        self.job.started_at.as_deref()
669    }
670
671    /// Convenience accessor: returns the exit code.
672    pub fn exit_code(&self) -> Option<i32> {
673        self.result.exit_code
674    }
675
676    /// Convenience accessor: returns the signal name.
677    pub fn signal(&self) -> Option<&str> {
678        self.result.signal.as_deref()
679    }
680
681    /// Convenience accessor: returns the duration in milliseconds.
682    pub fn duration_ms(&self) -> Option<u64> {
683        self.result.duration_ms
684    }
685}
686
687#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
688#[serde(rename_all = "lowercase")]
689pub enum JobStatus {
690    Created,
691    Running,
692    Exited,
693    Killed,
694    Failed,
695}
696
697impl JobStatus {
698    pub fn as_str(&self) -> &'static str {
699        match self {
700            JobStatus::Created => "created",
701            JobStatus::Running => "running",
702            JobStatus::Exited => "exited",
703            JobStatus::Killed => "killed",
704            JobStatus::Failed => "failed",
705        }
706    }
707
708    /// Returns true when the status is a non-terminal state (created or running).
709    pub fn is_non_terminal(&self) -> bool {
710        matches!(self, JobStatus::Created | JobStatus::Running)
711    }
712}