#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use subc_protocol::{manifest::ProviderRole, session::HealthStatus, BindIdentity, RouteTarget};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ConsumerIdentity {
pub module_id: String,
pub launch_nonce: String,
}
pub mod ops {
pub const SERVER: &str = "server.";
pub const CATALOG: &str = "catalog.";
pub const ROUTE: &str = "route.";
pub const SUPERVISOR: &str = "supervisor.";
pub const CONFIG: &str = "config.";
pub const SERVER_DESCRIBE: &str = "server.describe";
pub const CATALOG_LIST: &str = "catalog.list";
pub const ROUTE_OPEN: &str = "route.open";
pub const ROUTE_POLL: &str = "route.poll";
pub const SUPERVISOR_LIST: &str = "supervisor.list";
pub const SUPERVISOR_RESTART: &str = "supervisor.restart";
pub const SUPERVISOR_RELOAD: &str = "supervisor.reload";
pub const SUPERVISOR_RESCAN: &str = "supervisor.rescan";
pub const SUPERVISOR_SET_ENABLED: &str = "supervisor.set_enabled";
pub const SUPERVISOR_HEALTH_PROBE: &str = "supervisor.health_probe";
pub const SUPERVISOR_HEALTH: &str = "supervisor.health";
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "op")]
#[allow(clippy::large_enum_variant)]
pub enum ClientControlRequest {
#[serde(rename = "server.describe")]
ServerDescribe {},
#[serde(rename = "catalog.list")]
CatalogList {
#[serde(default)]
module_id: Option<String>,
},
#[serde(rename = "route.open")]
RouteOpen {
target: RouteTarget,
identity: BindIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
consumer_identity: Option<ConsumerIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
consumer_capabilities: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
admission_facts: Option<serde_json::Value>,
},
#[serde(rename = "route.poll")]
RoutePoll {
route_channel: u16,
route_epoch: u32,
kind: PollKind,
},
#[serde(rename = "supervisor.list")]
SupervisorList {},
#[serde(rename = "supervisor.restart")]
SupervisorRestart { module_id: String },
#[serde(rename = "supervisor.reload")]
SupervisorReload { module_id: String },
#[serde(rename = "supervisor.rescan")]
SupervisorRescan {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
preview: bool,
},
#[serde(rename = "supervisor.set_enabled")]
SupervisorSetEnabled { module_id: String, enabled: bool },
#[serde(rename = "supervisor.health_probe")]
SupervisorHealthProbe { module_id: String },
#[serde(rename = "supervisor.health")]
SupervisorHealth {},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "op")]
pub enum ClientControlResponse {
#[serde(rename = "server.describe")]
ServerDescribe {
protocol_ver: u8,
subc_ops: Vec<String>,
capabilities: Vec<String>,
connected_clients: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
counters: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
build_git_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
build_lock_digest: Option<String>,
},
#[serde(rename = "catalog.list")]
CatalogList {
generation: u64,
modules: Vec<CatalogEntry>,
subc_ops: Vec<String>,
},
#[serde(rename = "route.open")]
RouteOpen {
route_channel: u16,
route_epoch: u32,
},
#[serde(rename = "route.poll")]
RoutePoll {
route_channel: u16,
route_epoch: u32,
status: Option<String>,
live: Option<bool>,
},
#[serde(rename = "supervisor.list")]
SupervisorList {
generation: u64,
modules: Vec<SupervisorEntry>,
},
#[serde(rename = "supervisor.ack")]
SupervisorAck { module_id: String, applied: bool },
#[serde(rename = "supervisor.rescan")]
SupervisorRescan {
#[serde(flatten)]
result: SupervisorRescanResult,
},
#[serde(rename = "supervisor.health_probe")]
SupervisorHealthProbe {
module_id: String,
status: HealthStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metrics: Option<serde_json::Value>,
},
#[serde(rename = "supervisor.health")]
SupervisorHealth {
generation: u64,
modules: Vec<SupervisorHealthEntry>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PollKind {
Status,
Liveness,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CatalogEntry {
pub module_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub module_version: Option<String>,
pub roles: Vec<ProviderRole>,
pub control_ops: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SupervisorRescanResult {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed_pending_reload: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled_changes: Vec<String>,
pub unchanged: u32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preview: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub restart_required: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SupervisorEntry {
pub module_id: String,
pub state: String,
pub enabled: bool,
pub live: bool,
pub health: SupervisorHealthStatus,
#[serde(default)]
pub last_probe_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_exit_signal: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_restarts: Option<u32>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SupervisorHealthStatus {
Ok,
Degraded,
Failing,
Unresponsive,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SupervisorHealthEntry {
pub module_id: String,
pub status: SupervisorHealthStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metrics: Option<serde_json::Value>,
pub consecutive_failures: u32,
#[serde(default)]
pub late_answer_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_late_answer_latency_ms: Option<u64>,
#[serde(default)]
pub last_action: Option<String>,
#[serde(default)]
pub last_action_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_probe_ms: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
use subc_protocol::{BindIdentity, RouteTarget};
#[test]
fn route_poll_uses_kind_field() {
let body = serde_json::to_value(ClientControlRequest::RoutePoll {
route_channel: 7,
route_epoch: 11,
kind: PollKind::Status,
})
.unwrap();
assert_eq!(body["op"], "route.poll");
assert_eq!(body["route_epoch"], 11);
assert_eq!(body["kind"], "status");
assert!(body.get("op").is_some());
}
#[test]
fn route_open_is_internally_tagged() {
let request = ClientControlRequest::RouteOpen {
target: RouteTarget::ToolProvider {
module_id: "aft".to_string(),
},
identity: BindIdentity {
project_root: "/tmp/project".into(),
harness: "opencode".to_string(),
session: "session-1".to_string(),
},
consumer_identity: None,
consumer_capabilities: None,
admission_facts: None,
};
let body = serde_json::to_value(request).unwrap();
assert_eq!(body["op"], "route.open");
assert_eq!(body["target"]["kind"], "tool_provider");
assert!(body.get("consumer_identity").is_none());
assert!(body.get("consumer_capabilities").is_none());
}
#[test]
fn route_open_without_optional_fields_still_decodes() {
let body = serde_json::json!({
"op": "route.open",
"target": { "kind": "tool_provider", "module_id": "aft" },
"identity": {
"project_root": "/tmp/project",
"harness": "opencode",
"session": "session-1"
}
});
let decoded: ClientControlRequest = serde_json::from_value(body).unwrap();
let ClientControlRequest::RouteOpen {
consumer_identity,
consumer_capabilities,
admission_facts,
..
} = decoded
else {
panic!("decoded wrong request variant");
};
assert_eq!(consumer_identity, None);
assert_eq!(consumer_capabilities, None);
assert_eq!(admission_facts, None);
}
}