use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
static YAML_OUTPUT: AtomicBool = AtomicBool::new(false);
pub fn set_yaml_output(yaml: bool) {
YAML_OUTPUT.store(yaml, Ordering::Relaxed);
}
pub const SCHEMA_VERSION: &str = "0.1";
fn print_to_stdout(value: &impl Serialize) {
if YAML_OUTPUT.load(Ordering::Relaxed) {
print!(
"{}",
serde_yaml::to_string(value).expect("YAML serialization failed")
);
} else {
println!(
"{}",
serde_json::to_string(value).expect("JSON serialization failed")
);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Response<T: Serialize> {
pub schema_version: &'static str,
pub ok: bool,
#[serde(rename = "type")]
pub kind: &'static str,
#[serde(flatten)]
pub data: T,
}
impl<T: Serialize> Response<T> {
pub fn new(kind: &'static str, data: T) -> Self {
Response {
schema_version: SCHEMA_VERSION,
ok: true,
kind,
data,
}
}
pub fn print(&self) {
print_to_stdout(self);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub schema_version: &'static str,
pub ok: bool,
#[serde(rename = "type")]
pub kind: &'static str,
pub error: ErrorDetail,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorDetail {
pub code: String,
pub message: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
ErrorResponse {
schema_version: SCHEMA_VERSION,
ok: false,
kind: "error",
error: ErrorDetail {
code: code.into(),
message: message.into(),
retryable,
details: None,
},
}
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.error.details = Some(details);
self
}
pub fn print(&self) {
print_to_stdout(self);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateData {
pub job_id: String,
pub state: String,
pub stdout_log_path: String,
pub stderr_log_path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RunData {
pub job_id: String,
pub state: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub env_vars: Vec<String>,
pub stdout_log_path: String,
pub stderr_log_path: String,
pub elapsed_ms: u64,
pub waited_ms: u64,
pub stdout: String,
pub stderr: String,
pub stdout_range: [u64; 2],
pub stderr_range: [u64; 2],
pub stdout_total_bytes: u64,
pub stderr_total_bytes: u64,
pub encoding: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StatusData {
pub job_id: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TailData {
pub job_id: String,
pub stdout: String,
pub stderr: String,
pub encoding: String,
pub stdout_log_path: String,
pub stderr_log_path: String,
pub stdout_range: [u64; 2],
pub stderr_range: [u64; 2],
pub stdout_total_bytes: u64,
pub stderr_total_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WaitData {
pub job_id: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout_total_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr_total_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KillData {
pub job_id: String,
pub signal: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminated_signal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_within_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SchemaData {
pub schema_format: String,
pub schema: serde_json::Value,
pub generated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JobSummary {
pub job_id: String,
pub short_job_id: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TagSetData {
pub job_id: String,
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ListData {
pub root: String,
pub jobs: Vec<JobSummary>,
pub truncated: bool,
pub skipped: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GcJobResult {
pub job_id: String,
pub state: String,
pub action: String,
pub reason: String,
pub bytes: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GcData {
pub root: String,
pub dry_run: bool,
pub older_than: String,
pub older_than_source: String,
pub deleted: u64,
pub skipped: u64,
pub out_of_scope: u64,
pub failed: u64,
pub freed_bytes: u64,
pub scanned_dirs: u64,
pub candidate_count: u64,
pub jobs: Vec<GcJobResult>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteJobResult {
pub job_id: String,
pub state: String,
pub action: String,
pub reason: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteData {
pub root: String,
pub dry_run: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd_scope: Option<String>,
pub deleted: u64,
pub skipped: u64,
pub out_of_scope: u64,
pub failed: u64,
pub jobs: Vec<DeleteJobResult>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstalledSkillSummary {
pub name: String,
pub source_type: String,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NotifySetData {
pub job_id: String,
pub notification: NotificationConfig,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstallSkillsData {
pub skills: Vec<InstalledSkillSummary>,
pub global: bool,
pub lock_file_path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Snapshot {
pub stdout_tail: String,
pub stderr_tail: String,
pub truncated: bool,
pub encoding: String,
pub stdout_observed_bytes: u64,
pub stderr_observed_bytes: u64,
pub stdout_included_bytes: u64,
pub stderr_included_bytes: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputMatchType {
#[default]
Contains,
Regex,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OutputMatchStream {
Stdout,
Stderr,
#[default]
Either,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchConfig {
pub pattern: String,
#[serde(default)]
pub match_type: OutputMatchType,
#[serde(default)]
pub stream: OutputMatchStream,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotificationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub notify_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notify_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_output_match: Option<OutputMatchConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompletionEvent {
pub schema_version: String,
pub event_type: String,
pub job_id: String,
pub state: String,
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
pub started_at: String,
pub finished_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
pub stdout_log_path: String,
pub stderr_log_path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SinkDeliveryResult {
pub sink_type: String,
pub target: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub attempted_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompletionEventRecord {
#[serde(flatten)]
pub event: CompletionEvent,
pub delivery_results: Vec<SinkDeliveryResult>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchEvent {
pub schema_version: String,
pub event_type: String,
pub job_id: String,
pub pattern: String,
pub match_type: String,
pub stream: String,
pub line: String,
pub stdout_log_path: String,
pub stderr_log_path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OutputMatchEventRecord {
#[serde(flatten)]
pub event: OutputMatchEvent,
pub delivery_results: Vec<SinkDeliveryResult>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobMetaJob {
pub id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobMeta {
pub job: JobMetaJob,
pub schema_version: String,
pub command: Vec<String>,
pub created_at: String,
pub root: String,
pub env_keys: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub env_vars: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub env_vars_runtime: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub mask: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub notification: Option<NotificationConfig>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_inherit_env")]
pub inherit_env: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub env_files: Vec<String>,
#[serde(default)]
pub timeout_ms: u64,
#[serde(default)]
pub kill_after_ms: u64,
#[serde(default)]
pub progress_every_ms: u64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub shell_wrapper: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub stdin_file: Option<String>,
}
fn default_inherit_env() -> bool {
true
}
impl JobMeta {
pub fn job_id(&self) -> &str {
&self.job.id
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobStateJob {
pub id: String,
pub status: JobStatus,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub started_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobStateResult {
pub exit_code: Option<i32>,
pub signal: Option<String>,
pub duration_ms: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobState {
pub job: JobStateJob,
pub result: JobStateResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub windows_job_name: Option<String>,
}
impl JobState {
pub fn job_id(&self) -> &str {
&self.job.id
}
pub fn status(&self) -> &JobStatus {
&self.job.status
}
pub fn started_at(&self) -> Option<&str> {
self.job.started_at.as_deref()
}
pub fn exit_code(&self) -> Option<i32> {
self.result.exit_code
}
pub fn signal(&self) -> Option<&str> {
self.result.signal.as_deref()
}
pub fn duration_ms(&self) -> Option<u64> {
self.result.duration_ms
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Created,
Running,
Exited,
Killed,
Failed,
}
impl JobStatus {
pub fn as_str(&self) -> &'static str {
match self {
JobStatus::Created => "created",
JobStatus::Running => "running",
JobStatus::Exited => "exited",
JobStatus::Killed => "killed",
JobStatus::Failed => "failed",
}
}
pub fn is_non_terminal(&self) -> bool {
matches!(self, JobStatus::Created | JobStatus::Running)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_run_data(
exit_code: Option<i32>,
finished_at: Option<&str>,
signal: Option<&str>,
duration_ms: Option<u64>,
) -> RunData {
RunData {
job_id: "abc123".into(),
state: "exited".into(),
tags: vec![],
env_vars: vec![],
stdout_log_path: "/tmp/stdout.log".into(),
stderr_log_path: "/tmp/stderr.log".into(),
elapsed_ms: 50,
waited_ms: 40,
stdout: "".into(),
stderr: "".into(),
stdout_range: [0, 0],
stderr_range: [0, 0],
stdout_total_bytes: 0,
stderr_total_bytes: 0,
encoding: "utf-8-lossy".into(),
exit_code,
finished_at: finished_at.map(|s| s.to_string()),
signal: signal.map(|s| s.to_string()),
duration_ms,
}
}
#[test]
fn run_data_signal_and_duration_present_when_set() {
let data = sample_run_data(
Some(0),
Some("2025-01-01T00:00:01Z"),
Some("SIGTERM"),
Some(1000),
);
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["signal"], "SIGTERM");
assert_eq!(json["duration_ms"], 1000);
}
#[test]
fn run_data_signal_and_duration_omitted_when_none() {
let data = sample_run_data(None, None, None, None);
let json = serde_json::to_value(&data).unwrap();
assert!(
json.get("signal").is_none(),
"signal should be omitted: {json}"
);
assert!(
json.get("duration_ms").is_none(),
"duration_ms should be omitted: {json}"
);
assert!(
json.get("exit_code").is_none(),
"exit_code should be omitted: {json}"
);
assert!(
json.get("finished_at").is_none(),
"finished_at should be omitted: {json}"
);
}
#[test]
fn run_data_signal_omitted_duration_present() {
let data = sample_run_data(Some(7), Some("2025-01-01T00:00:01Z"), None, Some(500));
let json = serde_json::to_value(&data).unwrap();
assert!(json.get("signal").is_none(), "signal should be omitted");
assert_eq!(json["duration_ms"], 500);
assert_eq!(json["exit_code"], 7);
}
#[test]
fn wait_data_progress_hints_present_when_set() {
let data = WaitData {
job_id: "j1".into(),
state: "running".into(),
exit_code: None,
stdout_total_bytes: Some(1024),
stderr_total_bytes: Some(256),
updated_at: Some("2025-01-01T00:00:00Z".into()),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["stdout_total_bytes"], 1024);
assert_eq!(json["stderr_total_bytes"], 256);
assert_eq!(json["updated_at"], "2025-01-01T00:00:00Z");
assert!(json.get("exit_code").is_none());
}
#[test]
fn wait_data_progress_hints_omitted_when_none() {
let data = WaitData {
job_id: "j2".into(),
state: "running".into(),
exit_code: None,
stdout_total_bytes: None,
stderr_total_bytes: None,
updated_at: None,
};
let json = serde_json::to_value(&data).unwrap();
assert!(json.get("stdout_total_bytes").is_none());
assert!(json.get("stderr_total_bytes").is_none());
assert!(json.get("updated_at").is_none());
}
#[test]
fn wait_data_terminal_with_progress_hints() {
let data = WaitData {
job_id: "j3".into(),
state: "exited".into(),
exit_code: Some(0),
stdout_total_bytes: Some(512),
stderr_total_bytes: Some(0),
updated_at: Some("2025-01-01T00:00:02Z".into()),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["exit_code"], 0);
assert_eq!(json["stdout_total_bytes"], 512);
assert_eq!(json["updated_at"], "2025-01-01T00:00:02Z");
}
#[test]
fn wait_data_roundtrip() {
let data = WaitData {
job_id: "j4".into(),
state: "exited".into(),
exit_code: Some(1),
stdout_total_bytes: Some(100),
stderr_total_bytes: Some(200),
updated_at: Some("2025-06-01T12:00:00Z".into()),
};
let serialized = serde_json::to_string(&data).unwrap();
let deserialized: WaitData = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.stdout_total_bytes, Some(100));
assert_eq!(deserialized.stderr_total_bytes, Some(200));
assert_eq!(
deserialized.updated_at.as_deref(),
Some("2025-06-01T12:00:00Z")
);
}
#[test]
fn run_data_roundtrip_with_all_fields() {
let data = sample_run_data(
Some(1),
Some("2025-01-01T00:00:02Z"),
Some("SIGKILL"),
Some(2000),
);
let serialized = serde_json::to_string(&data).unwrap();
let deserialized: RunData = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.signal.as_deref(), Some("SIGKILL"));
assert_eq!(deserialized.duration_ms, Some(2000));
}
#[test]
fn error_detail_omits_details_when_none() {
let resp = ErrorResponse::new("test_error", "something went wrong", false);
let json = serde_json::to_value(&resp).unwrap();
assert!(
json["error"].get("details").is_none(),
"details should be omitted when None: {json}"
);
}
#[test]
fn error_detail_includes_details_when_present() {
let resp = ErrorResponse::new("ambiguous_job_id", "ambiguous prefix", false).with_details(
serde_json::json!({
"candidates": ["id1", "id2"],
"truncated": false,
}),
);
let json = serde_json::to_value(&resp).unwrap();
let details = &json["error"]["details"];
assert!(!details.is_null(), "details must be present: {json}");
assert_eq!(details["candidates"].as_array().unwrap().len(), 2);
assert_eq!(details["truncated"], false);
}
}