1use std::io::{BufRead, Write};
7
8use anyhow::{Context, Result, anyhow, bail};
9use serde::{Deserialize, Serialize};
10
11use crate::elicitation::ElicitationResponse;
12use crate::project_memory::ProjectMemorySnapshot;
13
14use super::snapshot::{RelayCommand, RelayEvent, RelayOperationalState};
15use super::{MAX_FRAME_BYTES, RELAY_MIN_PROTOCOL_VERSION, RELAY_PROTOCOL_VERSION};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct RelayVersionRange {
20 pub min: u32,
21 pub max: u32,
22}
23
24impl RelayVersionRange {
25 pub const CURRENT: Self = Self {
26 min: RELAY_MIN_PROTOCOL_VERSION,
27 max: RELAY_PROTOCOL_VERSION,
28 };
29
30 pub const fn contains(self, version: u32) -> bool {
31 self.min <= version && version <= self.max
32 }
33
34 pub fn negotiate(self, peer: Self) -> Option<u32> {
35 let minimum = self.min.max(peer.min);
36 let maximum = self.max.min(peer.max);
37 (minimum <= maximum).then_some(maximum)
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(
45 tag = "method",
46 content = "params",
47 rename_all = "snake_case",
48 deny_unknown_fields
49)]
50pub enum RelayRequest {
51 Hello {
52 controller_version: String,
53 supported: RelayVersionRange,
54 },
55 Attach {
56 after_ordinal: u64,
57 after_digest: String,
58 },
59 Acknowledge {
60 through_ordinal: u64,
61 through_digest: String,
62 },
63 Submit {
64 command_id: String,
65 command: RelayCommand,
66 },
67 Status,
68 AttachmentPresent {
69 reference: crate::attachment::AttachmentRef,
70 },
71 InstallAttachment {
72 reference: crate::attachment::AttachmentRef,
73 data: String,
74 },
75 ReadAttachment {
76 reference: crate::attachment::AttachmentRef,
77 },
78 InstallPromptContext {
82 text: String,
83 },
84 ProjectMemorySnapshot,
87 InstallProjectMemorySnapshot {
90 snapshot: ProjectMemorySnapshot,
91 },
92 CredentialState,
96 ReadCredentials,
99 InstallCredentials {
102 data: String,
103 },
104 SkillsState,
108 InstallSkills {
112 data: String,
113 },
114 GithubTokenState,
117 InstallGithubToken {
120 data: String,
121 },
122 RemoveGithubToken,
124 RespondElicitation {
126 elicitation_id: String,
127 response: ElicitationResponse,
128 },
129 StopBackgroundTask {
132 background_task_id: String,
133 },
134 SubagentRequests,
137 CompleteSubagentRequest {
140 result: crate::subagent::SubagentToolResult,
141 },
142 Reviewer {
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 role: Option<String>,
157 request: ReviewerRequest,
158 },
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(tag = "action", content = "params", rename_all = "snake_case")]
164pub enum ReviewerRequest {
165 Start {
168 config: Box<crate::worker_launch::ReviewerLaunchConfig>,
169 },
170 Attach {
173 after_ordinal: u64,
174 after_digest: String,
175 },
176 Acknowledge {
177 through_ordinal: u64,
178 through_digest: String,
179 },
180 Submit {
181 command_id: String,
182 command: RelayCommand,
183 },
184 Status,
185 RespondElicitation {
191 elicitation_id: String,
192 response: ElicitationResponse,
193 },
194 Pause,
197 CaptureDelta {
206 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
207 },
208 AdvanceBaseline {
211 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
212 },
213 AnalyzeDelta {
216 repositories: Vec<AnalyzeDeltaRepository>,
217 },
218 TakeLaneDispatches,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct AnalyzeDeltaRepository {
228 pub root: std::path::PathBuf,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub baseline_tree: Option<String>,
233 pub current_tree: String,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct RepoDelta {
240 pub root: std::path::PathBuf,
241 pub baseline_tree: Option<String>,
242 pub current_tree: String,
243 pub patch: String,
246 pub diffstat: String,
249 pub changed_lines: usize,
250}
251
252impl ReviewerRequest {
253 pub const fn action_name(&self) -> &'static str {
254 match self {
255 Self::Start { .. } => "reviewer_start",
256 Self::Attach { .. } => "reviewer_attach",
257 Self::Acknowledge { .. } => "reviewer_acknowledge",
258 Self::Submit { .. } => "reviewer_submit",
259 Self::Status => "reviewer_status",
260 Self::RespondElicitation { .. } => "reviewer_respond_elicitation",
261 Self::Pause => "reviewer_pause",
262 Self::CaptureDelta { .. } => "reviewer_capture_delta",
263 Self::AdvanceBaseline { .. } => "reviewer_advance_baseline",
264 Self::AnalyzeDelta { .. } => "reviewer_analyze_delta",
265 Self::TakeLaneDispatches => "reviewer_take_lane_dispatches",
266 }
267 }
268}
269
270impl RelayRequest {
271 pub const fn method_name(&self) -> &'static str {
272 match self {
273 Self::Hello { .. } => "hello",
274 Self::Attach { .. } => "attach",
275 Self::Acknowledge { .. } => "acknowledge",
276 Self::Submit { .. } => "submit",
277 Self::Status => "status",
278 Self::InstallPromptContext { .. } => "install_prompt_context",
279 Self::ProjectMemorySnapshot => "project_memory_snapshot",
280 Self::InstallProjectMemorySnapshot { .. } => "install_project_memory_snapshot",
281 Self::AttachmentPresent { .. } => "attachment_present",
282 Self::InstallAttachment { .. } => "install_attachment",
283 Self::ReadAttachment { .. } => "read_attachment",
284 Self::CredentialState => "credential_state",
285 Self::ReadCredentials => "read_credentials",
286 Self::InstallCredentials { .. } => "install_credentials",
287 Self::SkillsState => "skills_state",
288 Self::InstallSkills { .. } => "install_skills",
289 Self::GithubTokenState => "github_token_state",
290 Self::InstallGithubToken { .. } => "install_github_token",
291 Self::RemoveGithubToken => "remove_github_token",
292 Self::RespondElicitation { .. } => "respond_elicitation",
293 Self::StopBackgroundTask { .. } => "stop_background_task",
294 Self::SubagentRequests => "subagent_requests",
295 Self::CompleteSubagentRequest { .. } => "complete_subagent_request",
296 Self::Reviewer { request, .. } => request.action_name(),
297 }
298 }
299
300 pub fn minimum_protocol(&self) -> u32 {
305 match self {
306 Self::AttachmentPresent { .. }
307 | Self::InstallAttachment { .. }
308 | Self::ReadAttachment { .. } => 8,
309 Self::StopBackgroundTask { .. } => 9,
310 Self::SubagentRequests | Self::CompleteSubagentRequest { .. } => 12,
311 Self::RespondElicitation { .. } => 2,
312 Self::InstallPromptContext { .. } => 3,
313 Self::ProjectMemorySnapshot | Self::InstallProjectMemorySnapshot { .. } => 4,
314 Self::Submit { command, .. } => command.minimum_protocol(),
315 Self::Reviewer { .. } => 6,
316 _ => RELAY_MIN_PROTOCOL_VERSION,
317 }
318 }
319
320 pub fn supported_at(&self, protocol_version: u32) -> bool {
321 RelayVersionRange::CURRENT.contains(protocol_version)
322 && protocol_version >= self.minimum_protocol()
323 }
324}
325
326pub fn incompatible_request_protocol(protocol_version: u32) -> RelayResponseBody {
327 relay_error(
328 RelayErrorCode::IncompatibleProtocol,
329 format!(
330 "request uses protocol {protocol_version}, relay supports protocol {}-{}",
331 RELAY_MIN_PROTOCOL_VERSION, RELAY_PROTOCOL_VERSION
332 ),
333 false,
334 None,
335 )
336}
337
338pub fn incompatible_request_protocol_response(
339 request_id: String,
340 protocol_version: u32,
341) -> RelayResponseEnvelope {
342 RelayResponseEnvelope {
343 request_id,
344 protocol_version,
345 body: incompatible_request_protocol(protocol_version),
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct RelayRequestEnvelope {
352 pub request_id: String,
353 pub protocol_version: u32,
354 pub request: RelayRequest,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct RelayResponseEnvelope {
359 pub request_id: String,
360 pub protocol_version: u32,
361 #[serde(flatten)]
362 pub body: RelayResponseBody,
363}
364
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
366#[serde(tag = "result", rename_all = "snake_case")]
367#[allow(clippy::large_enum_variant)]
370pub enum RelayResponseBody {
371 Ok { payload: RelayResponsePayload },
372 Error { error: RelayProtocolError },
373}
374
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376#[serde(tag = "type", content = "data", rename_all = "snake_case")]
377pub enum RelayResponsePayload {
378 Hello {
379 negotiated: u32,
380 relay_version: String,
381 session_id: String,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
387 worker_build: Option<String>,
388 },
389 Attached {
390 state: RelayOperationalState,
391 events: Vec<RelayEvent>,
392 through_ordinal: u64,
393 through_digest: String,
394 },
395 Acknowledged {
396 through_ordinal: u64,
397 through_digest: String,
398 },
399 Accepted {
400 command_id: String,
401 ordinal: u64,
402 },
403 Status(RelayOperationalState),
404 AttachmentPresent {
405 present: bool,
406 },
407 AttachmentInstalled,
408 AttachmentData {
409 data: String,
410 },
411 PromptContextInstalled,
412 ProjectMemorySnapshot {
413 baseline: ProjectMemorySnapshot,
414 replica: ProjectMemorySnapshot,
415 },
416 ProjectMemorySnapshotInstalled,
417 CredentialState {
420 present: bool,
421 fingerprint: String,
422 freshness_epoch_ms: Option<i64>,
423 },
424 Credentials {
427 data: String,
428 },
429 SkillsState {
431 present: bool,
432 fingerprint: String,
433 },
434 GithubTokenState {
436 present: bool,
437 fingerprint: String,
438 },
439 ElicitationResolved {
440 elicitation_id: String,
441 },
442 BackgroundTaskStopRequested {
443 background_task_id: String,
444 },
445 SubagentRequests {
446 requests: Vec<crate::subagent::SubagentToolRequest>,
447 results: Vec<crate::subagent::SubagentToolResult>,
448 },
449 SubagentRequestCompleted,
450 ReviewerStarted {
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 native_session_id: Option<String>,
454 config_options: Vec<agent_client_protocol::schema::v1::SessionConfigOption>,
457 reused: bool,
459 state: Box<RelayOperationalState>,
460 },
461 ReviewerPaused,
463 ReviewDelta {
465 repositories: Vec<RepoDelta>,
466 },
467 ReviewBaselineAdvanced,
469 ReviewChangedFunctions {
471 packet: String,
472 },
473 LaneDispatches {
475 requests: Vec<crate::review::lanes::ReviewSubagentRequest>,
476 },
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct RelayProtocolError {
481 pub code: RelayErrorCode,
482 pub message: String,
483 pub retryable: bool,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub detail: Option<RelayErrorDetail>,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490pub enum RelayErrorCode {
491 IncompatibleProtocol,
492 InvalidRequest,
493 InvalidState,
494 Desynchronized,
495 Internal,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499#[serde(tag = "kind", rename_all = "snake_case")]
500pub enum RelayErrorDetail {
501 Desynchronized {
502 requested_after: u64,
503 requested_digest: String,
504 earliest_available: u64,
505 earliest_digest: String,
506 latest: u64,
507 latest_digest: String,
508 },
509}
510
511pub fn relay_protocol_error(
512 code: RelayErrorCode,
513 message: impl Into<String>,
514 retryable: bool,
515 detail: Option<RelayErrorDetail>,
516) -> RelayProtocolError {
517 RelayProtocolError {
518 code,
519 message: message.into(),
520 retryable,
521 detail,
522 }
523}
524
525pub fn relay_error(
526 code: RelayErrorCode,
527 message: impl Into<String>,
528 retryable: bool,
529 detail: Option<RelayErrorDetail>,
530) -> RelayResponseBody {
531 RelayResponseBody::Error {
532 error: relay_protocol_error(code, message, retryable, detail),
533 }
534}
535
536pub fn unsupported_relay_method_response(
537 request_id: String,
538 protocol_version: u32,
539 method: String,
540) -> RelayResponseEnvelope {
541 RelayResponseEnvelope {
542 request_id,
543 protocol_version,
544 body: relay_error(
545 RelayErrorCode::InvalidRequest,
546 format!("relay does not support method {method:?}"),
547 false,
548 None,
549 ),
550 }
551}
552
553pub fn invalid_relay_request_response(
554 request_id: String,
555 protocol_version: u32,
556 message: String,
557) -> RelayResponseEnvelope {
558 RelayResponseEnvelope {
559 request_id,
560 protocol_version,
561 body: relay_error(RelayErrorCode::InvalidRequest, message, false, None),
562 }
563}
564
565pub fn read_relay_frame(reader: &mut impl BufRead) -> Result<Option<RelayRequestEnvelope>> {
566 let mut bytes = Vec::new();
567 let (read, _) = read_bounded_line(reader, &mut bytes, MAX_FRAME_BYTES)
568 .context("read relay protocol frame")?;
569 if read == 0 {
570 return Ok(None);
571 }
572 if bytes.last() == Some(&b'\r') {
573 bytes.pop();
574 }
575 if bytes.is_empty() {
576 bail!("empty relay protocol frame");
577 }
578 serde_json::from_slice(&bytes)
579 .context("parse relay protocol request")
580 .map(Some)
581}
582
583pub fn write_relay_frame(writer: &mut impl Write, response: &RelayResponseEnvelope) -> Result<()> {
584 serde_json::to_writer(&mut *writer, response)?;
585 writer.write_all(b"\n")?;
586 writer.flush()?;
587 Ok(())
588}
589
590pub fn read_bounded_line(
591 reader: &mut impl BufRead,
592 line: &mut Vec<u8>,
593 maximum_bytes: usize,
594) -> Result<(usize, bool)> {
595 line.clear();
596 let mut consumed_total = 0_usize;
597 loop {
598 let available = reader.fill_buf()?;
599 if available.is_empty() {
600 return Ok((consumed_total, false));
601 }
602 let newline = available.iter().position(|byte| *byte == b'\n');
603 let content_bytes = newline.unwrap_or(available.len());
604 let next_len = line
605 .len()
606 .checked_add(content_bytes)
607 .ok_or_else(|| anyhow!("relay journal line length overflow"))?;
608 super::snapshot::ensure_byte_budget(next_len, maximum_bytes, "relay journal event")?;
609 line.extend_from_slice(&available[..content_bytes]);
610 let consumed = content_bytes + usize::from(newline.is_some());
611 reader.consume(consumed);
612 consumed_total = consumed_total
613 .checked_add(consumed)
614 .ok_or_else(|| anyhow!("relay journal length overflow"))?;
615 if newline.is_some() {
616 return Ok((consumed_total, true));
617 }
618 }
619}