Skip to main content

agent_exec/
schema.rs

1//! Shared JSON output schema types for agent-exec v0.1.
2//!
3//! All stdout output is JSON only. Tracing logs go to stderr.
4//! Schema version is fixed at "0.1".
5
6use serde::{Deserialize, Serialize};
7
8pub const SCHEMA_VERSION: &str = "0.1";
9
10/// Serialize `value` to a JSON string and print it as a single line to stdout.
11///
12/// This is the single place where stdout JSON output is written, ensuring the
13/// stdout-is-JSON-only contract is enforced uniformly across all response types.
14fn print_json_to_stdout(value: &impl Serialize) {
15    println!(
16        "{}",
17        serde_json::to_string(value).expect("JSON serialization failed")
18    );
19}
20
21/// Top-level envelope used for every successful response.
22#[derive(Debug, Serialize, Deserialize)]
23pub struct Response<T: Serialize> {
24    pub schema_version: &'static str,
25    pub ok: bool,
26    #[serde(rename = "type")]
27    pub kind: &'static str,
28    #[serde(flatten)]
29    pub data: T,
30}
31
32impl<T: Serialize> Response<T> {
33    pub fn new(kind: &'static str, data: T) -> Self {
34        Response {
35            schema_version: SCHEMA_VERSION,
36            ok: true,
37            kind,
38            data,
39        }
40    }
41
42    /// Serialize to a JSON string and print to stdout.
43    pub fn print(&self) {
44        print_json_to_stdout(self);
45    }
46}
47
48/// Top-level envelope for error responses.
49#[derive(Debug, Serialize, Deserialize)]
50pub struct ErrorResponse {
51    pub schema_version: &'static str,
52    pub ok: bool,
53    #[serde(rename = "type")]
54    pub kind: &'static str,
55    pub error: ErrorDetail,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59pub struct ErrorDetail {
60    pub code: String,
61    pub message: String,
62    /// Whether the caller may retry the same request and expect a different outcome.
63    pub retryable: bool,
64}
65
66impl ErrorResponse {
67    /// Create an error response.
68    ///
69    /// `retryable` should be `true` only when a transient condition (e.g. I/O
70    /// contention, temporary unavailability) caused the failure and the caller
71    /// is expected to succeed on a subsequent attempt without changing the
72    /// request.  Use `false` for permanent failures such as "job not found" or
73    /// internal logic errors.
74    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
75        ErrorResponse {
76            schema_version: SCHEMA_VERSION,
77            ok: false,
78            kind: "error",
79            error: ErrorDetail {
80                code: code.into(),
81                message: message.into(),
82                retryable,
83            },
84        }
85    }
86
87    pub fn print(&self) {
88        print_json_to_stdout(self);
89    }
90}
91
92// ---------- Command-specific response payloads ----------
93
94/// Response for `run` command.
95#[derive(Debug, Serialize, Deserialize)]
96pub struct RunData {
97    pub job_id: String,
98    pub state: String,
99    /// Environment variables passed to the job, with masked values replaced by "***".
100    /// Omitted from JSON when empty.
101    #[serde(skip_serializing_if = "Vec::is_empty", default)]
102    pub env_vars: Vec<String>,
103    /// Present when `snapshot_after` elapsed before `run` returned.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub snapshot: Option<Snapshot>,
106    /// Absolute path to stdout.log for this job.
107    pub stdout_log_path: String,
108    /// Absolute path to stderr.log for this job.
109    pub stderr_log_path: String,
110    /// Milliseconds actually waited for snapshot (0 when snapshot_after=0).
111    pub waited_ms: u64,
112    /// Wall-clock milliseconds from run invocation start to JSON output.
113    pub elapsed_ms: u64,
114    /// Exit code of the process; present only when `--wait` is used and job has terminated.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub exit_code: Option<i32>,
117    /// RFC 3339 timestamp when the job finished; present only when `--wait` is used.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub finished_at: Option<String>,
120    /// Final log tail snapshot taken after job completion; present only when `--wait` is used.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub final_snapshot: Option<Snapshot>,
123}
124
125/// Response for `status` command.
126#[derive(Debug, Serialize, Deserialize)]
127pub struct StatusData {
128    pub job_id: String,
129    pub state: String,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub exit_code: Option<i32>,
132    pub started_at: String,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub finished_at: Option<String>,
135}
136
137/// Response for `tail` command.
138#[derive(Debug, Serialize, Deserialize)]
139pub struct TailData {
140    pub job_id: String,
141    pub stdout_tail: String,
142    pub stderr_tail: String,
143    /// True when the output was truncated by tail_lines or max_bytes constraints.
144    pub truncated: bool,
145    pub encoding: String,
146    /// Absolute path to stdout.log for this job.
147    pub stdout_log_path: String,
148    /// Absolute path to stderr.log for this job.
149    pub stderr_log_path: String,
150    /// Size of stdout.log in bytes at the time of the tail read (0 if file absent).
151    pub stdout_observed_bytes: u64,
152    /// Size of stderr.log in bytes at the time of the tail read (0 if file absent).
153    pub stderr_observed_bytes: u64,
154    /// UTF-8 byte length of the stdout_tail string included in this response.
155    pub stdout_included_bytes: u64,
156    /// UTF-8 byte length of the stderr_tail string included in this response.
157    pub stderr_included_bytes: u64,
158}
159
160/// Response for `wait` command.
161#[derive(Debug, Serialize, Deserialize)]
162pub struct WaitData {
163    pub job_id: String,
164    pub state: String,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub exit_code: Option<i32>,
167}
168
169/// Response for `kill` command.
170#[derive(Debug, Serialize, Deserialize)]
171pub struct KillData {
172    pub job_id: String,
173    pub signal: String,
174}
175
176/// Response for `schema` command.
177#[derive(Debug, Serialize, Deserialize)]
178pub struct SchemaData {
179    /// The JSON Schema format identifier (e.g. "json-schema-draft-07").
180    pub schema_format: String,
181    /// The JSON Schema document describing all CLI response types.
182    pub schema: serde_json::Value,
183    /// Timestamp when the schema file was last updated (RFC 3339).
184    pub generated_at: String,
185}
186
187/// Summary of a single job, included in `list` responses.
188#[derive(Debug, Serialize, Deserialize)]
189pub struct JobSummary {
190    pub job_id: String,
191    /// Job state: running | exited | killed | failed | unknown
192    pub state: String,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub exit_code: Option<i32>,
195    /// Creation timestamp from meta.json (RFC 3339).
196    pub started_at: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub finished_at: Option<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub updated_at: Option<String>,
201}
202
203/// Response for `list` command.
204#[derive(Debug, Serialize, Deserialize)]
205pub struct ListData {
206    /// Resolved root directory path.
207    pub root: String,
208    /// Array of job summaries, sorted by started_at descending.
209    pub jobs: Vec<JobSummary>,
210    /// True when the result was truncated by --limit.
211    pub truncated: bool,
212    /// Number of directories skipped because they could not be read as jobs.
213    pub skipped: u64,
214}
215
216/// Per-job result entry in a `gc` response.
217#[derive(Debug, Serialize, Deserialize)]
218pub struct GcJobResult {
219    pub job_id: String,
220    /// Job state as reported from state.json: running | exited | killed | failed | unknown
221    pub state: String,
222    /// What GC did: "deleted" | "would_delete" | "skipped"
223    pub action: String,
224    /// Human-readable explanation for the action.
225    pub reason: String,
226    /// Byte size of the job directory (0 for skipped jobs where size is not computed).
227    pub bytes: u64,
228}
229
230/// Response for the `gc` command.
231#[derive(Debug, Serialize, Deserialize)]
232pub struct GcData {
233    /// Resolved root directory path.
234    pub root: String,
235    /// Whether this was a dry-run (no deletions performed).
236    pub dry_run: bool,
237    /// The effective retention window (e.g. "30d").
238    pub older_than: String,
239    /// How the retention window was determined: "default" or "flag".
240    pub older_than_source: String,
241    /// Number of job directories actually deleted (0 when dry_run=true).
242    pub deleted: u64,
243    /// Number of job directories skipped (running, unreadable, or too recent).
244    pub skipped: u64,
245    /// Total bytes freed (or would be freed in dry-run mode).
246    pub freed_bytes: u64,
247    /// Per-job details.
248    pub jobs: Vec<GcJobResult>,
249}
250
251// ---------- install-skills response payload ----------
252
253/// Summary of a single installed skill, included in `install_skills` responses.
254#[derive(Debug, Serialize, Deserialize)]
255pub struct InstalledSkillSummary {
256    /// Skill name (directory name under `.agents/skills/`).
257    pub name: String,
258    /// Source type string used when the skill was installed (e.g. "self", "local").
259    pub source_type: String,
260    /// Absolute path to the installed skill directory.
261    pub path: String,
262}
263
264/// Response for `install-skills` command.
265#[derive(Debug, Serialize, Deserialize)]
266pub struct InstallSkillsData {
267    /// List of installed skills.
268    pub skills: Vec<InstalledSkillSummary>,
269    /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
270    pub global: bool,
271    /// Absolute path to the `.skill-lock.json` file that was updated.
272    pub lock_file_path: String,
273}
274
275/// Snapshot of stdout/stderr tail at a point in time.
276#[derive(Debug, Serialize, Deserialize)]
277pub struct Snapshot {
278    pub stdout_tail: String,
279    pub stderr_tail: String,
280    /// True when the output was truncated by tail_lines or max_bytes constraints.
281    pub truncated: bool,
282    pub encoding: String,
283    /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
284    pub stdout_observed_bytes: u64,
285    /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
286    pub stderr_observed_bytes: u64,
287    /// UTF-8 byte length of the stdout_tail string included in this snapshot.
288    pub stdout_included_bytes: u64,
289    /// UTF-8 byte length of the stderr_tail string included in this snapshot.
290    pub stderr_included_bytes: u64,
291}
292
293// ---------- Notification / completion event models ----------
294
295/// Notification configuration persisted in meta.json.
296#[derive(Debug, Serialize, Deserialize, Clone)]
297pub struct NotificationConfig {
298    /// Shell command string for command sink; executed via platform shell on completion.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub notify_command: Option<String>,
301    /// File path for NDJSON append sink.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub notify_file: Option<String>,
304}
305
306/// The `job.finished` event payload.
307#[derive(Debug, Serialize, Deserialize, Clone)]
308pub struct CompletionEvent {
309    pub schema_version: String,
310    pub event_type: String,
311    pub job_id: String,
312    pub state: String,
313    pub command: Vec<String>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub cwd: Option<String>,
316    pub started_at: String,
317    pub finished_at: String,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub duration_ms: Option<u64>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub exit_code: Option<i32>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub signal: Option<String>,
324    pub stdout_log_path: String,
325    pub stderr_log_path: String,
326}
327
328/// Delivery result for a single notification sink.
329#[derive(Debug, Serialize, Deserialize, Clone)]
330pub struct SinkDeliveryResult {
331    pub sink_type: String,
332    pub target: String,
333    pub success: bool,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub error: Option<String>,
336    pub attempted_at: String,
337}
338
339/// Persisted in `completion_event.json` after terminal state is reached.
340#[derive(Debug, Serialize, Deserialize, Clone)]
341pub struct CompletionEventRecord {
342    #[serde(flatten)]
343    pub event: CompletionEvent,
344    pub delivery_results: Vec<SinkDeliveryResult>,
345}
346
347// ---------- Persisted job metadata / state ----------
348
349/// Nested `job` block within `meta.json`.
350#[derive(Debug, Serialize, Deserialize, Clone)]
351pub struct JobMetaJob {
352    pub id: String,
353}
354
355/// Persisted in `meta.json` at job creation time.
356///
357/// Structure:
358/// ```json
359/// {
360///   "job": { "id": "..." },
361///   "schema_version": "0.1",
362///   "command": [...],
363///   "created_at": "...",
364///   "root": "...",
365///   "env_keys": [...],
366///   "env_vars": [...],
367///   "mask": [...]
368/// }
369/// ```
370///
371/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
372/// Values MUST NOT be stored to avoid leaking secrets.
373/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***".
374/// `mask` stores the list of keys whose values are masked.
375/// `cwd` stores the effective working directory at job creation time (canonicalized).
376#[derive(Debug, Serialize, Deserialize, Clone)]
377pub struct JobMeta {
378    pub job: JobMetaJob,
379    pub schema_version: String,
380    pub command: Vec<String>,
381    pub created_at: String,
382    pub root: String,
383    /// Keys of environment variables provided at job creation time.
384    /// Values are intentionally omitted for security.
385    pub env_keys: Vec<String>,
386    /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
387    #[serde(skip_serializing_if = "Vec::is_empty", default)]
388    pub env_vars: Vec<String>,
389    /// Keys whose values are masked in output.
390    #[serde(skip_serializing_if = "Vec::is_empty", default)]
391    pub mask: Vec<String>,
392    /// Effective working directory at job creation time (canonicalized absolute path).
393    /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
394    #[serde(skip_serializing_if = "Option::is_none", default)]
395    pub cwd: Option<String>,
396    /// Notification configuration (present only when --notify-command or --notify-file was used).
397    #[serde(skip_serializing_if = "Option::is_none", default)]
398    pub notification: Option<NotificationConfig>,
399}
400
401impl JobMeta {
402    /// Convenience accessor: returns the job ID.
403    pub fn job_id(&self) -> &str {
404        &self.job.id
405    }
406}
407
408/// Nested `job` block within `state.json`.
409#[derive(Debug, Serialize, Deserialize, Clone)]
410pub struct JobStateJob {
411    pub id: String,
412    pub status: JobStatus,
413    pub started_at: String,
414}
415
416/// Nested `result` block within `state.json`.
417///
418/// Option fields are serialized as `null` (not omitted) so callers always
419/// see consistent keys regardless of job lifecycle stage.
420#[derive(Debug, Serialize, Deserialize, Clone)]
421pub struct JobStateResult {
422    /// `null` while running; set to exit code when process ends.
423    pub exit_code: Option<i32>,
424    /// `null` unless the process was killed by a signal.
425    pub signal: Option<String>,
426    /// `null` while running; set to elapsed milliseconds when process ends.
427    pub duration_ms: Option<u64>,
428}
429
430/// Persisted in `state.json`, updated as the job progresses.
431///
432/// Structure:
433/// ```json
434/// {
435///   "job": { "id": "...", "status": "running", "started_at": "..." },
436///   "result": { "exit_code": null, "signal": null, "duration_ms": null },
437///   "updated_at": "..."
438/// }
439/// ```
440///
441/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
442/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
443/// Option fields MUST be serialized as `null` (not omitted) so callers always
444/// see consistent keys regardless of job lifecycle stage.
445#[derive(Debug, Serialize, Deserialize, Clone)]
446pub struct JobState {
447    pub job: JobStateJob,
448    pub result: JobStateResult,
449    /// Process ID (not part of the public spec; omitted when not available).
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub pid: Option<u32>,
452    /// Finish time (not part of the nested result block; kept for internal use).
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub finished_at: Option<String>,
455    /// Last time this state was written to disk (RFC 3339).
456    pub updated_at: String,
457    /// Windows-only: name of the Job Object used to manage the process tree.
458    /// Present only when the supervisor successfully created and assigned a
459    /// named Job Object; absent on non-Windows platforms and when creation
460    /// fails (in which case tree management falls back to snapshot enumeration).
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub windows_job_name: Option<String>,
463}
464
465impl JobState {
466    /// Convenience accessor: returns the job ID.
467    pub fn job_id(&self) -> &str {
468        &self.job.id
469    }
470
471    /// Convenience accessor: returns the job status.
472    pub fn status(&self) -> &JobStatus {
473        &self.job.status
474    }
475
476    /// Convenience accessor: returns the started_at timestamp.
477    pub fn started_at(&self) -> &str {
478        &self.job.started_at
479    }
480
481    /// Convenience accessor: returns the exit code.
482    pub fn exit_code(&self) -> Option<i32> {
483        self.result.exit_code
484    }
485
486    /// Convenience accessor: returns the signal name.
487    pub fn signal(&self) -> Option<&str> {
488        self.result.signal.as_deref()
489    }
490
491    /// Convenience accessor: returns the duration in milliseconds.
492    pub fn duration_ms(&self) -> Option<u64> {
493        self.result.duration_ms
494    }
495}
496
497#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
498#[serde(rename_all = "lowercase")]
499pub enum JobStatus {
500    Running,
501    Exited,
502    Killed,
503    Failed,
504}
505
506impl JobStatus {
507    pub fn as_str(&self) -> &'static str {
508        match self {
509            JobStatus::Running => "running",
510            JobStatus::Exited => "exited",
511            JobStatus::Killed => "killed",
512            JobStatus::Failed => "failed",
513        }
514    }
515}