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// ---------- install-skills response payload ----------
217
218/// Summary of a single installed skill, included in `install_skills` responses.
219#[derive(Debug, Serialize, Deserialize)]
220pub struct InstalledSkillSummary {
221 /// Skill name (directory name under `.agents/skills/`).
222 pub name: String,
223 /// Source type string used when the skill was installed (e.g. "self", "local").
224 pub source_type: String,
225 /// Absolute path to the installed skill directory.
226 pub path: String,
227}
228
229/// Response for `install-skills` command.
230#[derive(Debug, Serialize, Deserialize)]
231pub struct InstallSkillsData {
232 /// List of installed skills.
233 pub skills: Vec<InstalledSkillSummary>,
234 /// Whether skills were installed globally (`~/.agents/`) or locally (`./.agents/`).
235 pub global: bool,
236 /// Absolute path to the `.skill-lock.json` file that was updated.
237 pub lock_file_path: String,
238}
239
240/// Snapshot of stdout/stderr tail at a point in time.
241#[derive(Debug, Serialize, Deserialize)]
242pub struct Snapshot {
243 pub stdout_tail: String,
244 pub stderr_tail: String,
245 /// True when the output was truncated by tail_lines or max_bytes constraints.
246 pub truncated: bool,
247 pub encoding: String,
248 /// Size of stdout.log in bytes at the time of the snapshot (0 if file absent).
249 pub stdout_observed_bytes: u64,
250 /// Size of stderr.log in bytes at the time of the snapshot (0 if file absent).
251 pub stderr_observed_bytes: u64,
252 /// UTF-8 byte length of the stdout_tail string included in this snapshot.
253 pub stdout_included_bytes: u64,
254 /// UTF-8 byte length of the stderr_tail string included in this snapshot.
255 pub stderr_included_bytes: u64,
256}
257
258// ---------- Notification / completion event models ----------
259
260/// Notification configuration persisted in meta.json.
261#[derive(Debug, Serialize, Deserialize, Clone)]
262pub struct NotificationConfig {
263 /// Shell command string for command sink; executed via platform shell on completion.
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub notify_command: Option<String>,
266 /// File path for NDJSON append sink.
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub notify_file: Option<String>,
269}
270
271/// The `job.finished` event payload.
272#[derive(Debug, Serialize, Deserialize, Clone)]
273pub struct CompletionEvent {
274 pub schema_version: String,
275 pub event_type: String,
276 pub job_id: String,
277 pub state: String,
278 pub command: Vec<String>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub cwd: Option<String>,
281 pub started_at: String,
282 pub finished_at: String,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub duration_ms: Option<u64>,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub exit_code: Option<i32>,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub signal: Option<String>,
289 pub stdout_log_path: String,
290 pub stderr_log_path: String,
291}
292
293/// Delivery result for a single notification sink.
294#[derive(Debug, Serialize, Deserialize, Clone)]
295pub struct SinkDeliveryResult {
296 pub sink_type: String,
297 pub target: String,
298 pub success: bool,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub error: Option<String>,
301 pub attempted_at: String,
302}
303
304/// Persisted in `completion_event.json` after terminal state is reached.
305#[derive(Debug, Serialize, Deserialize, Clone)]
306pub struct CompletionEventRecord {
307 #[serde(flatten)]
308 pub event: CompletionEvent,
309 pub delivery_results: Vec<SinkDeliveryResult>,
310}
311
312// ---------- Persisted job metadata / state ----------
313
314/// Nested `job` block within `meta.json`.
315#[derive(Debug, Serialize, Deserialize, Clone)]
316pub struct JobMetaJob {
317 pub id: String,
318}
319
320/// Persisted in `meta.json` at job creation time.
321///
322/// Structure:
323/// ```json
324/// {
325/// "job": { "id": "..." },
326/// "schema_version": "0.1",
327/// "command": [...],
328/// "created_at": "...",
329/// "root": "...",
330/// "env_keys": [...],
331/// "env_vars": [...],
332/// "mask": [...]
333/// }
334/// ```
335///
336/// `env_keys` stores only the names (keys) of environment variables passed via `--env`.
337/// Values MUST NOT be stored to avoid leaking secrets.
338/// `env_vars` stores KEY=VALUE strings with masked values replaced by "***".
339/// `mask` stores the list of keys whose values are masked.
340/// `cwd` stores the effective working directory at job creation time (canonicalized).
341#[derive(Debug, Serialize, Deserialize, Clone)]
342pub struct JobMeta {
343 pub job: JobMetaJob,
344 pub schema_version: String,
345 pub command: Vec<String>,
346 pub created_at: String,
347 pub root: String,
348 /// Keys of environment variables provided at job creation time.
349 /// Values are intentionally omitted for security.
350 pub env_keys: Vec<String>,
351 /// Environment variables as KEY=VALUE strings, with masked values replaced by "***".
352 #[serde(skip_serializing_if = "Vec::is_empty", default)]
353 pub env_vars: Vec<String>,
354 /// Keys whose values are masked in output.
355 #[serde(skip_serializing_if = "Vec::is_empty", default)]
356 pub mask: Vec<String>,
357 /// Effective working directory at job creation time (canonicalized absolute path).
358 /// Used by `list` to filter jobs by cwd. Absent for jobs created before this feature.
359 #[serde(skip_serializing_if = "Option::is_none", default)]
360 pub cwd: Option<String>,
361 /// Notification configuration (present only when --notify-command or --notify-file was used).
362 #[serde(skip_serializing_if = "Option::is_none", default)]
363 pub notification: Option<NotificationConfig>,
364}
365
366impl JobMeta {
367 /// Convenience accessor: returns the job ID.
368 pub fn job_id(&self) -> &str {
369 &self.job.id
370 }
371}
372
373/// Nested `job` block within `state.json`.
374#[derive(Debug, Serialize, Deserialize, Clone)]
375pub struct JobStateJob {
376 pub id: String,
377 pub status: JobStatus,
378 pub started_at: String,
379}
380
381/// Nested `result` block within `state.json`.
382///
383/// Option fields are serialized as `null` (not omitted) so callers always
384/// see consistent keys regardless of job lifecycle stage.
385#[derive(Debug, Serialize, Deserialize, Clone)]
386pub struct JobStateResult {
387 /// `null` while running; set to exit code when process ends.
388 pub exit_code: Option<i32>,
389 /// `null` unless the process was killed by a signal.
390 pub signal: Option<String>,
391 /// `null` while running; set to elapsed milliseconds when process ends.
392 pub duration_ms: Option<u64>,
393}
394
395/// Persisted in `state.json`, updated as the job progresses.
396///
397/// Structure:
398/// ```json
399/// {
400/// "job": { "id": "...", "status": "running", "started_at": "..." },
401/// "result": { "exit_code": null, "signal": null, "duration_ms": null },
402/// "updated_at": "..."
403/// }
404/// ```
405///
406/// Required fields per spec: `job.id`, `job.status`, `job.started_at`,
407/// `result.exit_code`, `result.signal`, `result.duration_ms`, `updated_at`.
408/// Option fields MUST be serialized as `null` (not omitted) so callers always
409/// see consistent keys regardless of job lifecycle stage.
410#[derive(Debug, Serialize, Deserialize, Clone)]
411pub struct JobState {
412 pub job: JobStateJob,
413 pub result: JobStateResult,
414 /// Process ID (not part of the public spec; omitted when not available).
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub pid: Option<u32>,
417 /// Finish time (not part of the nested result block; kept for internal use).
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub finished_at: Option<String>,
420 /// Last time this state was written to disk (RFC 3339).
421 pub updated_at: String,
422 /// Windows-only: name of the Job Object used to manage the process tree.
423 /// Present only when the supervisor successfully created and assigned a
424 /// named Job Object; absent on non-Windows platforms and when creation
425 /// fails (in which case tree management falls back to snapshot enumeration).
426 #[serde(skip_serializing_if = "Option::is_none")]
427 pub windows_job_name: Option<String>,
428}
429
430impl JobState {
431 /// Convenience accessor: returns the job ID.
432 pub fn job_id(&self) -> &str {
433 &self.job.id
434 }
435
436 /// Convenience accessor: returns the job status.
437 pub fn status(&self) -> &JobStatus {
438 &self.job.status
439 }
440
441 /// Convenience accessor: returns the started_at timestamp.
442 pub fn started_at(&self) -> &str {
443 &self.job.started_at
444 }
445
446 /// Convenience accessor: returns the exit code.
447 pub fn exit_code(&self) -> Option<i32> {
448 self.result.exit_code
449 }
450
451 /// Convenience accessor: returns the signal name.
452 pub fn signal(&self) -> Option<&str> {
453 self.result.signal.as_deref()
454 }
455
456 /// Convenience accessor: returns the duration in milliseconds.
457 pub fn duration_ms(&self) -> Option<u64> {
458 self.result.duration_ms
459 }
460}
461
462#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
463#[serde(rename_all = "lowercase")]
464pub enum JobStatus {
465 Running,
466 Exited,
467 Killed,
468 Failed,
469}
470
471impl JobStatus {
472 pub fn as_str(&self) -> &'static str {
473 match self {
474 JobStatus::Running => "running",
475 JobStatus::Exited => "exited",
476 JobStatus::Killed => "killed",
477 JobStatus::Failed => "failed",
478 }
479 }
480}