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 GcData {
326 pub root: String,
328 pub dry_run: bool,
330 pub older_than: String,
332 pub older_than_source: String,
334 pub deleted: u64,
336 pub skipped: u64,
339 pub out_of_scope: u64,
342 pub failed: u64,
345 pub freed_bytes: u64,
347 pub scanned_dirs: u64,
349 pub candidate_count: u64,
351}
352
353#[derive(Debug, Serialize, Deserialize)]
355pub struct DeleteJobResult {
356 pub job_id: String,
357 pub state: String,
359 pub action: String,
361 pub reason: String,
363}
364
365#[derive(Debug, Serialize, Deserialize)]
367pub struct DeleteData {
368 pub root: String,
370 pub dry_run: bool,
372 #[serde(skip_serializing_if = "Option::is_none")]
376 pub cwd_scope: Option<String>,
377 pub deleted: u64,
379 pub skipped: u64,
382 pub out_of_scope: u64,
385 pub failed: u64,
389 pub jobs: Vec<DeleteJobResult>,
391}
392
393#[derive(Debug, Serialize, Deserialize)]
397pub struct InstalledSkillSummary {
398 pub name: String,
400 pub source_type: String,
402 pub path: String,
404}
405
406#[derive(Debug, Serialize, Deserialize)]
408pub struct NotifySetData {
409 pub job_id: String,
410 pub notification: NotificationConfig,
412}
413
414#[derive(Debug, Serialize, Deserialize)]
416pub struct InstallSkillsData {
417 pub skills: Vec<InstalledSkillSummary>,
419 pub global: bool,
421 pub lock_file_path: String,
423}
424
425#[derive(Debug, Serialize, Deserialize)]
427pub struct Snapshot {
428 pub stdout_tail: String,
429 pub stderr_tail: String,
430 pub truncated: bool,
432 pub encoding: String,
433 pub stdout_observed_bytes: u64,
435 pub stderr_observed_bytes: u64,
437 pub stdout_included_bytes: u64,
439 pub stderr_included_bytes: u64,
441}
442
443#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
447#[serde(rename_all = "lowercase")]
448pub enum OutputMatchType {
449 #[default]
450 Contains,
451 Regex,
452}
453
454#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
456#[serde(rename_all = "lowercase")]
457pub enum OutputMatchStream {
458 Stdout,
459 Stderr,
460 #[default]
461 Either,
462}
463
464#[derive(Debug, Serialize, Deserialize, Clone)]
466pub struct OutputMatchConfig {
467 pub pattern: String,
469 #[serde(default)]
471 pub match_type: OutputMatchType,
472 #[serde(default)]
474 pub stream: OutputMatchStream,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub command: Option<String>,
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub file: Option<String>,
481}
482
483#[derive(Debug, Serialize, Deserialize, Clone)]
485pub struct NotificationConfig {
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub notify_command: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub notify_file: Option<String>,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub on_output_match: Option<OutputMatchConfig>,
495}
496
497#[derive(Debug, Serialize, Deserialize, Clone)]
499pub struct CompletionEvent {
500 pub schema_version: String,
501 pub event_type: String,
502 pub job_id: String,
503 pub state: String,
504 pub command: Vec<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
506 pub cwd: Option<String>,
507 pub started_at: String,
508 pub finished_at: String,
509 #[serde(skip_serializing_if = "Option::is_none")]
510 pub duration_ms: Option<u64>,
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub exit_code: Option<i32>,
513 #[serde(skip_serializing_if = "Option::is_none")]
514 pub signal: Option<String>,
515 pub stdout_log_path: String,
516 pub stderr_log_path: String,
517}
518
519#[derive(Debug, Serialize, Deserialize, Clone)]
521pub struct SinkDeliveryResult {
522 pub sink_type: String,
523 pub target: String,
524 pub success: bool,
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub error: Option<String>,
527 pub attempted_at: String,
528}
529
530#[derive(Debug, Serialize, Deserialize, Clone)]
532pub struct CompletionEventRecord {
533 #[serde(flatten)]
534 pub event: CompletionEvent,
535 pub delivery_results: Vec<SinkDeliveryResult>,
536}
537
538#[derive(Debug, Serialize, Deserialize, Clone)]
540pub struct OutputMatchEvent {
541 pub schema_version: String,
542 pub event_type: String,
543 pub job_id: String,
544 pub pattern: String,
545 pub match_type: String,
546 pub stream: String,
547 pub line: String,
548 pub stdout_log_path: String,
549 pub stderr_log_path: String,
550}
551
552#[derive(Debug, Serialize, Deserialize, Clone)]
554pub struct OutputMatchEventRecord {
555 #[serde(flatten)]
556 pub event: OutputMatchEvent,
557 pub delivery_results: Vec<SinkDeliveryResult>,
558}
559
560#[derive(Debug, Serialize, Deserialize, Clone)]
564pub struct JobMetaJob {
565 pub id: String,
566}
567
568#[derive(Debug, Serialize, Deserialize, Clone)]
596pub struct JobMeta {
597 pub job: JobMetaJob,
598 pub schema_version: String,
599 pub command: Vec<String>,
600 pub created_at: String,
601 pub root: String,
602 pub env_keys: Vec<String>,
604 #[serde(skip_serializing_if = "Vec::is_empty", default)]
607 pub env_vars: Vec<String>,
608 #[serde(skip_serializing_if = "Vec::is_empty", default)]
614 pub env_vars_runtime: Vec<String>,
615 #[serde(skip_serializing_if = "Vec::is_empty", default)]
617 pub mask: Vec<String>,
618 #[serde(skip_serializing_if = "Option::is_none", default)]
621 pub cwd: Option<String>,
622 #[serde(skip_serializing_if = "Option::is_none", default)]
624 pub notification: Option<NotificationConfig>,
625 #[serde(default)]
627 pub tags: Vec<String>,
628
629 #[serde(default = "default_inherit_env")]
632 pub inherit_env: bool,
633 #[serde(skip_serializing_if = "Vec::is_empty", default)]
635 pub env_files: Vec<String>,
636 #[serde(default)]
638 pub timeout_ms: u64,
639 #[serde(default)]
641 pub kill_after_ms: u64,
642 #[serde(default)]
644 pub progress_every_ms: u64,
645 #[serde(skip_serializing_if = "Option::is_none", default)]
647 pub shell_wrapper: Option<Vec<String>>,
648 #[serde(skip_serializing_if = "Option::is_none", default)]
650 pub stdin_file: Option<String>,
651}
652
653fn default_inherit_env() -> bool {
654 true
655}
656
657impl JobMeta {
658 pub fn job_id(&self) -> &str {
660 &self.job.id
661 }
662}
663
664#[derive(Debug, Serialize, Deserialize, Clone)]
666pub struct JobStateJob {
667 pub id: String,
668 pub status: JobStatus,
669 #[serde(skip_serializing_if = "Option::is_none", default)]
671 pub started_at: Option<String>,
672}
673
674#[derive(Debug, Serialize, Deserialize, Clone)]
679pub struct JobStateResult {
680 pub exit_code: Option<i32>,
682 pub signal: Option<String>,
684 pub duration_ms: Option<u64>,
686}
687
688#[derive(Debug, Serialize, Deserialize, Clone)]
704pub struct JobState {
705 pub job: JobStateJob,
706 pub result: JobStateResult,
707 #[serde(skip_serializing_if = "Option::is_none")]
709 pub pid: Option<u32>,
710 #[serde(skip_serializing_if = "Option::is_none")]
712 pub finished_at: Option<String>,
713 pub updated_at: String,
715 #[serde(skip_serializing_if = "Option::is_none")]
720 pub windows_job_name: Option<String>,
721}
722
723impl JobState {
724 pub fn job_id(&self) -> &str {
726 &self.job.id
727 }
728
729 pub fn status(&self) -> &JobStatus {
731 &self.job.status
732 }
733
734 pub fn started_at(&self) -> Option<&str> {
736 self.job.started_at.as_deref()
737 }
738
739 pub fn exit_code(&self) -> Option<i32> {
741 self.result.exit_code
742 }
743
744 pub fn signal(&self) -> Option<&str> {
746 self.result.signal.as_deref()
747 }
748
749 pub fn duration_ms(&self) -> Option<u64> {
751 self.result.duration_ms
752 }
753}
754
755#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
756#[serde(rename_all = "lowercase")]
757pub enum JobStatus {
758 Created,
759 Running,
760 Exited,
761 Killed,
762 Failed,
763}
764
765impl JobStatus {
766 pub fn as_str(&self) -> &'static str {
767 match self {
768 JobStatus::Created => "created",
769 JobStatus::Running => "running",
770 JobStatus::Exited => "exited",
771 JobStatus::Killed => "killed",
772 JobStatus::Failed => "failed",
773 }
774 }
775
776 pub fn is_non_terminal(&self) -> bool {
778 matches!(self, JobStatus::Created | JobStatus::Running)
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 fn sample_run_data(
787 exit_code: Option<i32>,
788 finished_at: Option<&str>,
789 signal: Option<&str>,
790 duration_ms: Option<u64>,
791 ) -> RunData {
792 RunData {
793 job_id: "abc123".into(),
794 state: "exited".into(),
795 tags: vec![],
796 env_vars: vec![],
797 stdout_log_path: "/tmp/stdout.log".into(),
798 stderr_log_path: "/tmp/stderr.log".into(),
799 elapsed_ms: 50,
800 waited_ms: 40,
801 stdout: "".into(),
802 stderr: "".into(),
803 stdout_range: [0, 0],
804 stderr_range: [0, 0],
805 stdout_total_bytes: 0,
806 stderr_total_bytes: 0,
807 encoding: "utf-8-lossy".into(),
808 exit_code,
809 finished_at: finished_at.map(|s| s.to_string()),
810 signal: signal.map(|s| s.to_string()),
811 duration_ms,
812 compression: None,
813 }
814 }
815
816 #[test]
817 fn run_data_signal_and_duration_present_when_set() {
818 let data = sample_run_data(
819 Some(0),
820 Some("2025-01-01T00:00:01Z"),
821 Some("SIGTERM"),
822 Some(1000),
823 );
824 let json = serde_json::to_value(&data).unwrap();
825 assert_eq!(json["signal"], "SIGTERM");
826 assert_eq!(json["duration_ms"], 1000);
827 }
828
829 #[test]
830 fn run_data_signal_and_duration_omitted_when_none() {
831 let data = sample_run_data(None, None, None, None);
832 let json = serde_json::to_value(&data).unwrap();
833 assert!(
834 json.get("signal").is_none(),
835 "signal should be omitted: {json}"
836 );
837 assert!(
838 json.get("duration_ms").is_none(),
839 "duration_ms should be omitted: {json}"
840 );
841 assert!(
842 json.get("exit_code").is_none(),
843 "exit_code should be omitted: {json}"
844 );
845 assert!(
846 json.get("finished_at").is_none(),
847 "finished_at should be omitted: {json}"
848 );
849 }
850
851 #[test]
852 fn run_data_signal_omitted_duration_present() {
853 let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
854 let json = serde_json::to_value(&data).unwrap();
855 assert!(json.get("signal").is_none(), "signal should be omitted");
856 assert_eq!(json["duration_ms"], 500);
857 assert_eq!(json["exit_code"], 7);
858 }
859
860 #[test]
861 fn wait_data_progress_hints_present_when_set() {
862 let data = WaitData {
863 job_id: "j1".into(),
864 state: "running".into(),
865 exit_code: None,
866 stdout_total_bytes: Some(1024),
867 stderr_total_bytes: Some(256),
868 updated_at: Some("2025-01-01T00:00:00Z".into()),
869 };
870 let json = serde_json::to_value(&data).unwrap();
871 assert_eq!(json["stdout_total_bytes"], 1024);
872 assert_eq!(json["stderr_total_bytes"], 256);
873 assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
874 assert!(json.get("exit_code").is_none());
875 }
876
877 #[test]
878 fn wait_data_progress_hints_omitted_when_none() {
879 let data = WaitData {
880 job_id: "j2".into(),
881 state: "running".into(),
882 exit_code: None,
883 stdout_total_bytes: None,
884 stderr_total_bytes: None,
885 updated_at: None,
886 };
887 let json = serde_json::to_value(&data).unwrap();
888 assert!(json.get("stdout_total_bytes").is_none());
889 assert!(json.get("stderr_total_bytes").is_none());
890 assert!(json.get("updated_at").is_none());
891 }
892
893 #[test]
894 fn wait_data_terminal_with_progress_hints() {
895 let data = WaitData {
896 job_id: "j3".into(),
897 state: "exited".into(),
898 exit_code: Some(0),
899 stdout_total_bytes: Some(512),
900 stderr_total_bytes: Some(0),
901 updated_at: Some("2025-01-01T00:00:02Z".into()),
902 };
903 let json = serde_json::to_value(&data).unwrap();
904 assert_eq!(json["exit_code"], 0);
905 assert_eq!(json["stdout_total_bytes"], 512);
906 assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
907 }
908
909 #[test]
910 fn wait_data_roundtrip() {
911 let data = WaitData {
912 job_id: "j4".into(),
913 state: "exited".into(),
914 exit_code: Some(1),
915 stdout_total_bytes: Some(100),
916 stderr_total_bytes: Some(200),
917 updated_at: Some("2025-06-01T12:00:00Z".into()),
918 };
919 let serialized = serde_json::to_string(&data).unwrap();
920 let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
921 assert_eq!(deserialized.stdout_total_bytes, Some(100));
922 assert_eq!(deserialized.stderr_total_bytes, Some(200));
923 assert_eq!(
924 deserialized.updated_at.as_deref(),
925 Some("2025-06-01T12:00:00Z")
926 );
927 }
928
929 #[test]
930 fn run_data_roundtrip_with_all_fields() {
931 let data = sample_run_data(
932 Some(1),
933 Some("2025-01-01T00:00:02Z"),
934 Some("SIGKILL"),
935 Some(2000),
936 );
937 let serialized = serde_json::to_string(&data).unwrap();
938 let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
939 assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
940 assert_eq!(deserialized.duration_ms, Some(2000));
941 }
942
943 #[test]
944 fn error_detail_omits_details_when_none() {
945 let resp = ErrorResponse::new("test_error", "something went wrong", false);
946 let json = serde_json::to_value(&resp).unwrap();
947 assert!(
948 json["error"].get("details").is_none(),
949 "details should be omitted when None: {json}"
950 );
951 }
952
953 #[test]
954 fn error_detail_includes_details_when_present() {
955 let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
956 serde_json::json!({
957 "candidates": ["id1", "id2"],
958 "truncated": false,
959 }),
960 );
961 let json = serde_json::to_value(&resp).unwrap();
962 let details = &json["error"]["details"];
963 assert!(!details.is_null(), "details must be present: {json}");
964 assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
965 assert_eq!(details["truncated"], false);
966 }
967}