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, Clone, Serialize, Deserialize)]
132pub struct CompressionData {
133 pub mode: String,
134 pub applied: bool,
135 pub detected_kind: String,
136 pub stdout: String,
137 pub stderr: String,
138 pub stdout_original_bytes: u64,
139 pub stderr_original_bytes: u64,
140 pub stdout_compressed_bytes: u64,
141 pub stderr_compressed_bytes: u64,
142 pub omitted: bool,
143 pub strategy: Vec<String>,
144}
145
146#[derive(Debug, Serialize, Deserialize)]
148pub struct RunData {
149 pub job_id: String,
150 pub state: String,
151 #[serde(default)]
153 pub tags: Vec<String>,
154 #[serde(skip_serializing_if = "Vec::is_empty", default)]
157 pub env_vars: Vec<String>,
158 pub stdout_log_path: String,
160 pub stderr_log_path: String,
162 pub elapsed_ms: u64,
164 pub waited_ms: u64,
166 pub stdout: String,
168 pub stderr: String,
170 pub stdout_range: [u64; 2],
172 pub stderr_range: [u64; 2],
174 pub stdout_total_bytes: u64,
176 pub stderr_total_bytes: u64,
178 pub encoding: String,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub exit_code: Option<i32>,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub finished_at: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub signal: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub duration_ms: Option<u64>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub compression: Option<CompressionData>,
194}
195
196#[derive(Debug, Serialize, Deserialize)]
198pub struct StatusData {
199 pub job_id: String,
200 pub state: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub exit_code: Option<i32>,
203 pub created_at: String,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub started_at: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub finished_at: Option<String>,
210}
211
212#[derive(Debug, Serialize, Deserialize)]
214pub struct TailData {
215 pub job_id: String,
216 pub stdout: String,
217 pub stderr: String,
218 pub encoding: String,
219 pub stdout_log_path: String,
221 pub stderr_log_path: String,
223 pub stdout_range: [u64; 2],
225 pub stderr_range: [u64; 2],
227 pub stdout_total_bytes: u64,
229 pub stderr_total_bytes: u64,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub compression: Option<CompressionData>,
233}
234
235#[derive(Debug, Serialize, Deserialize)]
237pub struct WaitData {
238 pub job_id: String,
239 pub state: String,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub exit_code: Option<i32>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub stdout_total_bytes: Option<u64>,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub stderr_total_bytes: Option<u64>,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub updated_at: Option<String>,
248}
249
250#[derive(Debug, Serialize, Deserialize)]
252pub struct KillData {
253 pub job_id: String,
254 pub signal: String,
255 #[serde(skip_serializing_if = "Option::is_none")]
256 pub state: Option<String>,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub exit_code: Option<i32>,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub terminated_signal: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub observed_within_ms: Option<u64>,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
267pub struct SchemaData {
268 pub schema_format: String,
270 pub schema: serde_json::Value,
272 pub generated_at: String,
274}
275
276#[derive(Debug, Serialize, Deserialize)]
278pub struct JobSummary {
279 pub job_id: String,
280 pub short_job_id: String,
282 pub state: String,
284 pub command: Vec<String>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub exit_code: Option<i32>,
288 pub created_at: String,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub started_at: Option<String>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub finished_at: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub updated_at: Option<String>,
297 #[serde(default)]
299 pub tags: Vec<String>,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
304pub struct TagSetData {
305 pub job_id: String,
306 pub tags: Vec<String>,
308}
309
310#[derive(Debug, Serialize, Deserialize)]
312pub struct ListData {
313 pub root: String,
315 pub jobs: Vec<JobSummary>,
317 pub truncated: bool,
319 pub skipped: u64,
321}
322
323#[derive(Debug, Serialize, Deserialize)]
325pub struct GcJobResult {
326 pub job_id: String,
327 pub state: String,
329 pub action: String,
331 pub reason: String,
333 pub bytes: u64,
335}
336
337#[derive(Debug, Serialize, Deserialize)]
339pub struct GcData {
340 pub root: String,
342 pub dry_run: bool,
344 pub older_than: String,
346 pub older_than_source: String,
348 pub deleted: u64,
350 pub skipped: u64,
353 pub out_of_scope: u64,
356 pub failed: u64,
359 pub freed_bytes: u64,
361 pub scanned_dirs: u64,
363 pub candidate_count: u64,
365 pub jobs: Vec<GcJobResult>,
367}
368
369#[derive(Debug, Serialize, Deserialize)]
371pub struct DeleteJobResult {
372 pub job_id: String,
373 pub state: String,
375 pub action: String,
377 pub reason: String,
379}
380
381#[derive(Debug, Serialize, Deserialize)]
383pub struct DeleteData {
384 pub root: String,
386 pub dry_run: bool,
388 #[serde(skip_serializing_if = "Option::is_none")]
392 pub cwd_scope: Option<String>,
393 pub deleted: u64,
395 pub skipped: u64,
398 pub out_of_scope: u64,
401 pub failed: u64,
405 pub jobs: Vec<DeleteJobResult>,
407}
408
409#[derive(Debug, Serialize, Deserialize)]
413pub struct InstalledSkillSummary {
414 pub name: String,
416 pub source_type: String,
418 pub path: String,
420}
421
422#[derive(Debug, Serialize, Deserialize)]
424pub struct NotifySetData {
425 pub job_id: String,
426 pub notification: NotificationConfig,
428}
429
430#[derive(Debug, Serialize, Deserialize)]
432pub struct InstallSkillsData {
433 pub skills: Vec<InstalledSkillSummary>,
435 pub global: bool,
437 pub lock_file_path: String,
439}
440
441#[derive(Debug, Serialize, Deserialize)]
443pub struct Snapshot {
444 pub stdout_tail: String,
445 pub stderr_tail: String,
446 pub truncated: bool,
448 pub encoding: String,
449 pub stdout_observed_bytes: u64,
451 pub stderr_observed_bytes: u64,
453 pub stdout_included_bytes: u64,
455 pub stderr_included_bytes: u64,
457}
458
459#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
463#[serde(rename_all = "lowercase")]
464pub enum OutputMatchType {
465 #[default]
466 Contains,
467 Regex,
468}
469
470#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
472#[serde(rename_all = "lowercase")]
473pub enum OutputMatchStream {
474 Stdout,
475 Stderr,
476 #[default]
477 Either,
478}
479
480#[derive(Debug, Serialize, Deserialize, Clone)]
482pub struct OutputMatchConfig {
483 pub pattern: String,
485 #[serde(default)]
487 pub match_type: OutputMatchType,
488 #[serde(default)]
490 pub stream: OutputMatchStream,
491 #[serde(skip_serializing_if = "Option::is_none")]
493 pub command: Option<String>,
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub file: Option<String>,
497}
498
499#[derive(Debug, Serialize, Deserialize, Clone)]
501pub struct NotificationConfig {
502 #[serde(skip_serializing_if = "Option::is_none")]
504 pub notify_command: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
507 pub notify_file: Option<String>,
508 #[serde(skip_serializing_if = "Option::is_none")]
510 pub on_output_match: Option<OutputMatchConfig>,
511}
512
513#[derive(Debug, Serialize, Deserialize, Clone)]
515pub struct CompletionEvent {
516 pub schema_version: String,
517 pub event_type: String,
518 pub job_id: String,
519 pub state: String,
520 pub command: Vec<String>,
521 #[serde(skip_serializing_if = "Option::is_none")]
522 pub cwd: Option<String>,
523 pub started_at: String,
524 pub finished_at: String,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub duration_ms: Option<u64>,
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub exit_code: Option<i32>,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub signal: Option<String>,
531 pub stdout_log_path: String,
532 pub stderr_log_path: String,
533}
534
535#[derive(Debug, Serialize, Deserialize, Clone)]
537pub struct SinkDeliveryResult {
538 pub sink_type: String,
539 pub target: String,
540 pub success: bool,
541 #[serde(skip_serializing_if = "Option::is_none")]
542 pub error: Option<String>,
543 pub attempted_at: String,
544}
545
546#[derive(Debug, Serialize, Deserialize, Clone)]
548pub struct CompletionEventRecord {
549 #[serde(flatten)]
550 pub event: CompletionEvent,
551 pub delivery_results: Vec<SinkDeliveryResult>,
552}
553
554#[derive(Debug, Serialize, Deserialize, Clone)]
556pub struct OutputMatchEvent {
557 pub schema_version: String,
558 pub event_type: String,
559 pub job_id: String,
560 pub pattern: String,
561 pub match_type: String,
562 pub stream: String,
563 pub line: String,
564 pub stdout_log_path: String,
565 pub stderr_log_path: String,
566}
567
568#[derive(Debug, Serialize, Deserialize, Clone)]
570pub struct OutputMatchEventRecord {
571 #[serde(flatten)]
572 pub event: OutputMatchEvent,
573 pub delivery_results: Vec<SinkDeliveryResult>,
574}
575
576#[derive(Debug, Serialize, Deserialize, Clone)]
580pub struct JobMetaJob {
581 pub id: String,
582}
583
584#[derive(Debug, Serialize, Deserialize, Clone)]
612pub struct JobMeta {
613 pub job: JobMetaJob,
614 pub schema_version: String,
615 pub command: Vec<String>,
616 pub created_at: String,
617 pub root: String,
618 pub env_keys: Vec<String>,
620 #[serde(skip_serializing_if = "Vec::is_empty", default)]
623 pub env_vars: Vec<String>,
624 #[serde(skip_serializing_if = "Vec::is_empty", default)]
630 pub env_vars_runtime: Vec<String>,
631 #[serde(skip_serializing_if = "Vec::is_empty", default)]
633 pub mask: Vec<String>,
634 #[serde(skip_serializing_if = "Option::is_none", default)]
637 pub cwd: Option<String>,
638 #[serde(skip_serializing_if = "Option::is_none", default)]
640 pub notification: Option<NotificationConfig>,
641 #[serde(default)]
643 pub tags: Vec<String>,
644
645 #[serde(default = "default_inherit_env")]
648 pub inherit_env: bool,
649 #[serde(skip_serializing_if = "Vec::is_empty", default)]
651 pub env_files: Vec<String>,
652 #[serde(default)]
654 pub timeout_ms: u64,
655 #[serde(default)]
657 pub kill_after_ms: u64,
658 #[serde(default)]
660 pub progress_every_ms: u64,
661 #[serde(skip_serializing_if = "Option::is_none", default)]
663 pub shell_wrapper: Option<Vec<String>>,
664 #[serde(skip_serializing_if = "Option::is_none", default)]
666 pub stdin_file: Option<String>,
667}
668
669fn default_inherit_env() -> bool {
670 true
671}
672
673impl JobMeta {
674 pub fn job_id(&self) -> &str {
676 &self.job.id
677 }
678}
679
680#[derive(Debug, Serialize, Deserialize, Clone)]
682pub struct JobStateJob {
683 pub id: String,
684 pub status: JobStatus,
685 #[serde(skip_serializing_if = "Option::is_none", default)]
687 pub started_at: Option<String>,
688}
689
690#[derive(Debug, Serialize, Deserialize, Clone)]
695pub struct JobStateResult {
696 pub exit_code: Option<i32>,
698 pub signal: Option<String>,
700 pub duration_ms: Option<u64>,
702}
703
704#[derive(Debug, Serialize, Deserialize, Clone)]
720pub struct JobState {
721 pub job: JobStateJob,
722 pub result: JobStateResult,
723 #[serde(skip_serializing_if = "Option::is_none")]
725 pub pid: Option<u32>,
726 #[serde(skip_serializing_if = "Option::is_none")]
728 pub finished_at: Option<String>,
729 pub updated_at: String,
731 #[serde(skip_serializing_if = "Option::is_none")]
736 pub windows_job_name: Option<String>,
737}
738
739impl JobState {
740 pub fn job_id(&self) -> &str {
742 &self.job.id
743 }
744
745 pub fn status(&self) -> &JobStatus {
747 &self.job.status
748 }
749
750 pub fn started_at(&self) -> Option<&str> {
752 self.job.started_at.as_deref()
753 }
754
755 pub fn exit_code(&self) -> Option<i32> {
757 self.result.exit_code
758 }
759
760 pub fn signal(&self) -> Option<&str> {
762 self.result.signal.as_deref()
763 }
764
765 pub fn duration_ms(&self) -> Option<u64> {
767 self.result.duration_ms
768 }
769}
770
771#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
772#[serde(rename_all = "lowercase")]
773pub enum JobStatus {
774 Created,
775 Running,
776 Exited,
777 Killed,
778 Failed,
779}
780
781impl JobStatus {
782 pub fn as_str(&self) -> &'static str {
783 match self {
784 JobStatus::Created => "created",
785 JobStatus::Running => "running",
786 JobStatus::Exited => "exited",
787 JobStatus::Killed => "killed",
788 JobStatus::Failed => "failed",
789 }
790 }
791
792 pub fn is_non_terminal(&self) -> bool {
794 matches!(self, JobStatus::Created | JobStatus::Running)
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn sample_run_data(
803 exit_code: Option<i32>,
804 finished_at: Option<&str>,
805 signal: Option<&str>,
806 duration_ms: Option<u64>,
807 ) -> RunData {
808 RunData {
809 job_id: "abc123".into(),
810 state: "exited".into(),
811 tags: vec![],
812 env_vars: vec![],
813 stdout_log_path: "/tmp/stdout.log".into(),
814 stderr_log_path: "/tmp/stderr.log".into(),
815 elapsed_ms: 50,
816 waited_ms: 40,
817 stdout: "".into(),
818 stderr: "".into(),
819 stdout_range: [0, 0],
820 stderr_range: [0, 0],
821 stdout_total_bytes: 0,
822 stderr_total_bytes: 0,
823 encoding: "utf-8-lossy".into(),
824 exit_code,
825 finished_at: finished_at.map(|s| s.to_string()),
826 signal: signal.map(|s| s.to_string()),
827 duration_ms,
828 compression: None,
829 }
830 }
831
832 #[test]
833 fn run_data_signal_and_duration_present_when_set() {
834 let data = sample_run_data(
835 Some(0),
836 Some("2025-01-01T00:00:01Z"),
837 Some("SIGTERM"),
838 Some(1000),
839 );
840 let json = serde_json::to_value(&data).unwrap();
841 assert_eq!(json["signal"], "SIGTERM");
842 assert_eq!(json["duration_ms"], 1000);
843 }
844
845 #[test]
846 fn run_data_signal_and_duration_omitted_when_none() {
847 let data = sample_run_data(None, None, None, None);
848 let json = serde_json::to_value(&data).unwrap();
849 assert!(
850 json.get("signal").is_none(),
851 "signal should be omitted: {json}"
852 );
853 assert!(
854 json.get("duration_ms").is_none(),
855 "duration_ms should be omitted: {json}"
856 );
857 assert!(
858 json.get("exit_code").is_none(),
859 "exit_code should be omitted: {json}"
860 );
861 assert!(
862 json.get("finished_at").is_none(),
863 "finished_at should be omitted: {json}"
864 );
865 }
866
867 #[test]
868 fn run_data_signal_omitted_duration_present() {
869 let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
870 let json = serde_json::to_value(&data).unwrap();
871 assert!(json.get("signal").is_none(), "signal should be omitted");
872 assert_eq!(json["duration_ms"], 500);
873 assert_eq!(json["exit_code"], 7);
874 }
875
876 #[test]
877 fn wait_data_progress_hints_present_when_set() {
878 let data = WaitData {
879 job_id: "j1".into(),
880 state: "running".into(),
881 exit_code: None,
882 stdout_total_bytes: Some(1024),
883 stderr_total_bytes: Some(256),
884 updated_at: Some("2025-01-01T00:00:00Z".into()),
885 };
886 let json = serde_json::to_value(&data).unwrap();
887 assert_eq!(json["stdout_total_bytes"], 1024);
888 assert_eq!(json["stderr_total_bytes"], 256);
889 assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
890 assert!(json.get("exit_code").is_none());
891 }
892
893 #[test]
894 fn wait_data_progress_hints_omitted_when_none() {
895 let data = WaitData {
896 job_id: "j2".into(),
897 state: "running".into(),
898 exit_code: None,
899 stdout_total_bytes: None,
900 stderr_total_bytes: None,
901 updated_at: None,
902 };
903 let json = serde_json::to_value(&data).unwrap();
904 assert!(json.get("stdout_total_bytes").is_none());
905 assert!(json.get("stderr_total_bytes").is_none());
906 assert!(json.get("updated_at").is_none());
907 }
908
909 #[test]
910 fn wait_data_terminal_with_progress_hints() {
911 let data = WaitData {
912 job_id: "j3".into(),
913 state: "exited".into(),
914 exit_code: Some(0),
915 stdout_total_bytes: Some(512),
916 stderr_total_bytes: Some(0),
917 updated_at: Some("2025-01-01T00:00:02Z".into()),
918 };
919 let json = serde_json::to_value(&data).unwrap();
920 assert_eq!(json["exit_code"], 0);
921 assert_eq!(json["stdout_total_bytes"], 512);
922 assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
923 }
924
925 #[test]
926 fn wait_data_roundtrip() {
927 let data = WaitData {
928 job_id: "j4".into(),
929 state: "exited".into(),
930 exit_code: Some(1),
931 stdout_total_bytes: Some(100),
932 stderr_total_bytes: Some(200),
933 updated_at: Some("2025-06-01T12:00:00Z".into()),
934 };
935 let serialized = serde_json::to_string(&data).unwrap();
936 let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
937 assert_eq!(deserialized.stdout_total_bytes, Some(100));
938 assert_eq!(deserialized.stderr_total_bytes, Some(200));
939 assert_eq!(
940 deserialized.updated_at.as_deref(),
941 Some("2025-06-01T12:00:00Z")
942 );
943 }
944
945 #[test]
946 fn run_data_roundtrip_with_all_fields() {
947 let data = sample_run_data(
948 Some(1),
949 Some("2025-01-01T00:00:02Z"),
950 Some("SIGKILL"),
951 Some(2000),
952 );
953 let serialized = serde_json::to_string(&data).unwrap();
954 let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
955 assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
956 assert_eq!(deserialized.duration_ms, Some(2000));
957 }
958
959 #[test]
960 fn error_detail_omits_details_when_none() {
961 let resp = ErrorResponse::new("test_error", "something went wrong", false);
962 let json = serde_json::to_value(&resp).unwrap();
963 assert!(
964 json["error"].get("details").is_none(),
965 "details should be omitted when None: {json}"
966 );
967 }
968
969 #[test]
970 fn error_detail_includes_details_when_present() {
971 let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
972 serde_json::json!({
973 "candidates": ["id1", "id2"],
974 "truncated": false,
975 }),
976 );
977 let json = serde_json::to_value(&resp).unwrap();
978 let details = &json["error"]["details"];
979 assert!(!details.is_null(), "details must be present: {json}");
980 assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
981 assert_eq!(details["truncated"], false);
982 }
983}