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