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,
331 pub freed_bytes: u64,
333 pub jobs: Vec<GcJobResult>,
335}
336
337#[derive(Debug, Serialize, Deserialize)]
339pub struct DeleteJobResult {
340 pub job_id: String,
341 pub state: String,
343 pub action: String,
345 pub reason: String,
347}
348
349#[derive(Debug, Serialize, Deserialize)]
351pub struct DeleteData {
352 pub root: String,
354 pub dry_run: bool,
356 pub deleted: u64,
358 pub skipped: u64,
360 pub jobs: Vec<DeleteJobResult>,
362}
363
364#[derive(Debug, Serialize, Deserialize)]
368pub struct InstalledSkillSummary {
369 pub name: String,
371 pub source_type: String,
373 pub path: String,
375}
376
377#[derive(Debug, Serialize, Deserialize)]
379pub struct NotifySetData {
380 pub job_id: String,
381 pub notification: NotificationConfig,
383}
384
385#[derive(Debug, Serialize, Deserialize)]
387pub struct InstallSkillsData {
388 pub skills: Vec<InstalledSkillSummary>,
390 pub global: bool,
392 pub lock_file_path: String,
394}
395
396#[derive(Debug, Serialize, Deserialize)]
398pub struct Snapshot {
399 pub stdout_tail: String,
400 pub stderr_tail: String,
401 pub truncated: bool,
403 pub encoding: String,
404 pub stdout_observed_bytes: u64,
406 pub stderr_observed_bytes: u64,
408 pub stdout_included_bytes: u64,
410 pub stderr_included_bytes: u64,
412}
413
414#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
418#[serde(rename_all = "lowercase")]
419pub enum OutputMatchType {
420 #[default]
421 Contains,
422 Regex,
423}
424
425#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
427#[serde(rename_all = "lowercase")]
428pub enum OutputMatchStream {
429 Stdout,
430 Stderr,
431 #[default]
432 Either,
433}
434
435#[derive(Debug, Serialize, Deserialize, Clone)]
437pub struct OutputMatchConfig {
438 pub pattern: String,
440 #[serde(default)]
442 pub match_type: OutputMatchType,
443 #[serde(default)]
445 pub stream: OutputMatchStream,
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub command: Option<String>,
449 #[serde(skip_serializing_if = "Option::is_none")]
451 pub file: Option<String>,
452}
453
454#[derive(Debug, Serialize, Deserialize, Clone)]
456pub struct NotificationConfig {
457 #[serde(skip_serializing_if = "Option::is_none")]
459 pub notify_command: Option<String>,
460 #[serde(skip_serializing_if = "Option::is_none")]
462 pub notify_file: Option<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
465 pub on_output_match: Option<OutputMatchConfig>,
466}
467
468#[derive(Debug, Serialize, Deserialize, Clone)]
470pub struct CompletionEvent {
471 pub schema_version: String,
472 pub event_type: String,
473 pub job_id: String,
474 pub state: String,
475 pub command: Vec<String>,
476 #[serde(skip_serializing_if = "Option::is_none")]
477 pub cwd: Option<String>,
478 pub started_at: String,
479 pub finished_at: String,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub duration_ms: Option<u64>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub exit_code: Option<i32>,
484 #[serde(skip_serializing_if = "Option::is_none")]
485 pub signal: Option<String>,
486 pub stdout_log_path: String,
487 pub stderr_log_path: String,
488}
489
490#[derive(Debug, Serialize, Deserialize, Clone)]
492pub struct SinkDeliveryResult {
493 pub sink_type: String,
494 pub target: String,
495 pub success: bool,
496 #[serde(skip_serializing_if = "Option::is_none")]
497 pub error: Option<String>,
498 pub attempted_at: String,
499}
500
501#[derive(Debug, Serialize, Deserialize, Clone)]
503pub struct CompletionEventRecord {
504 #[serde(flatten)]
505 pub event: CompletionEvent,
506 pub delivery_results: Vec<SinkDeliveryResult>,
507}
508
509#[derive(Debug, Serialize, Deserialize, Clone)]
511pub struct OutputMatchEvent {
512 pub schema_version: String,
513 pub event_type: String,
514 pub job_id: String,
515 pub pattern: String,
516 pub match_type: String,
517 pub stream: String,
518 pub line: String,
519 pub stdout_log_path: String,
520 pub stderr_log_path: String,
521}
522
523#[derive(Debug, Serialize, Deserialize, Clone)]
525pub struct OutputMatchEventRecord {
526 #[serde(flatten)]
527 pub event: OutputMatchEvent,
528 pub delivery_results: Vec<SinkDeliveryResult>,
529}
530
531#[derive(Debug, Serialize, Deserialize, Clone)]
535pub struct JobMetaJob {
536 pub id: String,
537}
538
539#[derive(Debug, Serialize, Deserialize, Clone)]
567pub struct JobMeta {
568 pub job: JobMetaJob,
569 pub schema_version: String,
570 pub command: Vec<String>,
571 pub created_at: String,
572 pub root: String,
573 pub env_keys: Vec<String>,
575 #[serde(skip_serializing_if = "Vec::is_empty", default)]
578 pub env_vars: Vec<String>,
579 #[serde(skip_serializing_if = "Vec::is_empty", default)]
585 pub env_vars_runtime: Vec<String>,
586 #[serde(skip_serializing_if = "Vec::is_empty", default)]
588 pub mask: Vec<String>,
589 #[serde(skip_serializing_if = "Option::is_none", default)]
592 pub cwd: Option<String>,
593 #[serde(skip_serializing_if = "Option::is_none", default)]
595 pub notification: Option<NotificationConfig>,
596 #[serde(default)]
598 pub tags: Vec<String>,
599
600 #[serde(default = "default_inherit_env")]
603 pub inherit_env: bool,
604 #[serde(skip_serializing_if = "Vec::is_empty", default)]
606 pub env_files: Vec<String>,
607 #[serde(default)]
609 pub timeout_ms: u64,
610 #[serde(default)]
612 pub kill_after_ms: u64,
613 #[serde(default)]
615 pub progress_every_ms: u64,
616 #[serde(skip_serializing_if = "Option::is_none", default)]
618 pub shell_wrapper: Option<Vec<String>>,
619 #[serde(skip_serializing_if = "Option::is_none", default)]
621 pub stdin_file: Option<String>,
622}
623
624fn default_inherit_env() -> bool {
625 true
626}
627
628impl JobMeta {
629 pub fn job_id(&self) -> &str {
631 &self.job.id
632 }
633}
634
635#[derive(Debug, Serialize, Deserialize, Clone)]
637pub struct JobStateJob {
638 pub id: String,
639 pub status: JobStatus,
640 #[serde(skip_serializing_if = "Option::is_none", default)]
642 pub started_at: Option<String>,
643}
644
645#[derive(Debug, Serialize, Deserialize, Clone)]
650pub struct JobStateResult {
651 pub exit_code: Option<i32>,
653 pub signal: Option<String>,
655 pub duration_ms: Option<u64>,
657}
658
659#[derive(Debug, Serialize, Deserialize, Clone)]
675pub struct JobState {
676 pub job: JobStateJob,
677 pub result: JobStateResult,
678 #[serde(skip_serializing_if = "Option::is_none")]
680 pub pid: Option<u32>,
681 #[serde(skip_serializing_if = "Option::is_none")]
683 pub finished_at: Option<String>,
684 pub updated_at: String,
686 #[serde(skip_serializing_if = "Option::is_none")]
691 pub windows_job_name: Option<String>,
692}
693
694impl JobState {
695 pub fn job_id(&self) -> &str {
697 &self.job.id
698 }
699
700 pub fn status(&self) -> &JobStatus {
702 &self.job.status
703 }
704
705 pub fn started_at(&self) -> Option<&str> {
707 self.job.started_at.as_deref()
708 }
709
710 pub fn exit_code(&self) -> Option<i32> {
712 self.result.exit_code
713 }
714
715 pub fn signal(&self) -> Option<&str> {
717 self.result.signal.as_deref()
718 }
719
720 pub fn duration_ms(&self) -> Option<u64> {
722 self.result.duration_ms
723 }
724}
725
726#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
727#[serde(rename_all = "lowercase")]
728pub enum JobStatus {
729 Created,
730 Running,
731 Exited,
732 Killed,
733 Failed,
734}
735
736impl JobStatus {
737 pub fn as_str(&self) -> &'static str {
738 match self {
739 JobStatus::Created => "created",
740 JobStatus::Running => "running",
741 JobStatus::Exited => "exited",
742 JobStatus::Killed => "killed",
743 JobStatus::Failed => "failed",
744 }
745 }
746
747 pub fn is_non_terminal(&self) -> bool {
749 matches!(self, JobStatus::Created | JobStatus::Running)
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 fn sample_run_data(
758 exit_code: Option<i32>,
759 finished_at: Option<&str>,
760 signal: Option<&str>,
761 duration_ms: Option<u64>,
762 ) -> RunData {
763 RunData {
764 job_id: "abc123".into(),
765 state: "exited".into(),
766 tags: vec![],
767 env_vars: vec![],
768 stdout_log_path: "/tmp/stdout.log".into(),
769 stderr_log_path: "/tmp/stderr.log".into(),
770 elapsed_ms: 50,
771 waited_ms: 40,
772 stdout: "".into(),
773 stderr: "".into(),
774 stdout_range: [0, 0],
775 stderr_range: [0, 0],
776 stdout_total_bytes: 0,
777 stderr_total_bytes: 0,
778 encoding: "utf-8-lossy".into(),
779 exit_code,
780 finished_at: finished_at.map(|s| s.to_string()),
781 signal: signal.map(|s| s.to_string()),
782 duration_ms,
783 }
784 }
785
786 #[test]
787 fn run_data_signal_and_duration_present_when_set() {
788 let data = sample_run_data(
789 Some(0),
790 Some("2025-01-01T00:00:01Z"),
791 Some("SIGTERM"),
792 Some(1000),
793 );
794 let json = serde_json::to_value(&data).unwrap();
795 assert_eq!(json["signal"], "SIGTERM");
796 assert_eq!(json["duration_ms"], 1000);
797 }
798
799 #[test]
800 fn run_data_signal_and_duration_omitted_when_none() {
801 let data = sample_run_data(None, None, None, None);
802 let json = serde_json::to_value(&data).unwrap();
803 assert!(
804 json.get("signal").is_none(),
805 "signal should be omitted: {json}"
806 );
807 assert!(
808 json.get("duration_ms").is_none(),
809 "duration_ms should be omitted: {json}"
810 );
811 assert!(
812 json.get("exit_code").is_none(),
813 "exit_code should be omitted: {json}"
814 );
815 assert!(
816 json.get("finished_at").is_none(),
817 "finished_at should be omitted: {json}"
818 );
819 }
820
821 #[test]
822 fn run_data_signal_omitted_duration_present() {
823 let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
824 let json = serde_json::to_value(&data).unwrap();
825 assert!(json.get("signal").is_none(), "signal should be omitted");
826 assert_eq!(json["duration_ms"], 500);
827 assert_eq!(json["exit_code"], 7);
828 }
829
830 #[test]
831 fn wait_data_progress_hints_present_when_set() {
832 let data = WaitData {
833 job_id: "j1".into(),
834 state: "running".into(),
835 exit_code: None,
836 stdout_total_bytes: Some(1024),
837 stderr_total_bytes: Some(256),
838 updated_at: Some("2025-01-01T00:00:00Z".into()),
839 };
840 let json = serde_json::to_value(&data).unwrap();
841 assert_eq!(json["stdout_total_bytes"], 1024);
842 assert_eq!(json["stderr_total_bytes"], 256);
843 assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
844 assert!(json.get("exit_code").is_none());
845 }
846
847 #[test]
848 fn wait_data_progress_hints_omitted_when_none() {
849 let data = WaitData {
850 job_id: "j2".into(),
851 state: "running".into(),
852 exit_code: None,
853 stdout_total_bytes: None,
854 stderr_total_bytes: None,
855 updated_at: None,
856 };
857 let json = serde_json::to_value(&data).unwrap();
858 assert!(json.get("stdout_total_bytes").is_none());
859 assert!(json.get("stderr_total_bytes").is_none());
860 assert!(json.get("updated_at").is_none());
861 }
862
863 #[test]
864 fn wait_data_terminal_with_progress_hints() {
865 let data = WaitData {
866 job_id: "j3".into(),
867 state: "exited".into(),
868 exit_code: Some(0),
869 stdout_total_bytes: Some(512),
870 stderr_total_bytes: Some(0),
871 updated_at: Some("2025-01-01T00:00:02Z".into()),
872 };
873 let json = serde_json::to_value(&data).unwrap();
874 assert_eq!(json["exit_code"], 0);
875 assert_eq!(json["stdout_total_bytes"], 512);
876 assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
877 }
878
879 #[test]
880 fn wait_data_roundtrip() {
881 let data = WaitData {
882 job_id: "j4".into(),
883 state: "exited".into(),
884 exit_code: Some(1),
885 stdout_total_bytes: Some(100),
886 stderr_total_bytes: Some(200),
887 updated_at: Some("2025-06-01T12:00:00Z".into()),
888 };
889 let serialized = serde_json::to_string(&data).unwrap();
890 let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
891 assert_eq!(deserialized.stdout_total_bytes, Some(100));
892 assert_eq!(deserialized.stderr_total_bytes, Some(200));
893 assert_eq!(
894 deserialized.updated_at.as_deref(),
895 Some("2025-06-01T12:00:00Z")
896 );
897 }
898
899 #[test]
900 fn run_data_roundtrip_with_all_fields() {
901 let data = sample_run_data(
902 Some(1),
903 Some("2025-01-01T00:00:02Z"),
904 Some("SIGKILL"),
905 Some(2000),
906 );
907 let serialized = serde_json::to_string(&data).unwrap();
908 let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
909 assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
910 assert_eq!(deserialized.duration_ms, Some(2000));
911 }
912
913 #[test]
914 fn error_detail_omits_details_when_none() {
915 let resp = ErrorResponse::new("test_error", "something went wrong", false);
916 let json = serde_json::to_value(&resp).unwrap();
917 assert!(
918 json["error"].get("details").is_none(),
919 "details should be omitted when None: {json}"
920 );
921 }
922
923 #[test]
924 fn error_detail_includes_details_when_present() {
925 let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
926 serde_json::json!({
927 "candidates": ["id1", "id2"],
928 "truncated": false,
929 }),
930 );
931 let json = serde_json::to_value(&resp).unwrap();
932 let details = &json["error"]["details"];
933 assert!(!details.is_null(), "details must be present: {json}");
934 assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
935 assert_eq!(details["truncated"], false);
936 }
937}