1use serde::{Deserialize, Serialize};
8use std::sync::atomic::{AtomicBool, Ordering};
9
10static YAML_OUTPUT: AtomicBool = AtomicBool::new(false);
12
13pub fn set_yaml_output(yaml: bool) {
15 YAML_OUTPUT.store(yaml, Ordering::Relaxed);
16}
17
18pub const SCHEMA_VERSION: &str = "0.1";
19
20fn 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#[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 pub fn print(&self) {
61 print_to_stdout(self);
62 }
63}
64
65#[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 pub retryable: bool,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub details: Option<serde_json::Value>,
83}
84
85impl ErrorResponse {
86 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#[derive(Debug, Serialize, Deserialize)]
121pub struct CreateData {
122 pub job_id: String,
123 pub state: String,
125 pub stdout_log_path: String,
127 pub stderr_log_path: String,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
133pub struct RunData {
134 pub job_id: String,
135 pub state: String,
136 #[serde(default)]
138 pub tags: Vec<String>,
139 #[serde(skip_serializing_if = "Vec::is_empty", default)]
142 pub env_vars: Vec<String>,
143 pub stdout_log_path: String,
145 pub stderr_log_path: String,
147 pub elapsed_ms: u64,
149 pub waited_ms: u64,
151 pub stdout: String,
153 pub stderr: String,
155 pub stdout_range: [u64; 2],
157 pub stderr_range: [u64; 2],
159 pub stdout_total_bytes: u64,
161 pub stderr_total_bytes: u64,
163 pub encoding: String,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub exit_code: Option<i32>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub finished_at: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub signal: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub duration_ms: Option<u64>,
177}
178
179#[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 pub created_at: String,
188 #[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#[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 pub stdout_log_path: String,
204 pub stderr_log_path: String,
206 pub stdout_range: [u64; 2],
208 pub stderr_range: [u64; 2],
210 pub stdout_total_bytes: u64,
212 pub stderr_total_bytes: u64,
214}
215
216#[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#[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#[derive(Debug, Serialize, Deserialize)]
248pub struct SchemaData {
249 pub schema_format: String,
251 pub schema: serde_json::Value,
253 pub generated_at: String,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
259pub struct JobSummary {
260 pub job_id: String,
261 pub short_job_id: String,
263 pub state: String,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub exit_code: Option<i32>,
267 pub created_at: String,
269 #[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 #[serde(default)]
278 pub tags: Vec<String>,
279}
280
281#[derive(Debug, Serialize, Deserialize)]
283pub struct TagSetData {
284 pub job_id: String,
285 pub tags: Vec<String>,
287}
288
289#[derive(Debug, Serialize, Deserialize)]
291pub struct ListData {
292 pub root: String,
294 pub jobs: Vec<JobSummary>,
296 pub truncated: bool,
298 pub skipped: u64,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
304pub struct GcJobResult {
305 pub job_id: String,
306 pub state: String,
308 pub action: String,
310 pub reason: String,
312 pub bytes: u64,
314}
315
316#[derive(Debug, Serialize, Deserialize)]
318pub struct GcData {
319 pub root: String,
321 pub dry_run: bool,
323 pub older_than: String,
325 pub older_than_source: String,
327 pub deleted: u64,
329 pub skipped: u64,
332 pub out_of_scope: u64,
335 pub failed: u64,
338 pub freed_bytes: u64,
340 pub scanned_dirs: u64,
342 pub candidate_count: u64,
344 pub jobs: Vec<GcJobResult>,
346}
347
348#[derive(Debug, Serialize, Deserialize)]
350pub struct DeleteJobResult {
351 pub job_id: String,
352 pub state: String,
354 pub action: String,
356 pub reason: String,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
362pub struct DeleteData {
363 pub root: String,
365 pub dry_run: bool,
367 #[serde(skip_serializing_if = "Option::is_none")]
371 pub cwd_scope: Option<String>,
372 pub deleted: u64,
374 pub skipped: u64,
377 pub out_of_scope: u64,
380 pub failed: u64,
384 pub jobs: Vec<DeleteJobResult>,
386}
387
388#[derive(Debug, Serialize, Deserialize)]
392pub struct InstalledSkillSummary {
393 pub name: String,
395 pub source_type: String,
397 pub path: String,
399}
400
401#[derive(Debug, Serialize, Deserialize)]
403pub struct NotifySetData {
404 pub job_id: String,
405 pub notification: NotificationConfig,
407}
408
409#[derive(Debug, Serialize, Deserialize)]
411pub struct InstallSkillsData {
412 pub skills: Vec<InstalledSkillSummary>,
414 pub global: bool,
416 pub lock_file_path: String,
418}
419
420#[derive(Debug, Serialize, Deserialize)]
422pub struct Snapshot {
423 pub stdout_tail: String,
424 pub stderr_tail: String,
425 pub truncated: bool,
427 pub encoding: String,
428 pub stdout_observed_bytes: u64,
430 pub stderr_observed_bytes: u64,
432 pub stdout_included_bytes: u64,
434 pub stderr_included_bytes: u64,
436}
437
438#[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#[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#[derive(Debug, Serialize, Deserialize, Clone)]
461pub struct OutputMatchConfig {
462 pub pattern: String,
464 #[serde(default)]
466 pub match_type: OutputMatchType,
467 #[serde(default)]
469 pub stream: OutputMatchStream,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub command: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub file: Option<String>,
476}
477
478#[derive(Debug, Serialize, Deserialize, Clone)]
480pub struct NotificationConfig {
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub notify_command: Option<String>,
484 #[serde(skip_serializing_if = "Option::is_none")]
486 pub notify_file: Option<String>,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub on_output_match: Option<OutputMatchConfig>,
490}
491
492#[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#[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#[derive(Debug, Serialize, Deserialize, Clone)]
527pub struct CompletionEventRecord {
528 #[serde(flatten)]
529 pub event: CompletionEvent,
530 pub delivery_results: Vec<SinkDeliveryResult>,
531}
532
533#[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#[derive(Debug, Serialize, Deserialize, Clone)]
549pub struct OutputMatchEventRecord {
550 #[serde(flatten)]
551 pub event: OutputMatchEvent,
552 pub delivery_results: Vec<SinkDeliveryResult>,
553}
554
555#[derive(Debug, Serialize, Deserialize, Clone)]
559pub struct JobMetaJob {
560 pub id: String,
561}
562
563#[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 pub env_keys: Vec<String>,
599 #[serde(skip_serializing_if = "Vec::is_empty", default)]
602 pub env_vars: Vec<String>,
603 #[serde(skip_serializing_if = "Vec::is_empty", default)]
609 pub env_vars_runtime: Vec<String>,
610 #[serde(skip_serializing_if = "Vec::is_empty", default)]
612 pub mask: Vec<String>,
613 #[serde(skip_serializing_if = "Option::is_none", default)]
616 pub cwd: Option<String>,
617 #[serde(skip_serializing_if = "Option::is_none", default)]
619 pub notification: Option<NotificationConfig>,
620 #[serde(default)]
622 pub tags: Vec<String>,
623
624 #[serde(default = "default_inherit_env")]
627 pub inherit_env: bool,
628 #[serde(skip_serializing_if = "Vec::is_empty", default)]
630 pub env_files: Vec<String>,
631 #[serde(default)]
633 pub timeout_ms: u64,
634 #[serde(default)]
636 pub kill_after_ms: u64,
637 #[serde(default)]
639 pub progress_every_ms: u64,
640 #[serde(skip_serializing_if = "Option::is_none", default)]
642 pub shell_wrapper: Option<Vec<String>>,
643 #[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 pub fn job_id(&self) -> &str {
655 &self.job.id
656 }
657}
658
659#[derive(Debug, Serialize, Deserialize, Clone)]
661pub struct JobStateJob {
662 pub id: String,
663 pub status: JobStatus,
664 #[serde(skip_serializing_if = "Option::is_none", default)]
666 pub started_at: Option<String>,
667}
668
669#[derive(Debug, Serialize, Deserialize, Clone)]
674pub struct JobStateResult {
675 pub exit_code: Option<i32>,
677 pub signal: Option<String>,
679 pub duration_ms: Option<u64>,
681}
682
683#[derive(Debug, Serialize, Deserialize, Clone)]
699pub struct JobState {
700 pub job: JobStateJob,
701 pub result: JobStateResult,
702 #[serde(skip_serializing_if = "Option::is_none")]
704 pub pid: Option<u32>,
705 #[serde(skip_serializing_if = "Option::is_none")]
707 pub finished_at: Option<String>,
708 pub updated_at: String,
710 #[serde(skip_serializing_if = "Option::is_none")]
715 pub windows_job_name: Option<String>,
716}
717
718impl JobState {
719 pub fn job_id(&self) -> &str {
721 &self.job.id
722 }
723
724 pub fn status(&self) -> &JobStatus {
726 &self.job.status
727 }
728
729 pub fn started_at(&self) -> Option<&str> {
731 self.job.started_at.as_deref()
732 }
733
734 pub fn exit_code(&self) -> Option<i32> {
736 self.result.exit_code
737 }
738
739 pub fn signal(&self) -> Option<&str> {
741 self.result.signal.as_deref()
742 }
743
744 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 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}