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    #[serde(skip_serializing_if = "Option::is_none")]
82    pub details: Option<serde_json::Value>,
83}
84
85impl ErrorResponse {
86    /// Create an error response.
87    ///
88    /// `retryable` should be `true` only when a transient condition (e.g. I/O
89    /// contention, temporary unavailability) caused the failure and the caller
90    /// is expected to succeed on a subsequent attempt without changing the
91    /// request.  Use `false` for permanent failures such as "job not found" or
92    /// internal logic errors.
93    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
94        ErrorResponse {
95            schema_version: SCHEMA_VERSION,
96            ok: false,
97            kind: "error",
98            error: ErrorDetail {
99                code: code.into(),
100                message: message.into(),
101                retryable,
102                details: None,
103            },
104        }
105    }
106
107    pub fn with_details(mut self, details: serde_json::Value) -> Self {
108        self.error.details = Some(details);
109        self
110    }
111
112    pub fn print(&self) {
113        print_to_stdout(self);
114    }
115}
116
117// ---------- Command-specific response payloads ----------
118
119/// Response for `create` command.
120#[derive(Debug, Serialize, Deserialize)]
121pub struct CreateData {
122    pub job_id: String,
123    /// Always "created".
124    pub state: String,
125    /// Absolute path to stdout.log for this job.
126    pub stdout_log_path: String,
127    /// Absolute path to stderr.log for this job.
128    pub stderr_log_path: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct CompressionData {
133    pub mode: String,
134    pub applied: bool,
135    pub detected_kind: String,
136    pub stdout: String,
137    pub stderr: String,
138    pub stdout_original_bytes: u64,
139    pub stderr_original_bytes: u64,
140    pub stdout_compressed_bytes: u64,
141    pub stderr_compressed_bytes: u64,
142    pub omitted: bool,
143    pub strategy: Vec<String>,
144}
145
146/// Response for `run` command.
147#[derive(Debug, Serialize, Deserialize)]
148pub struct RunData {
149    pub job_id: String,
150    pub state: String,
151    /// Tags assigned to this job (always present; empty array when none).
152    #[serde(default)]
153    pub tags: Vec<String>,
154    /// Environment variables passed to the job, with masked values replaced by "***".
155    /// Omitted from JSON when empty.
156    #[serde(skip_serializing_if = "Vec::is_empty", default)]
157    pub env_vars: Vec<String>,
158    /// Absolute path to stdout.log for this job.
159    pub stdout_log_path: String,
160    /// Absolute path to stderr.log for this job.
161    pub stderr_log_path: String,
162    /// Wall-clock milliseconds from run/start invocation start to JSON output.
163    pub elapsed_ms: u64,
164    /// Time spent waiting for inline output observation.
165    pub waited_ms: u64,
166    /// UTF-8 lossy stdout excerpt.
167    pub stdout: String,
168    /// UTF-8 lossy stderr excerpt.
169    pub stderr: String,
170    /// Raw stdout byte range represented by `stdout` as [begin, end).
171    pub stdout_range: [u64; 2],
172    /// Raw stderr byte range represented by `stderr` as [begin, end).
173    pub stderr_range: [u64; 2],
174    /// Total bytes currently observed in stdout.log.
175    pub stdout_total_bytes: u64,
176    /// Total bytes currently observed in stderr.log.
177    pub stderr_total_bytes: u64,
178    /// Encoding contract for stdout/stderr excerpts.
179    pub encoding: String,
180    /// Exit code when terminal.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub exit_code: Option<i32>,
183    /// Finished-at timestamp when terminal.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub finished_at: Option<String>,
186    /// POSIX signal name when terminated by signal (e.g. "SIGTERM").
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub signal: Option<String>,
189    /// Wall-clock milliseconds from started_at to finished_at.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub duration_ms: Option<u64>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub compression: Option<CompressionData>,
194}
195
196/// Response for `status` command.
197#[derive(Debug, Serialize, Deserialize)]
198pub struct StatusData {
199    pub job_id: String,
200    pub state: String,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub exit_code: Option<i32>,
203    /// RFC 3339 timestamp when the job was created (always present).
204    pub created_at: String,
205    /// RFC 3339 timestamp when the job started executing; absent for `created` state.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub started_at: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub finished_at: Option<String>,
210}
211
212/// Response for `tail` command.
213#[derive(Debug, Serialize, Deserialize)]
214pub struct TailData {
215    pub job_id: String,
216    pub stdout: String,
217    pub stderr: String,
218    pub encoding: String,
219    /// Absolute path to stdout.log for this job.
220    pub stdout_log_path: String,
221    /// Absolute path to stderr.log for this job.
222    pub stderr_log_path: String,
223    /// Raw stdout byte range represented by `stdout` as [begin, end).
224    pub stdout_range: [u64; 2],
225    /// Raw stderr byte range represented by `stderr` as [begin, end).
226    pub stderr_range: [u64; 2],
227    /// Total bytes currently observed in stdout.log.
228    pub stdout_total_bytes: u64,
229    /// Total bytes currently observed in stderr.log.
230    pub stderr_total_bytes: u64,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub compression: Option<CompressionData>,
233}
234
235/// Response for `wait` command.
236#[derive(Debug, Serialize, Deserialize)]
237pub struct WaitData {
238    pub job_id: String,
239    pub state: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub exit_code: Option<i32>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub stdout_total_bytes: Option<u64>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub stderr_total_bytes: Option<u64>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub updated_at: Option<String>,
248}
249
250/// Response for `kill` command.
251#[derive(Debug, Serialize, Deserialize)]
252pub struct KillData {
253    pub job_id: String,
254    pub signal: String,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub state: Option<String>,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub exit_code: Option<i32>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub terminated_signal: Option<String>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub observed_within_ms: Option<u64>,
263}
264
265/// Response for `schema` command.
266#[derive(Debug, Serialize, Deserialize)]
267pub struct SchemaData {
268    /// The JSON Schema format identifier (e.g. "json-schema-draft-07").
269    pub schema_format: String,
270    /// The JSON Schema document describing all CLI response types.
271    pub schema: serde_json::Value,
272    /// Timestamp when the schema file was last updated (RFC 3339).
273    pub generated_at: String,
274}
275
276/// Summary of a single job, included in `list` responses.
277#[derive(Debug, Serialize, Deserialize)]
278pub struct JobSummary {
279    pub job_id: String,
280    /// Human-facing short identifier (first 7 characters of job_id).
281    pub short_job_id: String,
282    /// Job state: created | running | exited | killed | failed | unknown
283    pub state: String,
284    /// Original command argv persisted in meta.json.
285    pub command: Vec<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub exit_code: Option<i32>,
288    /// Creation timestamp from meta.json (RFC 3339).
289    pub created_at: String,
290    /// Execution start timestamp; absent for `created` state.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub started_at: Option<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub finished_at: Option<String>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub updated_at: Option<String>,
297    /// Tags assigned to this job (always present; empty array when none).
298    #[serde(default)]
299    pub tags: Vec<String>,
300}
301
302/// Response for `tag set` command.
303#[derive(Debug, Serialize, Deserialize)]
304pub struct TagSetData {
305    pub job_id: String,
306    /// The new deduplicated tag list as persisted to meta.json.
307    pub tags: Vec<String>,
308}
309
310/// Response for `list` command.
311#[derive(Debug, Serialize, Deserialize)]
312pub struct ListData {
313    /// Resolved root directory path.
314    pub root: String,
315    /// Array of job summaries, sorted by started_at descending.
316    pub jobs: Vec<JobSummary>,
317    /// True when the result was truncated by --limit.
318    pub truncated: bool,
319    /// Number of directories skipped because they could not be read as jobs.
320    pub skipped: u64,
321}
322
323/// Per-job result entry in a `gc` response.
324#[derive(Debug, Serialize, Deserialize)]
325pub struct GcJobResult {
326    pub job_id: String,
327    /// Job state as reported from state.json: running | exited | killed | failed | unknown
328    pub state: String,
329    /// What GC did: "deleted" | "would_delete" | "skipped"
330    pub action: String,
331    /// Human-readable explanation for the action.
332    pub reason: String,
333    /// Byte size of the job directory (0 for skipped jobs where size is not computed).
334    pub bytes: u64,
335}
336
337/// Response for the `gc` command.
338#[derive(Debug, Serialize, Deserialize)]
339pub struct GcData {
340    /// Resolved root directory path.
341    pub root: String,
342    /// Whether this was a dry-run (no deletions performed).
343    pub dry_run: bool,
344    /// The effective retention window (e.g. "30d").
345    pub older_than: String,
346    /// How the retention window was determined: "default" or "flag".
347    pub older_than_source: String,
348    /// Number of job directories actually deleted (0 when dry_run=true).
349    pub deleted: u64,
350    /// Number of job directories skipped (running, unreadable, or too recent).
351    /// Equals `out_of_scope + failed` for the per-job results aggregated here.
352    pub skipped: u64,
353    /// Number of jobs that were not candidates for deletion (e.g. running,
354    /// non-terminal status, missing timestamp, retention window not satisfied).
355    pub out_of_scope: u64,
356    /// Number of jobs that were eligible candidates but could not be removed
357    /// (delete syscall failed or post-delete existence check still saw the path).
358    pub failed: u64,
359    /// Total bytes freed (or would be freed in dry-run mode).
360    pub freed_bytes: u64,
361    /// Number of job directories scanned.
362    pub scanned_dirs: u64,
363    /// Number of deletion candidates selected by policy.
364    pub candidate_count: u64,
365    /// Per-job details.
366    pub jobs: Vec<GcJobResult>,
367}
368
369/// Per-job result entry in a `delete` response.
370#[derive(Debug, Serialize, Deserialize)]
371pub struct DeleteJobResult {
372    pub job_id: String,
373    /// Job state as reported from state.json: created | running | exited | killed | failed | unknown
374    pub state: String,
375    /// What delete did: "deleted" | "would_delete" | "skipped"
376    pub action: String,
377    /// Human-readable explanation for the action.
378    pub reason: String,
379}
380
381/// Response for the `delete` command.
382#[derive(Debug, Serialize, Deserialize)]
383pub struct DeleteData {
384    /// Resolved root directory path.
385    pub root: String,
386    /// Whether this was a dry-run (no deletions performed).
387    pub dry_run: bool,
388    /// Effective cwd scope used by `--all` to decide which jobs to evaluate.
389    /// Absent for single-job `delete <JOB_ID>` invocations because they are not
390    /// scoped by cwd.
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub cwd_scope: Option<String>,
393    /// Number of job directories actually deleted (0 when dry_run=true).
394    pub deleted: u64,
395    /// Number of job directories skipped.
396    /// For aggregations involving per-job results, equals `out_of_scope + failed`.
397    pub skipped: u64,
398    /// Number of jobs that were filtered out before any deletion was attempted
399    /// (cwd mismatch for `--all`, or non-terminal/state-unreadable jobs).
400    pub out_of_scope: u64,
401    /// Number of jobs that were targeted for deletion but the deletion did not
402    /// take effect (delete syscall failed or post-delete existence check still
403    /// saw the path).
404    pub failed: u64,
405    /// Per-job details.
406    pub jobs: Vec<DeleteJobResult>,
407}
408
409// ---------- install-skills response payload ----------
410
411/// Summary of a single installed skill, included in `install_skills` responses.
412#[derive(Debug, Serialize, Deserialize)]
413pub struct InstalledSkillSummary {
414    /// Skill name (directory name under `.agents/skills/`).
415    pub name: String,
416    /// Source type string used when the skill was installed (currently "embedded").
417    pub source_type: String,
418    /// Absolute path to the installed skill directory.
419    pub path: String,
420}
421
422/// Response for `notify set` command.
423#[derive(Debug, Serialize, Deserialize)]
424pub struct NotifySetData {
425    pub job_id: String,
426    /// Updated notification configuration saved to meta.json.
427    pub notification: NotificationConfig,
428}
429
430/// Response for `install-skills` command.
431#[derive(Debug, Serialize, Deserialize)]
432pub struct InstallSkillsData {
433    /// List of installed skills.
434    pub skills: Vec<InstalledSkillSummary>,
435    /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
436    pub global: bool,
437    /// Absolute path to the `.skill-lock.json` file that was updated.
438    pub lock_file_path: String,
439}
440
441/// Snapshot of stdout/stderr tail at a point in time.
442#[derive(Debug, Serialize, Deserialize)]
443pub struct Snapshot {
444    pub stdout_tail: String,
445    pub stderr_tail: String,
446    /// True when the output was truncated by tail_lines or max_bytes constraints.
447    pub truncated: bool,
448    pub encoding: String,
449    /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
450    pub stdout_observed_bytes: u64,
451    /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
452    pub stderr_observed_bytes: u64,
453    /// UTF-8 byte length of the stdout_tail string included in this snapshot.
454    pub stdout_included_bytes: u64,
455    /// UTF-8 byte length of the stderr_tail string included in this snapshot.
456    pub stderr_included_bytes: u64,
457}
458
459// ---------- Notification / completion event models ----------
460
461/// Match type for output-match notification.
462#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
463#[serde(rename_all = "lowercase")]
464pub enum OutputMatchType {
465    #[default]
466    Contains,
467    Regex,
468}
469
470/// Stream selector for output-match notification.
471#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
472#[serde(rename_all = "lowercase")]
473pub enum OutputMatchStream {
474    Stdout,
475    Stderr,
476    #[default]
477    Either,
478}
479
480/// Configuration for output-match notifications.
481#[derive(Debug, Serialize, Deserialize, Clone)]
482pub struct OutputMatchConfig {
483    /// Pattern to match against output lines.
484    pub pattern: String,
485    /// Match type: contains (substring) or regex.
486    #[serde(default)]
487    pub match_type: OutputMatchType,
488    /// Which stream to match: stdout, stderr, or either.
489    #[serde(default)]
490    pub stream: OutputMatchStream,
491    /// Shell command string for command sink; executed via platform shell on match.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub command: Option<String>,
494    /// File path for NDJSON append sink.
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub file: Option<String>,
497}
498
499/// Notification configuration persisted in meta.json.
500#[derive(Debug, Serialize, Deserialize, Clone)]
501pub struct NotificationConfig {
502    /// Shell command string for command sink; executed via platform shell on completion.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub notify_command: Option<String>,
505    /// File path for NDJSON append sink.
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub notify_file: Option<String>,
508    /// Output-match notification configuration.
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub on_output_match: Option<OutputMatchConfig>,
511}
512
513/// The `job.finished` event payload.
514#[derive(Debug, Serialize, Deserialize, Clone)]
515pub struct CompletionEvent {
516    pub schema_version: String,
517    pub event_type: String,
518    pub job_id: String,
519    pub state: String,
520    pub command: Vec<String>,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub cwd: Option<String>,
523    pub started_at: String,
524    pub finished_at: String,
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub duration_ms: Option<u64>,
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub exit_code: Option<i32>,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    pub signal: Option<String>,
531    pub stdout_log_path: String,
532    pub stderr_log_path: String,
533}
534
535/// Delivery result for a single notification sink.
536#[derive(Debug, Serialize, Deserialize, Clone)]
537pub struct SinkDeliveryResult {
538    pub sink_type: String,
539    pub target: String,
540    pub success: bool,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub error: Option<String>,
543    pub attempted_at: String,
544}
545
546/// Persisted in `completion_event.json` after terminal state is reached.
547#[derive(Debug, Serialize, Deserialize, Clone)]
548pub struct CompletionEventRecord {
549    #[serde(flatten)]
550    pub event: CompletionEvent,
551    pub delivery_results: Vec<SinkDeliveryResult>,
552}
553
554/// The `job.output.matched` event payload.
555#[derive(Debug, Serialize, Deserialize, Clone)]
556pub struct OutputMatchEvent {
557    pub schema_version: String,
558    pub event_type: String,
559    pub job_id: String,
560    pub pattern: String,
561    pub match_type: String,
562    pub stream: String,
563    pub line: String,
564    pub stdout_log_path: String,
565    pub stderr_log_path: String,
566}
567
568/// Delivery record for a single output-match event; appended to `notification_events.ndjson`.
569#[derive(Debug, Serialize, Deserialize, Clone)]
570pub struct OutputMatchEventRecord {
571    #[serde(flatten)]
572    pub event: OutputMatchEvent,
573    pub delivery_results: Vec<SinkDeliveryResult>,
574}
575
576// ---------- Persisted job metadata / state ----------
577
578/// Nested `job` block within `meta.json`.
579#[derive(Debug, Serialize, Deserialize, Clone)]
580pub struct JobMetaJob {
581    pub id: String,
582}
583
584/// Persisted in `meta.json` at job creation time.
585///
586/// Structure:
587/// ```json
588/// {
589///   "job": { "id": "..." },
590///   "schema_version": "0.1",
591///   "command": [...],
592///   "created_at": "...",
593///   "root": "...",
594///   "env_keys": [...],
595///   "env_vars": [...],
596///   "mask": [...]
597/// }
598/// ```
599///
600/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
601/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***" (display only).
602/// `env_vars_runtime` stores the actual (unmasked) KEY=VALUE strings used at `start` time.
603///   For the `run` command, this field is empty (env vars are passed directly to the supervisor).
604///   For the `create`/`start` lifecycle, this field persists the real KEY=VALUE pairs so
605///   `start` can apply them without re-specifying CLI arguments.
606/// `mask` stores the list of keys whose values are masked in output/metadata views.
607/// `cwd` stores the effective working directory at job creation time (canonicalized).
608///
609/// For the `create`/`start` lifecycle, additional execution-definition fields are
610/// persisted so that `start` can launch the job without re-specifying CLI arguments.
611#[derive(Debug, Serialize, Deserialize, Clone)]
612pub struct JobMeta {
613    pub job: JobMetaJob,
614    pub schema_version: String,
615    pub command: Vec<String>,
616    pub created_at: String,
617    pub root: String,
618    /// Keys of environment variables provided at job creation time.
619    pub env_keys: Vec<String>,
620    /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
621    /// Used for display in JSON responses and metadata views only.
622    #[serde(skip_serializing_if = "Vec::is_empty", default)]
623    pub env_vars: Vec<String>,
624    /// Actual (unmasked) KEY=VALUE env var pairs persisted for `start` runtime use.
625    /// Only populated in the `create`/`start` lifecycle. For `run`, this is empty
626    /// because env vars are passed directly to the supervisor.
627    /// `--env` in the create/start lifecycle is treated as durable, non-secret configuration;
628    /// use `--env-file` for values that should never be written to disk.
629    #[serde(skip_serializing_if = "Vec::is_empty", default)]
630    pub env_vars_runtime: Vec<String>,
631    /// Keys whose values are masked in output.
632    #[serde(skip_serializing_if = "Vec::is_empty", default)]
633    pub mask: Vec<String>,
634    /// Effective working directory at job creation time (canonicalized absolute path).
635    /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
636    #[serde(skip_serializing_if = "Option::is_none", default)]
637    pub cwd: Option<String>,
638    /// Notification configuration (present only when --notify-command or --notify-file was used).
639    #[serde(skip_serializing_if = "Option::is_none", default)]
640    pub notification: Option<NotificationConfig>,
641    /// User-defined tags for grouping and filtering. Empty array when none.
642    #[serde(default)]
643    pub tags: Vec<String>,
644
645    // --- Execution-definition fields (persisted for create/start lifecycle) ---
646    /// Whether to inherit the current process environment at start time. Default: true.
647    #[serde(default = "default_inherit_env")]
648    pub inherit_env: bool,
649    /// Env-file paths to apply in order at start time (real values read from file on start).
650    #[serde(skip_serializing_if = "Vec::is_empty", default)]
651    pub env_files: Vec<String>,
652    /// Timeout in milliseconds; 0 = no timeout.
653    #[serde(default)]
654    pub timeout_ms: u64,
655    /// Milliseconds after SIGTERM before SIGKILL; 0 = immediate SIGKILL.
656    #[serde(default)]
657    pub kill_after_ms: u64,
658    /// Interval (ms) for state.json updated_at refresh; 0 = disabled.
659    #[serde(default)]
660    pub progress_every_ms: u64,
661    /// Resolved shell wrapper argv (e.g. ["sh", "-lc"]). None = resolved from config at start time.
662    #[serde(skip_serializing_if = "Option::is_none", default)]
663    pub shell_wrapper: Option<Vec<String>>,
664    /// Relative path (from job directory) to materialized stdin content.
665    #[serde(skip_serializing_if = "Option::is_none", default)]
666    pub stdin_file: Option<String>,
667}
668
669fn default_inherit_env() -> bool {
670    true
671}
672
673impl JobMeta {
674    /// Convenience accessor: returns the job ID.
675    pub fn job_id(&self) -> &str {
676        &self.job.id
677    }
678}
679
680/// Nested `job` block within `state.json`.
681#[derive(Debug, Serialize, Deserialize, Clone)]
682pub struct JobStateJob {
683    pub id: String,
684    pub status: JobStatus,
685    /// RFC 3339 execution start timestamp; absent for jobs in `created` state.
686    #[serde(skip_serializing_if = "Option::is_none", default)]
687    pub started_at: Option<String>,
688}
689
690/// Nested `result` block within `state.json`.
691///
692/// Option fields are serialized as `null` (not omitted) so callers always
693/// see consistent keys regardless of job lifecycle stage.
694#[derive(Debug, Serialize, Deserialize, Clone)]
695pub struct JobStateResult {
696    /// `null` while running; set to exit code when process ends.
697    pub exit_code: Option<i32>,
698    /// `null` unless the process was killed by a signal.
699    pub signal: Option<String>,
700    /// `null` while running; set to elapsed milliseconds when process ends.
701    pub duration_ms: Option<u64>,
702}
703
704/// Persisted in `state.json`, updated as the job progresses.
705///
706/// Structure:
707/// ```json
708/// {
709///   "job": { "id": "...", "status": "running", "started_at": "..." },
710///   "result": { "exit_code": null, "signal": null, "duration_ms": null },
711///   "updated_at": "..."
712/// }
713/// ```
714///
715/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
716/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
717/// Option fields MUST be serialized as `null` (not omitted) so callers always
718/// see consistent keys regardless of job lifecycle stage.
719#[derive(Debug, Serialize, Deserialize, Clone)]
720pub struct JobState {
721    pub job: JobStateJob,
722    pub result: JobStateResult,
723    /// Process ID (not part of the public spec; omitted when not available).
724    #[serde(skip_serializing_if = "Option::is_none")]
725    pub pid: Option<u32>,
726    /// Finish time (not part of the nested result block; kept for internal use).
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub finished_at: Option<String>,
729    /// Last time this state was written to disk (RFC 3339).
730    pub updated_at: String,
731    /// Windows-only: name of the Job Object used to manage the process tree.
732    /// Present only when the supervisor successfully created and assigned a
733    /// named Job Object; absent on non-Windows platforms and when creation
734    /// fails (in which case tree management falls back to snapshot enumeration).
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub windows_job_name: Option<String>,
737}
738
739impl JobState {
740    /// Convenience accessor: returns the job ID.
741    pub fn job_id(&self) -> &str {
742        &self.job.id
743    }
744
745    /// Convenience accessor: returns the job status.
746    pub fn status(&self) -> &JobStatus {
747        &self.job.status
748    }
749
750    /// Convenience accessor: returns the started_at timestamp, if present.
751    pub fn started_at(&self) -> Option<&str> {
752        self.job.started_at.as_deref()
753    }
754
755    /// Convenience accessor: returns the exit code.
756    pub fn exit_code(&self) -> Option<i32> {
757        self.result.exit_code
758    }
759
760    /// Convenience accessor: returns the signal name.
761    pub fn signal(&self) -> Option<&str> {
762        self.result.signal.as_deref()
763    }
764
765    /// Convenience accessor: returns the duration in milliseconds.
766    pub fn duration_ms(&self) -> Option<u64> {
767        self.result.duration_ms
768    }
769}
770
771#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
772#[serde(rename_all = "lowercase")]
773pub enum JobStatus {
774    Created,
775    Running,
776    Exited,
777    Killed,
778    Failed,
779}
780
781impl JobStatus {
782    pub fn as_str(&self) -> &'static str {
783        match self {
784            JobStatus::Created => "created",
785            JobStatus::Running => "running",
786            JobStatus::Exited => "exited",
787            JobStatus::Killed => "killed",
788            JobStatus::Failed => "failed",
789        }
790    }
791
792    /// Returns true when the status is a non-terminal state (created or running).
793    pub fn is_non_terminal(&self) -> bool {
794        matches!(self, JobStatus::Created | JobStatus::Running)
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    fn sample_run_data(
803        exit_code: Option<i32>,
804        finished_at: Option<&str>,
805        signal: Option<&str>,
806        duration_ms: Option<u64>,
807    ) -> RunData {
808        RunData {
809            job_id: "abc123".into(),
810            state: "exited".into(),
811            tags: vec![],
812            env_vars: vec![],
813            stdout_log_path: "/tmp/stdout.log".into(),
814            stderr_log_path: "/tmp/stderr.log".into(),
815            elapsed_ms: 50,
816            waited_ms: 40,
817            stdout: "".into(),
818            stderr: "".into(),
819            stdout_range: [0, 0],
820            stderr_range: [0, 0],
821            stdout_total_bytes: 0,
822            stderr_total_bytes: 0,
823            encoding: "utf-8-lossy".into(),
824            exit_code,
825            finished_at: finished_at.map(|s| s.to_string()),
826            signal: signal.map(|s| s.to_string()),
827            duration_ms,
828            compression: None,
829        }
830    }
831
832    #[test]
833    fn run_data_signal_and_duration_present_when_set() {
834        let data = sample_run_data(
835            Some(0),
836            Some("2025-01-01T00:00:01Z"),
837            Some("SIGTERM"),
838            Some(1000),
839        );
840        let json = serde_json::to_value(&data).unwrap();
841        assert_eq!(json["signal"], "SIGTERM");
842        assert_eq!(json["duration_ms"], 1000);
843    }
844
845    #[test]
846    fn run_data_signal_and_duration_omitted_when_none() {
847        let data = sample_run_data(None, None, None, None);
848        let json = serde_json::to_value(&data).unwrap();
849        assert!(
850            json.get("signal").is_none(),
851            "signal should be omitted: {json}"
852        );
853        assert!(
854            json.get("duration_ms").is_none(),
855            "duration_ms should be omitted: {json}"
856        );
857        assert!(
858            json.get("exit_code").is_none(),
859            "exit_code should be omitted: {json}"
860        );
861        assert!(
862            json.get("finished_at").is_none(),
863            "finished_at should be omitted: {json}"
864        );
865    }
866
867    #[test]
868    fn run_data_signal_omitted_duration_present() {
869        let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
870        let json = serde_json::to_value(&data).unwrap();
871        assert!(json.get("signal").is_none(), "signal should be omitted");
872        assert_eq!(json["duration_ms"], 500);
873        assert_eq!(json["exit_code"], 7);
874    }
875
876    #[test]
877    fn wait_data_progress_hints_present_when_set() {
878        let data = WaitData {
879            job_id: "j1".into(),
880            state: "running".into(),
881            exit_code: None,
882            stdout_total_bytes: Some(1024),
883            stderr_total_bytes: Some(256),
884            updated_at: Some("2025-01-01T00:00:00Z".into()),
885        };
886        let json = serde_json::to_value(&data).unwrap();
887        assert_eq!(json["stdout_total_bytes"], 1024);
888        assert_eq!(json["stderr_total_bytes"], 256);
889        assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
890        assert!(json.get("exit_code").is_none());
891    }
892
893    #[test]
894    fn wait_data_progress_hints_omitted_when_none() {
895        let data = WaitData {
896            job_id: "j2".into(),
897            state: "running".into(),
898            exit_code: None,
899            stdout_total_bytes: None,
900            stderr_total_bytes: None,
901            updated_at: None,
902        };
903        let json = serde_json::to_value(&data).unwrap();
904        assert!(json.get("stdout_total_bytes").is_none());
905        assert!(json.get("stderr_total_bytes").is_none());
906        assert!(json.get("updated_at").is_none());
907    }
908
909    #[test]
910    fn wait_data_terminal_with_progress_hints() {
911        let data = WaitData {
912            job_id: "j3".into(),
913            state: "exited".into(),
914            exit_code: Some(0),
915            stdout_total_bytes: Some(512),
916            stderr_total_bytes: Some(0),
917            updated_at: Some("2025-01-01T00:00:02Z".into()),
918        };
919        let json = serde_json::to_value(&data).unwrap();
920        assert_eq!(json["exit_code"], 0);
921        assert_eq!(json["stdout_total_bytes"], 512);
922        assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
923    }
924
925    #[test]
926    fn wait_data_roundtrip() {
927        let data = WaitData {
928            job_id: "j4".into(),
929            state: "exited".into(),
930            exit_code: Some(1),
931            stdout_total_bytes: Some(100),
932            stderr_total_bytes: Some(200),
933            updated_at: Some("2025-06-01T12:00:00Z".into()),
934        };
935        let serialized = serde_json::to_string(&data).unwrap();
936        let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
937        assert_eq!(deserialized.stdout_total_bytes, Some(100));
938        assert_eq!(deserialized.stderr_total_bytes, Some(200));
939        assert_eq!(
940            deserialized.updated_at.as_deref(),
941            Some("2025-06-01T12:00:00Z")
942        );
943    }
944
945    #[test]
946    fn run_data_roundtrip_with_all_fields() {
947        let data = sample_run_data(
948            Some(1),
949            Some("2025-01-01T00:00:02Z"),
950            Some("SIGKILL"),
951            Some(2000),
952        );
953        let serialized = serde_json::to_string(&data).unwrap();
954        let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
955        assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
956        assert_eq!(deserialized.duration_ms, Some(2000));
957    }
958
959    #[test]
960    fn error_detail_omits_details_when_none() {
961        let resp = ErrorResponse::new("test_error", "something went wrong", false);
962        let json = serde_json::to_value(&resp).unwrap();
963        assert!(
964            json["error"].get("details").is_none(),
965            "details should be omitted when None: {json}"
966        );
967    }
968
969    #[test]
970    fn error_detail_includes_details_when_present() {
971        let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
972            serde_json::json!({
973                "candidates": ["id1", "id2"],
974                "truncated": false,
975            }),
976        );
977        let json = serde_json::to_value(&resp).unwrap();
978        let details = &json["error"]["details"];
979        assert!(!details.is_null(), "details must be present: {json}");
980        assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
981        assert_eq!(details["truncated"], false);
982    }
983}