use std::io::{BufRead, Write};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use crate::elicitation::ElicitationResponse;
use crate::project_memory::ProjectMemorySnapshot;
use super::snapshot::{RelayCommand, RelayEvent, RelayOperationalState};
use super::{MAX_FRAME_BYTES, RELAY_MIN_PROTOCOL_VERSION, RELAY_PROTOCOL_VERSION};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelayVersionRange {
pub min: u32,
pub max: u32,
}
impl RelayVersionRange {
pub const CURRENT: Self = Self {
min: RELAY_MIN_PROTOCOL_VERSION,
max: RELAY_PROTOCOL_VERSION,
};
pub const fn contains(self, version: u32) -> bool {
self.min <= version && version <= self.max
}
pub fn negotiate(self, peer: Self) -> Option<u32> {
let minimum = self.min.max(peer.min);
let maximum = self.max.min(peer.max);
(minimum <= maximum).then_some(maximum)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "method",
content = "params",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum RelayRequest {
Hello {
controller_version: String,
supported: RelayVersionRange,
},
Attach {
after_ordinal: u64,
after_digest: String,
},
Acknowledge {
through_ordinal: u64,
through_digest: String,
},
Submit {
command_id: String,
command: RelayCommand,
},
Status,
AttachmentPresent {
reference: crate::attachment::AttachmentRef,
},
InstallAttachment {
reference: crate::attachment::AttachmentRef,
data: String,
},
ReadAttachment {
reference: crate::attachment::AttachmentRef,
},
InstallPromptContext {
text: String,
},
ProjectMemorySnapshot,
InstallProjectMemorySnapshot {
snapshot: ProjectMemorySnapshot,
},
CredentialState,
ReadCredentials,
InstallCredentials {
data: String,
},
SkillsState,
InstallSkills {
data: String,
},
GithubTokenState,
InstallGithubToken {
data: String,
},
RemoveGithubToken,
RespondElicitation {
elicitation_id: String,
response: ElicitationResponse,
},
StopBackgroundTask {
background_task_id: String,
},
SubagentRequests,
CompleteSubagentRequest {
result: crate::subagent::SubagentToolResult,
},
Reviewer {
#[serde(default, skip_serializing_if = "Option::is_none")]
role: Option<String>,
request: ReviewerRequest,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", content = "params", rename_all = "snake_case")]
pub enum ReviewerRequest {
Start {
config: Box<crate::worker_launch::ReviewerLaunchConfig>,
},
Attach {
after_ordinal: u64,
after_digest: String,
},
Acknowledge {
through_ordinal: u64,
through_digest: String,
},
Submit {
command_id: String,
command: RelayCommand,
},
Status,
RespondElicitation {
elicitation_id: String,
response: ElicitationResponse,
},
Pause,
CaptureDelta {
baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
},
AdvanceBaseline {
trees: std::collections::BTreeMap<std::path::PathBuf, String>,
},
AnalyzeDelta {
repositories: Vec<AnalyzeDeltaRepository>,
},
TakeLaneDispatches,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnalyzeDeltaRepository {
pub root: std::path::PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline_tree: Option<String>,
pub current_tree: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoDelta {
pub root: std::path::PathBuf,
pub baseline_tree: Option<String>,
pub current_tree: String,
pub patch: String,
pub diffstat: String,
pub changed_lines: usize,
}
impl ReviewerRequest {
pub const fn action_name(&self) -> &'static str {
match self {
Self::Start { .. } => "reviewer_start",
Self::Attach { .. } => "reviewer_attach",
Self::Acknowledge { .. } => "reviewer_acknowledge",
Self::Submit { .. } => "reviewer_submit",
Self::Status => "reviewer_status",
Self::RespondElicitation { .. } => "reviewer_respond_elicitation",
Self::Pause => "reviewer_pause",
Self::CaptureDelta { .. } => "reviewer_capture_delta",
Self::AdvanceBaseline { .. } => "reviewer_advance_baseline",
Self::AnalyzeDelta { .. } => "reviewer_analyze_delta",
Self::TakeLaneDispatches => "reviewer_take_lane_dispatches",
}
}
}
impl RelayRequest {
pub const fn method_name(&self) -> &'static str {
match self {
Self::Hello { .. } => "hello",
Self::Attach { .. } => "attach",
Self::Acknowledge { .. } => "acknowledge",
Self::Submit { .. } => "submit",
Self::Status => "status",
Self::InstallPromptContext { .. } => "install_prompt_context",
Self::ProjectMemorySnapshot => "project_memory_snapshot",
Self::InstallProjectMemorySnapshot { .. } => "install_project_memory_snapshot",
Self::AttachmentPresent { .. } => "attachment_present",
Self::InstallAttachment { .. } => "install_attachment",
Self::ReadAttachment { .. } => "read_attachment",
Self::CredentialState => "credential_state",
Self::ReadCredentials => "read_credentials",
Self::InstallCredentials { .. } => "install_credentials",
Self::SkillsState => "skills_state",
Self::InstallSkills { .. } => "install_skills",
Self::GithubTokenState => "github_token_state",
Self::InstallGithubToken { .. } => "install_github_token",
Self::RemoveGithubToken => "remove_github_token",
Self::RespondElicitation { .. } => "respond_elicitation",
Self::StopBackgroundTask { .. } => "stop_background_task",
Self::SubagentRequests => "subagent_requests",
Self::CompleteSubagentRequest { .. } => "complete_subagent_request",
Self::Reviewer { request, .. } => request.action_name(),
}
}
pub fn minimum_protocol(&self) -> u32 {
match self {
Self::AttachmentPresent { .. }
| Self::InstallAttachment { .. }
| Self::ReadAttachment { .. } => 8,
Self::StopBackgroundTask { .. } => 9,
Self::SubagentRequests | Self::CompleteSubagentRequest { .. } => 12,
Self::RespondElicitation { .. } => 2,
Self::InstallPromptContext { .. } => 3,
Self::ProjectMemorySnapshot | Self::InstallProjectMemorySnapshot { .. } => 4,
Self::Submit { command, .. } => command.minimum_protocol(),
Self::Reviewer { .. } => 6,
_ => RELAY_MIN_PROTOCOL_VERSION,
}
}
pub fn supported_at(&self, protocol_version: u32) -> bool {
RelayVersionRange::CURRENT.contains(protocol_version)
&& protocol_version >= self.minimum_protocol()
}
}
pub fn incompatible_request_protocol(protocol_version: u32) -> RelayResponseBody {
relay_error(
RelayErrorCode::IncompatibleProtocol,
format!(
"request uses protocol {protocol_version}, relay supports protocol {}-{}",
RELAY_MIN_PROTOCOL_VERSION, RELAY_PROTOCOL_VERSION
),
false,
None,
)
}
pub fn incompatible_request_protocol_response(
request_id: String,
protocol_version: u32,
) -> RelayResponseEnvelope {
RelayResponseEnvelope {
request_id,
protocol_version,
body: incompatible_request_protocol(protocol_version),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RelayRequestEnvelope {
pub request_id: String,
pub protocol_version: u32,
pub request: RelayRequest,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RelayResponseEnvelope {
pub request_id: String,
pub protocol_version: u32,
#[serde(flatten)]
pub body: RelayResponseBody,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant)]
pub enum RelayResponseBody {
Ok { payload: RelayResponsePayload },
Error { error: RelayProtocolError },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum RelayResponsePayload {
Hello {
negotiated: u32,
relay_version: String,
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
worker_build: Option<String>,
},
Attached {
state: RelayOperationalState,
events: Vec<RelayEvent>,
through_ordinal: u64,
through_digest: String,
},
Acknowledged {
through_ordinal: u64,
through_digest: String,
},
Accepted {
command_id: String,
ordinal: u64,
},
Status(RelayOperationalState),
AttachmentPresent {
present: bool,
},
AttachmentInstalled,
AttachmentData {
data: String,
},
PromptContextInstalled,
ProjectMemorySnapshot {
baseline: ProjectMemorySnapshot,
replica: ProjectMemorySnapshot,
},
ProjectMemorySnapshotInstalled,
CredentialState {
present: bool,
fingerprint: String,
freshness_epoch_ms: Option<i64>,
},
Credentials {
data: String,
},
SkillsState {
present: bool,
fingerprint: String,
},
GithubTokenState {
present: bool,
fingerprint: String,
},
ElicitationResolved {
elicitation_id: String,
},
BackgroundTaskStopRequested {
background_task_id: String,
},
SubagentRequests {
requests: Vec<crate::subagent::SubagentToolRequest>,
results: Vec<crate::subagent::SubagentToolResult>,
},
SubagentRequestCompleted,
ReviewerStarted {
#[serde(default, skip_serializing_if = "Option::is_none")]
native_session_id: Option<String>,
config_options: Vec<agent_client_protocol::schema::v1::SessionConfigOption>,
reused: bool,
state: Box<RelayOperationalState>,
},
ReviewerPaused,
ReviewDelta {
repositories: Vec<RepoDelta>,
},
ReviewBaselineAdvanced,
ReviewChangedFunctions {
packet: String,
},
LaneDispatches {
requests: Vec<crate::review::lanes::ReviewSubagentRequest>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayProtocolError {
pub code: RelayErrorCode,
pub message: String,
pub retryable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<RelayErrorDetail>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelayErrorCode {
IncompatibleProtocol,
InvalidRequest,
InvalidState,
Desynchronized,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RelayErrorDetail {
Desynchronized {
requested_after: u64,
requested_digest: String,
earliest_available: u64,
earliest_digest: String,
latest: u64,
latest_digest: String,
},
}
pub fn relay_protocol_error(
code: RelayErrorCode,
message: impl Into<String>,
retryable: bool,
detail: Option<RelayErrorDetail>,
) -> RelayProtocolError {
RelayProtocolError {
code,
message: message.into(),
retryable,
detail,
}
}
pub fn relay_error(
code: RelayErrorCode,
message: impl Into<String>,
retryable: bool,
detail: Option<RelayErrorDetail>,
) -> RelayResponseBody {
RelayResponseBody::Error {
error: relay_protocol_error(code, message, retryable, detail),
}
}
pub fn unsupported_relay_method_response(
request_id: String,
protocol_version: u32,
method: String,
) -> RelayResponseEnvelope {
RelayResponseEnvelope {
request_id,
protocol_version,
body: relay_error(
RelayErrorCode::InvalidRequest,
format!("relay does not support method {method:?}"),
false,
None,
),
}
}
pub fn invalid_relay_request_response(
request_id: String,
protocol_version: u32,
message: String,
) -> RelayResponseEnvelope {
RelayResponseEnvelope {
request_id,
protocol_version,
body: relay_error(RelayErrorCode::InvalidRequest, message, false, None),
}
}
pub fn read_relay_frame(reader: &mut impl BufRead) -> Result<Option<RelayRequestEnvelope>> {
let mut bytes = Vec::new();
let (read, _) = read_bounded_line(reader, &mut bytes, MAX_FRAME_BYTES)
.context("read relay protocol frame")?;
if read == 0 {
return Ok(None);
}
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
if bytes.is_empty() {
bail!("empty relay protocol frame");
}
serde_json::from_slice(&bytes)
.context("parse relay protocol request")
.map(Some)
}
pub fn write_relay_frame(writer: &mut impl Write, response: &RelayResponseEnvelope) -> Result<()> {
serde_json::to_writer(&mut *writer, response)?;
writer.write_all(b"\n")?;
writer.flush()?;
Ok(())
}
pub fn read_bounded_line(
reader: &mut impl BufRead,
line: &mut Vec<u8>,
maximum_bytes: usize,
) -> Result<(usize, bool)> {
line.clear();
let mut consumed_total = 0_usize;
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok((consumed_total, false));
}
let newline = available.iter().position(|byte| *byte == b'\n');
let content_bytes = newline.unwrap_or(available.len());
let next_len = line
.len()
.checked_add(content_bytes)
.ok_or_else(|| anyhow!("relay journal line length overflow"))?;
super::snapshot::ensure_byte_budget(next_len, maximum_bytes, "relay journal event")?;
line.extend_from_slice(&available[..content_bytes]);
let consumed = content_bytes + usize::from(newline.is_some());
reader.consume(consumed);
consumed_total = consumed_total
.checked_add(consumed)
.ok_or_else(|| anyhow!("relay journal length overflow"))?;
if newline.is_some() {
return Ok((consumed_total, true));
}
}
}