agent-exec 0.1.18

Non-interactive agent job runner. Runs commands as background jobs and returns structured JSON on stdout.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
//! Shared output schema types for agent-exec v0.1.
//!
//! Stdout output is JSON by default; YAML when --yaml is set.
//! Tracing logs go to stderr.
//! Schema version is fixed at "0.1".

use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};

/// Global flag: when true, print YAML instead of JSON on stdout.
static YAML_OUTPUT: AtomicBool = AtomicBool::new(false);

/// Set the output format.  Call once from `main` before running any subcommand.
pub fn set_yaml_output(yaml: bool) {
    YAML_OUTPUT.store(yaml, Ordering::Relaxed);
}

pub const SCHEMA_VERSION: &str = "0.1";

/// Serialize `value` and print to stdout in the selected format (JSON default, YAML with --yaml).
///
/// This is the single place where stdout output is written, ensuring the
/// stdout-is-machine-readable contract is enforced uniformly across all response types.
fn print_to_stdout(value: &impl Serialize) {
    if YAML_OUTPUT.load(Ordering::Relaxed) {
        print!(
            "{}",
            serde_yaml::to_string(value).expect("YAML serialization failed")
        );
    } else {
        println!(
            "{}",
            serde_json::to_string(value).expect("JSON serialization failed")
        );
    }
}

/// Top-level envelope used for every successful response.
#[derive(Debug, Serialize, Deserialize)]
pub struct Response<T: Serialize> {
    pub schema_version: &'static str,
    pub ok: bool,
    #[serde(rename = "type")]
    pub kind: &'static str,
    #[serde(flatten)]
    pub data: T,
}

impl<T: Serialize> Response<T> {
    pub fn new(kind: &'static str, data: T) -> Self {
        Response {
            schema_version: SCHEMA_VERSION,
            ok: true,
            kind,
            data,
        }
    }

    /// Serialize to a JSON string and print to stdout.
    pub fn print(&self) {
        print_to_stdout(self);
    }
}

/// Top-level envelope for error responses.
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
    pub schema_version: &'static str,
    pub ok: bool,
    #[serde(rename = "type")]
    pub kind: &'static str,
    pub error: ErrorDetail,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorDetail {
    pub code: String,
    pub message: String,
    /// Whether the caller may retry the same request and expect a different outcome.
    pub retryable: bool,
}

impl ErrorResponse {
    /// Create an error response.
    ///
    /// `retryable` should be `true` only when a transient condition (e.g. I/O
    /// contention, temporary unavailability) caused the failure and the caller
    /// is expected to succeed on a subsequent attempt without changing the
    /// request.  Use `false` for permanent failures such as "job not found" or
    /// internal logic errors.
    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
        ErrorResponse {
            schema_version: SCHEMA_VERSION,
            ok: false,
            kind: "error",
            error: ErrorDetail {
                code: code.into(),
                message: message.into(),
                retryable,
            },
        }
    }

    pub fn print(&self) {
        print_to_stdout(self);
    }
}

// ---------- Command-specific response payloads ----------

/// Response for `create` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateData {
    pub job_id: String,
    /// Always "created".
    pub state: String,
    /// Absolute path to stdout.log for this job.
    pub stdout_log_path: String,
    /// Absolute path to stderr.log for this job.
    pub stderr_log_path: String,
}

/// Response for `run` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct RunData {
    pub job_id: String,
    pub state: String,
    /// Tags assigned to this job (always present; empty array when none).
    #[serde(default)]
    pub tags: Vec<String>,
    /// Environment variables passed to the job, with masked values replaced by "***".
    /// Omitted from JSON when empty.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub env_vars: Vec<String>,
    /// Present when `snapshot_after` elapsed before `run` returned.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub snapshot: Option<Snapshot>,
    /// Absolute path to stdout.log for this job.
    pub stdout_log_path: String,
    /// Absolute path to stderr.log for this job.
    pub stderr_log_path: String,
    /// Milliseconds actually waited for snapshot (0 when snapshot_after=0).
    pub waited_ms: u64,
    /// Wall-clock milliseconds from run invocation start to JSON output.
    pub elapsed_ms: u64,
    /// Exit code of the process; present only when `--wait` is used and job has terminated.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// RFC 3339 timestamp when the job finished; present only when `--wait` is used.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<String>,
    /// Final log tail snapshot taken after job completion; present only when `--wait` is used.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub final_snapshot: Option<Snapshot>,
}

/// Response for `status` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct StatusData {
    pub job_id: String,
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// RFC 3339 timestamp when the job was created (always present).
    pub created_at: String,
    /// RFC 3339 timestamp when the job started executing; absent for `created` state.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub started_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<String>,
}

/// Response for `tail` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct TailData {
    pub job_id: String,
    pub stdout_tail: String,
    pub stderr_tail: String,
    /// True when the output was truncated by tail_lines or max_bytes constraints.
    pub truncated: bool,
    pub encoding: String,
    /// Absolute path to stdout.log for this job.
    pub stdout_log_path: String,
    /// Absolute path to stderr.log for this job.
    pub stderr_log_path: String,
    /// Size of stdout.log in bytes at the time of the tail read (0 if file absent).
    pub stdout_observed_bytes: u64,
    /// Size of stderr.log in bytes at the time of the tail read (0 if file absent).
    pub stderr_observed_bytes: u64,
    /// UTF-8 byte length of the stdout_tail string included in this response.
    pub stdout_included_bytes: u64,
    /// UTF-8 byte length of the stderr_tail string included in this response.
    pub stderr_included_bytes: u64,
}

/// Response for `wait` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct WaitData {
    pub job_id: String,
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
}

/// Response for `kill` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct KillData {
    pub job_id: String,
    pub signal: String,
}

/// Response for `schema` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct SchemaData {
    /// The JSON Schema format identifier (e.g. "json-schema-draft-07").
    pub schema_format: String,
    /// The JSON Schema document describing all CLI response types.
    pub schema: serde_json::Value,
    /// Timestamp when the schema file was last updated (RFC 3339).
    pub generated_at: String,
}

/// Summary of a single job, included in `list` responses.
#[derive(Debug, Serialize, Deserialize)]
pub struct JobSummary {
    pub job_id: String,
    /// Job state: created | running | exited | killed | failed | unknown
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// Creation timestamp from meta.json (RFC 3339).
    pub created_at: String,
    /// Execution start timestamp; absent for `created` state.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub started_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated_at: Option<String>,
    /// Tags assigned to this job (always present; empty array when none).
    #[serde(default)]
    pub tags: Vec<String>,
}

/// Response for `tag set` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct TagSetData {
    pub job_id: String,
    /// The new deduplicated tag list as persisted to meta.json.
    pub tags: Vec<String>,
}

/// Response for `list` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListData {
    /// Resolved root directory path.
    pub root: String,
    /// Array of job summaries, sorted by started_at descending.
    pub jobs: Vec<JobSummary>,
    /// True when the result was truncated by --limit.
    pub truncated: bool,
    /// Number of directories skipped because they could not be read as jobs.
    pub skipped: u64,
}

/// Per-job result entry in a `gc` response.
#[derive(Debug, Serialize, Deserialize)]
pub struct GcJobResult {
    pub job_id: String,
    /// Job state as reported from state.json: running | exited | killed | failed | unknown
    pub state: String,
    /// What GC did: "deleted" | "would_delete" | "skipped"
    pub action: String,
    /// Human-readable explanation for the action.
    pub reason: String,
    /// Byte size of the job directory (0 for skipped jobs where size is not computed).
    pub bytes: u64,
}

/// Response for the `gc` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct GcData {
    /// Resolved root directory path.
    pub root: String,
    /// Whether this was a dry-run (no deletions performed).
    pub dry_run: bool,
    /// The effective retention window (e.g. "30d").
    pub older_than: String,
    /// How the retention window was determined: "default" or "flag".
    pub older_than_source: String,
    /// Number of job directories actually deleted (0 when dry_run=true).
    pub deleted: u64,
    /// Number of job directories skipped (running, unreadable, or too recent).
    pub skipped: u64,
    /// Total bytes freed (or would be freed in dry-run mode).
    pub freed_bytes: u64,
    /// Per-job details.
    pub jobs: Vec<GcJobResult>,
}

/// Per-job result entry in a `delete` response.
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteJobResult {
    pub job_id: String,
    /// Job state as reported from state.json: created | running | exited | killed | failed | unknown
    pub state: String,
    /// What delete did: "deleted" | "would_delete" | "skipped"
    pub action: String,
    /// Human-readable explanation for the action.
    pub reason: String,
}

/// Response for the `delete` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteData {
    /// Resolved root directory path.
    pub root: String,
    /// Whether this was a dry-run (no deletions performed).
    pub dry_run: bool,
    /// Number of job directories actually deleted (0 when dry_run=true).
    pub deleted: u64,
    /// Number of job directories skipped.
    pub skipped: u64,
    /// Per-job details.
    pub jobs: Vec<DeleteJobResult>,
}

// ---------- install-skills response payload ----------

/// Summary of a single installed skill, included in `install_skills` responses.
#[derive(Debug, Serialize, Deserialize)]
pub struct InstalledSkillSummary {
    /// Skill name (directory name under `.agents/skills/`).
    pub name: String,
    /// Source type string used when the skill was installed (e.g. "self", "local").
    pub source_type: String,
    /// Absolute path to the installed skill directory.
    pub path: String,
}

/// Response for `notify set` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct NotifySetData {
    pub job_id: String,
    /// Updated notification configuration saved to meta.json.
    pub notification: NotificationConfig,
}

/// Response for `install-skills` command.
#[derive(Debug, Serialize, Deserialize)]
pub struct InstallSkillsData {
    /// List of installed skills.
    pub skills: Vec<InstalledSkillSummary>,
    /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
    pub global: bool,
    /// Absolute path to the `.skill-lock.json` file that was updated.
    pub lock_file_path: String,
}

/// Snapshot of stdout/stderr tail at a point in time.
#[derive(Debug, Serialize, Deserialize)]
pub struct Snapshot {
    pub stdout_tail: String,
    pub stderr_tail: String,
    /// True when the output was truncated by tail_lines or max_bytes constraints.
    pub truncated: bool,
    pub encoding: String,
    /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
    pub stdout_observed_bytes: u64,
    /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
    pub stderr_observed_bytes: u64,
    /// UTF-8 byte length of the stdout_tail string included in this snapshot.
    pub stdout_included_bytes: u64,
    /// UTF-8 byte length of the stderr_tail string included in this snapshot.
    pub stderr_included_bytes: u64,
}

// ---------- Notification / completion event models ----------

/// Match type for output-match notification.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputMatchType {
    #[default]
    Contains,
    Regex,
}

/// Stream selector for output-match notification.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputMatchStream {
    Stdout,
    Stderr,
    #[default]
    Either,
}

/// Configuration for output-match notifications.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchConfig {
    /// Pattern to match against output lines.
    pub pattern: String,
    /// Match type: contains (substring) or regex.
    #[serde(default)]
    pub match_type: OutputMatchType,
    /// Which stream to match: stdout, stderr, or either.
    #[serde(default)]
    pub stream: OutputMatchStream,
    /// Shell command string for command sink; executed via platform shell on match.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// File path for NDJSON append sink.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
}

/// Notification configuration persisted in meta.json.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotificationConfig {
    /// Shell command string for command sink; executed via platform shell on completion.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notify_command: Option<String>,
    /// File path for NDJSON append sink.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notify_file: Option<String>,
    /// Output-match notification configuration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub on_output_match: Option<OutputMatchConfig>,
}

/// The `job.finished` event payload.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompletionEvent {
    pub schema_version: String,
    pub event_type: String,
    pub job_id: String,
    pub state: String,
    pub command: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    pub started_at: String,
    pub finished_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signal: Option<String>,
    pub stdout_log_path: String,
    pub stderr_log_path: String,
}

/// Delivery result for a single notification sink.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SinkDeliveryResult {
    pub sink_type: String,
    pub target: String,
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    pub attempted_at: String,
}

/// Persisted in `completion_event.json` after terminal state is reached.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompletionEventRecord {
    #[serde(flatten)]
    pub event: CompletionEvent,
    pub delivery_results: Vec<SinkDeliveryResult>,
}

/// The `job.output.matched` event payload.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchEvent {
    pub schema_version: String,
    pub event_type: String,
    pub job_id: String,
    pub pattern: String,
    pub match_type: String,
    pub stream: String,
    pub line: String,
    pub stdout_log_path: String,
    pub stderr_log_path: String,
}

/// Delivery record for a single output-match event; appended to `notification_events.ndjson`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchEventRecord {
    #[serde(flatten)]
    pub event: OutputMatchEvent,
    pub delivery_results: Vec<SinkDeliveryResult>,
}

// ---------- Persisted job metadata / state ----------

/// Nested `job` block within `meta.json`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobMetaJob {
    pub id: String,
}

/// Persisted in `meta.json` at job creation time.
///
/// Structure:
/// ```json
/// {
///   "job": { "id": "..." },
///   "schema_version": "0.1",
///   "command": [...],
///   "created_at": "...",
///   "root": "...",
///   "env_keys": [...],
///   "env_vars": [...],
///   "mask": [...]
/// }
/// ```
///
/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***" (display only).
/// `env_vars_runtime` stores the actual (unmasked) KEY=VALUE strings used at `start` time.
///   For the `run` command, this field is empty (env vars are passed directly to the supervisor).
///   For the `create`/`start` lifecycle, this field persists the real KEY=VALUE pairs so
///   `start` can apply them without re-specifying CLI arguments.
/// `mask` stores the list of keys whose values are masked in output/metadata views.
/// `cwd` stores the effective working directory at job creation time (canonicalized).
///
/// For the `create`/`start` lifecycle, additional execution-definition fields are
/// persisted so that `start` can launch the job without re-specifying CLI arguments.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobMeta {
    pub job: JobMetaJob,
    pub schema_version: String,
    pub command: Vec<String>,
    pub created_at: String,
    pub root: String,
    /// Keys of environment variables provided at job creation time.
    pub env_keys: Vec<String>,
    /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
    /// Used for display in JSON responses and metadata views only.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub env_vars: Vec<String>,
    /// Actual (unmasked) KEY=VALUE env var pairs persisted for `start` runtime use.
    /// Only populated in the `create`/`start` lifecycle. For `run`, this is empty
    /// because env vars are passed directly to the supervisor.
    /// `--env` in the create/start lifecycle is treated as durable, non-secret configuration;
    /// use `--env-file` for values that should never be written to disk.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub env_vars_runtime: Vec<String>,
    /// Keys whose values are masked in output.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub mask: Vec<String>,
    /// Effective working directory at job creation time (canonicalized absolute path).
    /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub cwd: Option<String>,
    /// Notification configuration (present only when --notify-command or --notify-file was used).
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub notification: Option<NotificationConfig>,
    /// User-defined tags for grouping and filtering. Empty array when none.
    #[serde(default)]
    pub tags: Vec<String>,

    // --- Execution-definition fields (persisted for create/start lifecycle) ---
    /// Whether to inherit the current process environment at start time. Default: true.
    #[serde(default = "default_inherit_env")]
    pub inherit_env: bool,
    /// Env-file paths to apply in order at start time (real values read from file on start).
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub env_files: Vec<String>,
    /// Timeout in milliseconds; 0 = no timeout.
    #[serde(default)]
    pub timeout_ms: u64,
    /// Milliseconds after SIGTERM before SIGKILL; 0 = immediate SIGKILL.
    #[serde(default)]
    pub kill_after_ms: u64,
    /// Interval (ms) for state.json updated_at refresh; 0 = disabled.
    #[serde(default)]
    pub progress_every_ms: u64,
    /// Resolved shell wrapper argv (e.g. ["sh", "-lc"]). None = resolved from config at start time.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub shell_wrapper: Option<Vec<String>>,
}

fn default_inherit_env() -> bool {
    true
}

impl JobMeta {
    /// Convenience accessor: returns the job ID.
    pub fn job_id(&self) -> &str {
        &self.job.id
    }
}

/// Nested `job` block within `state.json`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobStateJob {
    pub id: String,
    pub status: JobStatus,
    /// RFC 3339 execution start timestamp; absent for jobs in `created` state.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub started_at: Option<String>,
}

/// Nested `result` block within `state.json`.
///
/// Option fields are serialized as `null` (not omitted) so callers always
/// see consistent keys regardless of job lifecycle stage.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobStateResult {
    /// `null` while running; set to exit code when process ends.
    pub exit_code: Option<i32>,
    /// `null` unless the process was killed by a signal.
    pub signal: Option<String>,
    /// `null` while running; set to elapsed milliseconds when process ends.
    pub duration_ms: Option<u64>,
}

/// Persisted in `state.json`, updated as the job progresses.
///
/// Structure:
/// ```json
/// {
///   "job": { "id": "...", "status": "running", "started_at": "..." },
///   "result": { "exit_code": null, "signal": null, "duration_ms": null },
///   "updated_at": "..."
/// }
/// ```
///
/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
/// Option fields MUST be serialized as `null` (not omitted) so callers always
/// see consistent keys regardless of job lifecycle stage.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobState {
    pub job: JobStateJob,
    pub result: JobStateResult,
    /// Process ID (not part of the public spec; omitted when not available).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,
    /// Finish time (not part of the nested result block; kept for internal use).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<String>,
    /// Last time this state was written to disk (RFC 3339).
    pub updated_at: String,
    /// Windows-only: name of the Job Object used to manage the process tree.
    /// Present only when the supervisor successfully created and assigned a
    /// named Job Object; absent on non-Windows platforms and when creation
    /// fails (in which case tree management falls back to snapshot enumeration).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub windows_job_name: Option<String>,
}

impl JobState {
    /// Convenience accessor: returns the job ID.
    pub fn job_id(&self) -> &str {
        &self.job.id
    }

    /// Convenience accessor: returns the job status.
    pub fn status(&self) -> &JobStatus {
        &self.job.status
    }

    /// Convenience accessor: returns the started_at timestamp, if present.
    pub fn started_at(&self) -> Option<&str> {
        self.job.started_at.as_deref()
    }

    /// Convenience accessor: returns the exit code.
    pub fn exit_code(&self) -> Option<i32> {
        self.result.exit_code
    }

    /// Convenience accessor: returns the signal name.
    pub fn signal(&self) -> Option<&str> {
        self.result.signal.as_deref()
    }

    /// Convenience accessor: returns the duration in milliseconds.
    pub fn duration_ms(&self) -> Option<u64> {
        self.result.duration_ms
    }
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
    Created,
    Running,
    Exited,
    Killed,
    Failed,
}

impl JobStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            JobStatus::Created => "created",
            JobStatus::Running => "running",
            JobStatus::Exited => "exited",
            JobStatus::Killed => "killed",
            JobStatus::Failed => "failed",
        }
    }

    /// Returns true when the status is a non-terminal state (created or running).
    pub fn is_non_terminal(&self) -> bool {
        matches!(self, JobStatus::Created | JobStatus::Running)
    }
}