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    pub skipped: u64,
331    /// Total bytes freed (or would be freed in dry-run mode).
332    pub freed_bytes: u64,
333    /// Per-job details.
334    pub jobs: Vec<GcJobResult>,
335}
336
337/// Per-job result entry in a `delete` response.
338#[derive(Debug, Serialize, Deserialize)]
339pub struct DeleteJobResult {
340    pub job_id: String,
341    /// Job state as reported from state.json: created | running | exited | killed | failed | unknown
342    pub state: String,
343    /// What delete did: "deleted" | "would_delete" | "skipped"
344    pub action: String,
345    /// Human-readable explanation for the action.
346    pub reason: String,
347}
348
349/// Response for the `delete` command.
350#[derive(Debug, Serialize, Deserialize)]
351pub struct DeleteData {
352    /// Resolved root directory path.
353    pub root: String,
354    /// Whether this was a dry-run (no deletions performed).
355    pub dry_run: bool,
356    /// Number of job directories actually deleted (0 when dry_run=true).
357    pub deleted: u64,
358    /// Number of job directories skipped.
359    pub skipped: u64,
360    /// Per-job details.
361    pub jobs: Vec<DeleteJobResult>,
362}
363
364// ---------- install-skills response payload ----------
365
366/// Summary of a single installed skill, included in `install_skills` responses.
367#[derive(Debug, Serialize, Deserialize)]
368pub struct InstalledSkillSummary {
369    /// Skill name (directory name under `.agents/skills/`).
370    pub name: String,
371    /// Source type string used when the skill was installed (currently "embedded").
372    pub source_type: String,
373    /// Absolute path to the installed skill directory.
374    pub path: String,
375}
376
377/// Response for `notify set` command.
378#[derive(Debug, Serialize, Deserialize)]
379pub struct NotifySetData {
380    pub job_id: String,
381    /// Updated notification configuration saved to meta.json.
382    pub notification: NotificationConfig,
383}
384
385/// Response for `install-skills` command.
386#[derive(Debug, Serialize, Deserialize)]
387pub struct InstallSkillsData {
388    /// List of installed skills.
389    pub skills: Vec<InstalledSkillSummary>,
390    /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
391    pub global: bool,
392    /// Absolute path to the `.skill-lock.json` file that was updated.
393    pub lock_file_path: String,
394}
395
396/// Snapshot of stdout/stderr tail at a point in time.
397#[derive(Debug, Serialize, Deserialize)]
398pub struct Snapshot {
399    pub stdout_tail: String,
400    pub stderr_tail: String,
401    /// True when the output was truncated by tail_lines or max_bytes constraints.
402    pub truncated: bool,
403    pub encoding: String,
404    /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
405    pub stdout_observed_bytes: u64,
406    /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
407    pub stderr_observed_bytes: u64,
408    /// UTF-8 byte length of the stdout_tail string included in this snapshot.
409    pub stdout_included_bytes: u64,
410    /// UTF-8 byte length of the stderr_tail string included in this snapshot.
411    pub stderr_included_bytes: u64,
412}
413
414// ---------- Notification / completion event models ----------
415
416/// Match type for output-match notification.
417#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
418#[serde(rename_all = "lowercase")]
419pub enum OutputMatchType {
420    #[default]
421    Contains,
422    Regex,
423}
424
425/// Stream selector for output-match notification.
426#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
427#[serde(rename_all = "lowercase")]
428pub enum OutputMatchStream {
429    Stdout,
430    Stderr,
431    #[default]
432    Either,
433}
434
435/// Configuration for output-match notifications.
436#[derive(Debug, Serialize, Deserialize, Clone)]
437pub struct OutputMatchConfig {
438    /// Pattern to match against output lines.
439    pub pattern: String,
440    /// Match type: contains (substring) or regex.
441    #[serde(default)]
442    pub match_type: OutputMatchType,
443    /// Which stream to match: stdout, stderr, or either.
444    #[serde(default)]
445    pub stream: OutputMatchStream,
446    /// Shell command string for command sink; executed via platform shell on match.
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub command: Option<String>,
449    /// File path for NDJSON append sink.
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub file: Option<String>,
452}
453
454/// Notification configuration persisted in meta.json.
455#[derive(Debug, Serialize, Deserialize, Clone)]
456pub struct NotificationConfig {
457    /// Shell command string for command sink; executed via platform shell on completion.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub notify_command: Option<String>,
460    /// File path for NDJSON append sink.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub notify_file: Option<String>,
463    /// Output-match notification configuration.
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub on_output_match: Option<OutputMatchConfig>,
466}
467
468/// The `job.finished` event payload.
469#[derive(Debug, Serialize, Deserialize, Clone)]
470pub struct CompletionEvent {
471    pub schema_version: String,
472    pub event_type: String,
473    pub job_id: String,
474    pub state: String,
475    pub command: Vec<String>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub cwd: Option<String>,
478    pub started_at: String,
479    pub finished_at: String,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub duration_ms: Option<u64>,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub exit_code: Option<i32>,
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub signal: Option<String>,
486    pub stdout_log_path: String,
487    pub stderr_log_path: String,
488}
489
490/// Delivery result for a single notification sink.
491#[derive(Debug, Serialize, Deserialize, Clone)]
492pub struct SinkDeliveryResult {
493    pub sink_type: String,
494    pub target: String,
495    pub success: bool,
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub error: Option<String>,
498    pub attempted_at: String,
499}
500
501/// Persisted in `completion_event.json` after terminal state is reached.
502#[derive(Debug, Serialize, Deserialize, Clone)]
503pub struct CompletionEventRecord {
504    #[serde(flatten)]
505    pub event: CompletionEvent,
506    pub delivery_results: Vec<SinkDeliveryResult>,
507}
508
509/// The `job.output.matched` event payload.
510#[derive(Debug, Serialize, Deserialize, Clone)]
511pub struct OutputMatchEvent {
512    pub schema_version: String,
513    pub event_type: String,
514    pub job_id: String,
515    pub pattern: String,
516    pub match_type: String,
517    pub stream: String,
518    pub line: String,
519    pub stdout_log_path: String,
520    pub stderr_log_path: String,
521}
522
523/// Delivery record for a single output-match event; appended to `notification_events.ndjson`.
524#[derive(Debug, Serialize, Deserialize, Clone)]
525pub struct OutputMatchEventRecord {
526    #[serde(flatten)]
527    pub event: OutputMatchEvent,
528    pub delivery_results: Vec<SinkDeliveryResult>,
529}
530
531// ---------- Persisted job metadata / state ----------
532
533/// Nested `job` block within `meta.json`.
534#[derive(Debug, Serialize, Deserialize, Clone)]
535pub struct JobMetaJob {
536    pub id: String,
537}
538
539/// Persisted in `meta.json` at job creation time.
540///
541/// Structure:
542/// ```json
543/// {
544///   "job": { "id": "..." },
545///   "schema_version": "0.1",
546///   "command": [...],
547///   "created_at": "...",
548///   "root": "...",
549///   "env_keys": [...],
550///   "env_vars": [...],
551///   "mask": [...]
552/// }
553/// ```
554///
555/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
556/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***" (display only).
557/// `env_vars_runtime` stores the actual (unmasked) KEY=VALUE strings used at `start` time.
558///   For the `run` command, this field is empty (env vars are passed directly to the supervisor).
559///   For the `create`/`start` lifecycle, this field persists the real KEY=VALUE pairs so
560///   `start` can apply them without re-specifying CLI arguments.
561/// `mask` stores the list of keys whose values are masked in output/metadata views.
562/// `cwd` stores the effective working directory at job creation time (canonicalized).
563///
564/// For the `create`/`start` lifecycle, additional execution-definition fields are
565/// persisted so that `start` can launch the job without re-specifying CLI arguments.
566#[derive(Debug, Serialize, Deserialize, Clone)]
567pub struct JobMeta {
568    pub job: JobMetaJob,
569    pub schema_version: String,
570    pub command: Vec<String>,
571    pub created_at: String,
572    pub root: String,
573    /// Keys of environment variables provided at job creation time.
574    pub env_keys: Vec<String>,
575    /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
576    /// Used for display in JSON responses and metadata views only.
577    #[serde(skip_serializing_if = "Vec::is_empty", default)]
578    pub env_vars: Vec<String>,
579    /// Actual (unmasked) KEY=VALUE env var pairs persisted for `start` runtime use.
580    /// Only populated in the `create`/`start` lifecycle. For `run`, this is empty
581    /// because env vars are passed directly to the supervisor.
582    /// `--env` in the create/start lifecycle is treated as durable, non-secret configuration;
583    /// use `--env-file` for values that should never be written to disk.
584    #[serde(skip_serializing_if = "Vec::is_empty", default)]
585    pub env_vars_runtime: Vec<String>,
586    /// Keys whose values are masked in output.
587    #[serde(skip_serializing_if = "Vec::is_empty", default)]
588    pub mask: Vec<String>,
589    /// Effective working directory at job creation time (canonicalized absolute path).
590    /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
591    #[serde(skip_serializing_if = "Option::is_none", default)]
592    pub cwd: Option<String>,
593    /// Notification configuration (present only when --notify-command or --notify-file was used).
594    #[serde(skip_serializing_if = "Option::is_none", default)]
595    pub notification: Option<NotificationConfig>,
596    /// User-defined tags for grouping and filtering. Empty array when none.
597    #[serde(default)]
598    pub tags: Vec<String>,
599
600    // --- Execution-definition fields (persisted for create/start lifecycle) ---
601    /// Whether to inherit the current process environment at start time. Default: true.
602    #[serde(default = "default_inherit_env")]
603    pub inherit_env: bool,
604    /// Env-file paths to apply in order at start time (real values read from file on start).
605    #[serde(skip_serializing_if = "Vec::is_empty", default)]
606    pub env_files: Vec<String>,
607    /// Timeout in milliseconds; 0 = no timeout.
608    #[serde(default)]
609    pub timeout_ms: u64,
610    /// Milliseconds after SIGTERM before SIGKILL; 0 = immediate SIGKILL.
611    #[serde(default)]
612    pub kill_after_ms: u64,
613    /// Interval (ms) for state.json updated_at refresh; 0 = disabled.
614    #[serde(default)]
615    pub progress_every_ms: u64,
616    /// Resolved shell wrapper argv (e.g. ["sh", "-lc"]). None = resolved from config at start time.
617    #[serde(skip_serializing_if = "Option::is_none", default)]
618    pub shell_wrapper: Option<Vec<String>>,
619    /// Relative path (from job directory) to materialized stdin content.
620    #[serde(skip_serializing_if = "Option::is_none", default)]
621    pub stdin_file: Option<String>,
622}
623
624fn default_inherit_env() -> bool {
625    true
626}
627
628impl JobMeta {
629    /// Convenience accessor: returns the job ID.
630    pub fn job_id(&self) -> &str {
631        &self.job.id
632    }
633}
634
635/// Nested `job` block within `state.json`.
636#[derive(Debug, Serialize, Deserialize, Clone)]
637pub struct JobStateJob {
638    pub id: String,
639    pub status: JobStatus,
640    /// RFC 3339 execution start timestamp; absent for jobs in `created` state.
641    #[serde(skip_serializing_if = "Option::is_none", default)]
642    pub started_at: Option<String>,
643}
644
645/// Nested `result` block within `state.json`.
646///
647/// Option fields are serialized as `null` (not omitted) so callers always
648/// see consistent keys regardless of job lifecycle stage.
649#[derive(Debug, Serialize, Deserialize, Clone)]
650pub struct JobStateResult {
651    /// `null` while running; set to exit code when process ends.
652    pub exit_code: Option<i32>,
653    /// `null` unless the process was killed by a signal.
654    pub signal: Option<String>,
655    /// `null` while running; set to elapsed milliseconds when process ends.
656    pub duration_ms: Option<u64>,
657}
658
659/// Persisted in `state.json`, updated as the job progresses.
660///
661/// Structure:
662/// ```json
663/// {
664///   "job": { "id": "...", "status": "running", "started_at": "..." },
665///   "result": { "exit_code": null, "signal": null, "duration_ms": null },
666///   "updated_at": "..."
667/// }
668/// ```
669///
670/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
671/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
672/// Option fields MUST be serialized as `null` (not omitted) so callers always
673/// see consistent keys regardless of job lifecycle stage.
674#[derive(Debug, Serialize, Deserialize, Clone)]
675pub struct JobState {
676    pub job: JobStateJob,
677    pub result: JobStateResult,
678    /// Process ID (not part of the public spec; omitted when not available).
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub pid: Option<u32>,
681    /// Finish time (not part of the nested result block; kept for internal use).
682    #[serde(skip_serializing_if = "Option::is_none")]
683    pub finished_at: Option<String>,
684    /// Last time this state was written to disk (RFC 3339).
685    pub updated_at: String,
686    /// Windows-only: name of the Job Object used to manage the process tree.
687    /// Present only when the supervisor successfully created and assigned a
688    /// named Job Object; absent on non-Windows platforms and when creation
689    /// fails (in which case tree management falls back to snapshot enumeration).
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub windows_job_name: Option<String>,
692}
693
694impl JobState {
695    /// Convenience accessor: returns the job ID.
696    pub fn job_id(&self) -> &str {
697        &self.job.id
698    }
699
700    /// Convenience accessor: returns the job status.
701    pub fn status(&self) -> &JobStatus {
702        &self.job.status
703    }
704
705    /// Convenience accessor: returns the started_at timestamp, if present.
706    pub fn started_at(&self) -> Option<&str> {
707        self.job.started_at.as_deref()
708    }
709
710    /// Convenience accessor: returns the exit code.
711    pub fn exit_code(&self) -> Option<i32> {
712        self.result.exit_code
713    }
714
715    /// Convenience accessor: returns the signal name.
716    pub fn signal(&self) -> Option<&str> {
717        self.result.signal.as_deref()
718    }
719
720    /// Convenience accessor: returns the duration in milliseconds.
721    pub fn duration_ms(&self) -> Option<u64> {
722        self.result.duration_ms
723    }
724}
725
726#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
727#[serde(rename_all = "lowercase")]
728pub enum JobStatus {
729    Created,
730    Running,
731    Exited,
732    Killed,
733    Failed,
734}
735
736impl JobStatus {
737    pub fn as_str(&self) -> &'static str {
738        match self {
739            JobStatus::Created => "created",
740            JobStatus::Running => "running",
741            JobStatus::Exited => "exited",
742            JobStatus::Killed => "killed",
743            JobStatus::Failed => "failed",
744        }
745    }
746
747    /// Returns true when the status is a non-terminal state (created or running).
748    pub fn is_non_terminal(&self) -> bool {
749        matches!(self, JobStatus::Created | JobStatus::Running)
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    fn sample_run_data(
758        exit_code: Option<i32>,
759        finished_at: Option<&str>,
760        signal: Option<&str>,
761        duration_ms: Option<u64>,
762    ) -> RunData {
763        RunData {
764            job_id: "abc123".into(),
765            state: "exited".into(),
766            tags: vec![],
767            env_vars: vec![],
768            stdout_log_path: "/tmp/stdout.log".into(),
769            stderr_log_path: "/tmp/stderr.log".into(),
770            elapsed_ms: 50,
771            waited_ms: 40,
772            stdout: "".into(),
773            stderr: "".into(),
774            stdout_range: [0, 0],
775            stderr_range: [0, 0],
776            stdout_total_bytes: 0,
777            stderr_total_bytes: 0,
778            encoding: "utf-8-lossy".into(),
779            exit_code,
780            finished_at: finished_at.map(|s| s.to_string()),
781            signal: signal.map(|s| s.to_string()),
782            duration_ms,
783        }
784    }
785
786    #[test]
787    fn run_data_signal_and_duration_present_when_set() {
788        let data = sample_run_data(
789            Some(0),
790            Some("2025-01-01T00:00:01Z"),
791            Some("SIGTERM"),
792            Some(1000),
793        );
794        let json = serde_json::to_value(&data).unwrap();
795        assert_eq!(json["signal"], "SIGTERM");
796        assert_eq!(json["duration_ms"], 1000);
797    }
798
799    #[test]
800    fn run_data_signal_and_duration_omitted_when_none() {
801        let data = sample_run_data(None, None, None, None);
802        let json = serde_json::to_value(&data).unwrap();
803        assert!(
804            json.get("signal").is_none(),
805            "signal should be omitted: {json}"
806        );
807        assert!(
808            json.get("duration_ms").is_none(),
809            "duration_ms should be omitted: {json}"
810        );
811        assert!(
812            json.get("exit_code").is_none(),
813            "exit_code should be omitted: {json}"
814        );
815        assert!(
816            json.get("finished_at").is_none(),
817            "finished_at should be omitted: {json}"
818        );
819    }
820
821    #[test]
822    fn run_data_signal_omitted_duration_present() {
823        let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
824        let json = serde_json::to_value(&data).unwrap();
825        assert!(json.get("signal").is_none(), "signal should be omitted");
826        assert_eq!(json["duration_ms"], 500);
827        assert_eq!(json["exit_code"], 7);
828    }
829
830    #[test]
831    fn wait_data_progress_hints_present_when_set() {
832        let data = WaitData {
833            job_id: "j1".into(),
834            state: "running".into(),
835            exit_code: None,
836            stdout_total_bytes: Some(1024),
837            stderr_total_bytes: Some(256),
838            updated_at: Some("2025-01-01T00:00:00Z".into()),
839        };
840        let json = serde_json::to_value(&data).unwrap();
841        assert_eq!(json["stdout_total_bytes"], 1024);
842        assert_eq!(json["stderr_total_bytes"], 256);
843        assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
844        assert!(json.get("exit_code").is_none());
845    }
846
847    #[test]
848    fn wait_data_progress_hints_omitted_when_none() {
849        let data = WaitData {
850            job_id: "j2".into(),
851            state: "running".into(),
852            exit_code: None,
853            stdout_total_bytes: None,
854            stderr_total_bytes: None,
855            updated_at: None,
856        };
857        let json = serde_json::to_value(&data).unwrap();
858        assert!(json.get("stdout_total_bytes").is_none());
859        assert!(json.get("stderr_total_bytes").is_none());
860        assert!(json.get("updated_at").is_none());
861    }
862
863    #[test]
864    fn wait_data_terminal_with_progress_hints() {
865        let data = WaitData {
866            job_id: "j3".into(),
867            state: "exited".into(),
868            exit_code: Some(0),
869            stdout_total_bytes: Some(512),
870            stderr_total_bytes: Some(0),
871            updated_at: Some("2025-01-01T00:00:02Z".into()),
872        };
873        let json = serde_json::to_value(&data).unwrap();
874        assert_eq!(json["exit_code"], 0);
875        assert_eq!(json["stdout_total_bytes"], 512);
876        assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
877    }
878
879    #[test]
880    fn wait_data_roundtrip() {
881        let data = WaitData {
882            job_id: "j4".into(),
883            state: "exited".into(),
884            exit_code: Some(1),
885            stdout_total_bytes: Some(100),
886            stderr_total_bytes: Some(200),
887            updated_at: Some("2025-06-01T12:00:00Z".into()),
888        };
889        let serialized = serde_json::to_string(&data).unwrap();
890        let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
891        assert_eq!(deserialized.stdout_total_bytes, Some(100));
892        assert_eq!(deserialized.stderr_total_bytes, Some(200));
893        assert_eq!(
894            deserialized.updated_at.as_deref(),
895            Some("2025-06-01T12:00:00Z")
896        );
897    }
898
899    #[test]
900    fn run_data_roundtrip_with_all_fields() {
901        let data = sample_run_data(
902            Some(1),
903            Some("2025-01-01T00:00:02Z"),
904            Some("SIGKILL"),
905            Some(2000),
906        );
907        let serialized = serde_json::to_string(&data).unwrap();
908        let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
909        assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
910        assert_eq!(deserialized.duration_ms, Some(2000));
911    }
912
913    #[test]
914    fn error_detail_omits_details_when_none() {
915        let resp = ErrorResponse::new("test_error", "something went wrong", false);
916        let json = serde_json::to_value(&resp).unwrap();
917        assert!(
918            json["error"].get("details").is_none(),
919            "details should be omitted when None: {json}"
920        );
921    }
922
923    #[test]
924    fn error_detail_includes_details_when_present() {
925        let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
926            serde_json::json!({
927                "candidates": ["id1", "id2"],
928                "truncated": false,
929            }),
930        );
931        let json = serde_json::to_value(&resp).unwrap();
932        let details = &json["error"]["details"];
933        assert!(!details.is_null(), "details must be present: {json}");
934        assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
935        assert_eq!(details["truncated"], false);
936    }
937}