1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::path::Path;
5use std::process::Stdio;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, anyhow, bail};
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::process::{Child, ChildStdin, ChildStdout, Command};
14use tokio::sync::{mpsc, watch};
15
16use hel::hel_config::harness_authentication_marker;
17use hel::hel_credentials::{
18 CredentialSnapshot, CredentialSyncAction, CredentialSyncHandle, CredentialSyncOutcome,
19 CredentialSyncResult, CredentialSyncTarget, SYNC_INTERVAL, SyncAction, SyncTrigger, enqueue,
20 profiles_with_targets, read_credential_file, reconcile, validate_credential_payload,
21 write_credential_file,
22};
23use hel::hel_elicitation::ElicitationResponse;
24use hel::hel_targets::CommandSpec;
25use hel::hel_worker::{
26 MAX_FRAME_BYTES, RELAY_EVENT_GENESIS_DIGEST, RELAY_MIN_PROTOCOL_VERSION,
27 RELAY_PROTOCOL_VERSION, RelayCommand, RelayCursor, RelayErrorCode, RelayEvent,
28 RelayOperationalState, RelayProtocolError, RelayRequest, RelayRequestEnvelope,
29 RelayResponseBody, RelayResponseEnvelope, RelayResponsePayload, RelayVersionRange,
30 ReviewerRequest, validate_relay_event,
31};
32use hel::hel_worker_launch::ReviewerLaunchConfig;
33pub use mj_client::session::{RelayAttachment, StartedReviewer};
34
35const RELAY_RPC_TIMEOUT: Duration = Duration::from_secs(15);
36const RELAY_SLOW_OPERATION_WARNING: Duration = Duration::from_secs(5);
37const RELAY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(300);
41const RELAY_HISTORY_TIMEOUT: Duration = Duration::from_secs(900);
45const RELAY_ACKNOWLEDGE_TIMEOUT: Duration = Duration::from_secs(300);
49const REVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300);
52const REVIEW_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(660);
56const RELAY_PROXY_DETACH_GRACE: Duration = Duration::from_millis(500);
57const RELAY_PROXY_REAP_POLL: Duration = Duration::from_millis(10);
58
59async fn drain_proxy_stderr(
63 errors: tokio::process::ChildStderr,
64 purpose: String,
65 session_id: String,
66) {
67 let mut lines = BufReader::new(errors).lines();
68 loop {
69 match lines.next_line().await {
70 Ok(Some(line)) if line.trim().is_empty() => continue,
71 Ok(Some(line)) => {
72 tracing::warn!(%session_id, %purpose, %line, "relay proxy stderr")
73 }
74 Ok(None) => return,
75 Err(error) => {
76 tracing::warn!(%session_id, %purpose, %error, "read relay proxy stderr");
77 return;
78 }
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
87pub struct RelayEventPage {
88 pub events: Vec<RelayEvent>,
89 pub through_ordinal: u64,
90 pub through_digest: String,
91}
92
93#[derive(Debug, Clone)]
94pub struct RelayCatchUp {
95 pub state: RelayOperationalState,
96 pub frontier: RelayCursor,
97 pub first_page: RelayEventPage,
98}
99
100#[derive(Debug)]
101pub struct RelayRejected(pub RelayProtocolError);
102
103impl std::fmt::Display for RelayRejected {
104 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 write!(
106 formatter,
107 "relay rejected request ({:?}): {}",
108 self.0.code, self.0.message
109 )
110 }
111}
112
113impl std::error::Error for RelayRejected {}
114
115impl RelayRejected {
116 pub fn is_desynchronized(&self) -> bool {
117 self.0.code == RelayErrorCode::Desynchronized
118 }
119
120 pub fn is_retryable(&self) -> bool {
123 self.0.retryable
124 }
125}
126
127#[derive(Debug)]
134pub struct RelayTransportDead {
135 message: String,
136 handshake_failed: bool,
137}
138
139impl std::fmt::Display for RelayTransportDead {
140 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 formatter.write_str(&self.message)
142 }
143}
144
145impl std::error::Error for RelayTransportDead {}
146
147impl RelayTransportDead {
148 pub fn new(message: impl Into<String>) -> Self {
149 Self {
150 message: message.into(),
151 handshake_failed: false,
152 }
153 }
154
155 fn from_io(error: std::io::Error, kind: ExchangeKind) -> Self {
158 Self::during_exchange(error.to_string(), kind)
159 }
160
161 fn during_exchange(message: impl Into<String>, kind: ExchangeKind) -> Self {
162 Self {
163 message: message.into(),
164 handshake_failed: kind == ExchangeKind::Handshake,
165 }
166 }
167
168 pub fn marks(error: &anyhow::Error) -> bool {
170 error.downcast_ref::<Self>().is_some()
171 }
172
173 pub fn marks_failed_handshake(error: &anyhow::Error) -> bool {
178 error
179 .downcast_ref::<Self>()
180 .is_some_and(|failure| failure.handshake_failed)
181 }
182}
183
184#[derive(Clone, Copy, PartialEq, Eq)]
191enum ExchangeKind {
192 Handshake,
193 Call,
194}
195
196pub struct RelayClient {
202 child: Option<Child>,
203 input: Option<ChildStdin>,
204 output: BufReader<ChildStdout>,
205 request_timeout: Duration,
206 abandoned: Option<String>,
209 next_request: u64,
210 connection_nonce: u64,
211 protocol_version: u32,
212 session_id: String,
213 relay_version: String,
214 worker_build: Option<String>,
217 latest_ordinal: u64,
218 latest_digest: String,
219}
220
221impl RelayClient {
222 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
223 Self::connect_with_timeouts(
224 spec,
225 expected_session_id,
226 RELAY_RPC_TIMEOUT,
227 RELAY_HANDSHAKE_TIMEOUT,
228 )
229 .await
230 }
231
232 #[cfg(all(test, unix))]
233 async fn connect_with_timeout(
234 spec: &CommandSpec,
235 expected_session_id: &str,
236 request_timeout: Duration,
237 ) -> Result<Self> {
238 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
239 .await
240 }
241
242 async fn connect_with_timeouts(
243 spec: &CommandSpec,
244 expected_session_id: &str,
245 request_timeout: Duration,
246 handshake_timeout: Duration,
247 ) -> Result<Self> {
248 let mut child = Command::new(&spec.program)
249 .args(&spec.args)
250 .envs(&spec.env)
251 .stdin(Stdio::piped())
252 .stdout(Stdio::piped())
253 .stderr(Stdio::piped())
257 .kill_on_drop(true)
258 .spawn()
259 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
260 .map_err(|error| {
261 tracing::warn!(
262 session_id = %expected_session_id,
263 operation = "connect",
264 purpose = %spec.purpose,
265 error = %error,
266 "could not start relay proxy"
267 );
268 error
269 })?;
270 if let Some(errors) = child.stderr.take() {
271 let purpose = spec.purpose.clone();
272 let session_id = expected_session_id.to_owned();
273 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id));
274 }
275 let input = child
276 .stdin
277 .take()
278 .context("relay proxy stdin unavailable")
279 .map_err(|error| {
280 tracing::warn!(
281 session_id = %expected_session_id,
282 operation = "connect",
283 purpose = %spec.purpose,
284 error = %error,
285 "relay proxy did not provide stdin"
286 );
287 error
288 })?;
289 let output = child
290 .stdout
291 .take()
292 .context("relay proxy stdout unavailable")
293 .map_err(|error| {
294 tracing::warn!(
295 session_id = %expected_session_id,
296 operation = "connect",
297 purpose = %spec.purpose,
298 error = %error,
299 "relay proxy did not provide stdout"
300 );
301 error
302 })?;
303 let mut nonce_bytes = [0_u8; 8];
304 getrandom::fill(&mut nonce_bytes).map_err(|error| {
305 let error = anyhow!("generate relay request nonce: {error}");
306 tracing::warn!(
307 session_id = %expected_session_id,
308 operation = "connect",
309 error = %error,
310 "could not initialize relay request nonce"
311 );
312 error
313 })?;
314 let mut client = Self {
315 child: Some(child),
316 input: Some(input),
317 output: BufReader::new(output),
318 request_timeout,
319 abandoned: None,
320 next_request: 1,
321 connection_nonce: u64::from_le_bytes(nonce_bytes),
322 protocol_version: RELAY_PROTOCOL_VERSION,
323 session_id: expected_session_id.to_owned(),
327 relay_version: String::new(),
328 worker_build: None,
329 latest_ordinal: 0,
330 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
331 };
332 let response = client
333 .call_hello(
334 RelayRequest::Hello {
335 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
336 supported: RelayVersionRange::CURRENT,
337 },
338 handshake_timeout,
339 )
340 .await?;
341 let RelayResponsePayload::Hello {
342 negotiated,
343 relay_version,
344 session_id,
345 worker_build,
346 } = response
347 else {
348 let error = anyhow!("relay returned an unexpected hello response");
349 log_relay_client_failure(&client, "hello", "relay-hello", &error);
350 return Err(error);
351 };
352 if session_id != expected_session_id {
353 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
354 log_relay_client_failure(&client, "hello", "relay-hello", &error);
355 return Err(error);
356 }
357 if !RelayVersionRange::CURRENT.contains(negotiated) {
358 let error = anyhow!(
359 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
360 RELAY_MIN_PROTOCOL_VERSION,
361 RELAY_PROTOCOL_VERSION
362 );
363 log_relay_client_failure(&client, "hello", "relay-hello", &error);
364 return Err(error);
365 }
366 client.protocol_version = negotiated;
367 client.session_id = session_id;
368 client.relay_version = relay_version;
369 client.worker_build = worker_build;
370 Ok(client)
371 }
372
373 pub fn session_id(&self) -> &str {
374 &self.session_id
375 }
376
377 pub const fn supports_project_memory_sync(&self) -> bool {
378 self.protocol_version >= 4
379 }
380
381 pub fn relay_version(&self) -> &str {
382 &self.relay_version
383 }
384
385 pub fn worker_build(&self) -> Option<&str> {
389 self.worker_build.as_deref()
390 }
391
392 pub fn protocol_version(&self) -> u32 {
393 self.protocol_version
394 }
395
396 pub fn latest_ordinal(&self) -> u64 {
397 self.latest_ordinal
398 }
399
400 pub fn latest_digest(&self) -> &str {
401 &self.latest_digest
402 }
403
404 pub async fn attach(
405 &mut self,
406 after_ordinal: u64,
407 after_digest: impl Into<String>,
408 ) -> Result<RelayAttachment> {
409 let after_digest = after_digest.into();
410 match self
411 .call_with_timeout(
412 RelayRequest::Attach {
413 after_ordinal,
414 after_digest: after_digest.clone(),
415 },
416 RELAY_HISTORY_TIMEOUT,
417 )
418 .await?
419 {
420 RelayResponsePayload::Attached {
421 state,
422 events,
423 through_ordinal,
424 through_digest,
425 } => {
426 let mut cursor = RelayCursor {
427 ordinal: after_ordinal,
428 digest: after_digest,
429 };
430 for event in &events {
431 validate_relay_event(cursor.ordinal, &cursor.digest, event)
432 .context("verify relay attachment event chain")?;
433 cursor.ordinal = event.ordinal;
434 cursor.digest.clone_from(&event.digest);
435 }
436 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
437 bail!("relay attachment frontier does not match its event chain");
438 }
439 self.latest_ordinal = state.latest_ordinal;
440 self.latest_digest = state.latest_digest.clone();
441 Ok(RelayAttachment {
442 state,
443 events,
444 through_ordinal,
445 through_digest,
446 })
447 }
448 _ => bail!("relay returned an unexpected attach response"),
449 }
450 }
451
452 pub async fn begin_catch_up(
457 &mut self,
458 after_ordinal: u64,
459 after_digest: impl Into<String>,
460 ) -> Result<RelayCatchUp> {
461 let after_digest = after_digest.into();
462 let first = self.attach(after_ordinal, after_digest.clone()).await?;
463 let frontier = RelayCursor {
464 ordinal: first.state.latest_ordinal,
465 digest: first.state.latest_digest.clone(),
466 };
467 let previous = RelayCursor {
468 ordinal: after_ordinal,
469 digest: after_digest,
470 };
471 let state = first.state.clone();
472 let first_page = clip_catch_up_page(first, &previous, &frontier)?;
473 Ok(RelayCatchUp {
474 state,
475 frontier,
476 first_page,
477 })
478 }
479
480 pub async fn next_catch_up_page(
484 &mut self,
485 previous: &RelayCursor,
486 frontier: &RelayCursor,
487 ) -> Result<RelayEventPage> {
488 if previous.ordinal >= frontier.ordinal {
489 bail!("relay catch-up is already at its fixed frontier");
490 }
491 let attachment = self
492 .attach(previous.ordinal, previous.digest.clone())
493 .await?;
494 clip_catch_up_page(attachment, previous, frontier)
495 }
496
497 pub async fn acknowledge(
498 &mut self,
499 through_ordinal: u64,
500 through_digest: impl Into<String>,
501 ) -> Result<RelayCursor> {
502 match self
503 .call_with_timeout(
504 RelayRequest::Acknowledge {
505 through_ordinal,
506 through_digest: through_digest.into(),
507 },
508 RELAY_ACKNOWLEDGE_TIMEOUT,
509 )
510 .await?
511 {
512 RelayResponsePayload::Acknowledged {
513 through_ordinal,
514 through_digest,
515 } => Ok(RelayCursor {
516 ordinal: through_ordinal,
517 digest: through_digest,
518 }),
519 _ => bail!("relay returned an unexpected acknowledgement response"),
520 }
521 }
522
523 pub async fn status(&mut self) -> Result<RelayOperationalState> {
524 match self.call(RelayRequest::Status).await? {
525 RelayResponsePayload::Status(status) => {
526 self.latest_ordinal = status.latest_ordinal;
527 self.latest_digest = status.latest_digest.clone();
528 Ok(status)
529 }
530 _ => bail!("relay returned an unexpected status response"),
531 }
532 }
533
534 pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
537 credential_snapshot(self.call(RelayRequest::CredentialState).await?)
538 }
539
540 pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
543 match self.call(RelayRequest::ReadCredentials).await? {
544 RelayResponsePayload::Credentials { data } => BASE64
545 .decode(data.as_bytes())
546 .context("decode relay credential payload"),
547 _ => bail!("relay returned an unexpected credential response"),
548 }
549 }
550
551 pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
554 credential_snapshot(
555 self.call(RelayRequest::InstallCredentials {
556 data: BASE64.encode(bytes),
557 })
558 .await?,
559 )
560 }
561
562 pub async fn github_token_state(
563 &mut self,
564 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
565 github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
566 }
567
568 pub async fn install_github_token(
569 &mut self,
570 token: &str,
571 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
572 github_token_snapshot(
573 self.call(RelayRequest::InstallGithubToken {
574 data: BASE64.encode(token.as_bytes()),
575 })
576 .await?,
577 )
578 }
579
580 pub async fn remove_github_token(
581 &mut self,
582 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
583 github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
584 }
585
586 pub async fn skills_state(&mut self) -> Result<hel::hel_skills::SkillsSyncState> {
589 skills_sync_state(self.call(RelayRequest::SkillsState).await?)
590 }
591
592 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
595 let request = RelayRequest::InstallPromptContext { text };
596 if !request.supported_at(self.protocol_version) {
597 bail!(
598 "hidden prompt context requires relay protocol {}; this session negotiated {}",
599 request.minimum_protocol(),
600 self.protocol_version
601 );
602 }
603 match self.call(request).await? {
604 RelayResponsePayload::PromptContextInstalled => Ok(()),
605 _ => bail!("relay returned an unexpected prompt-context response"),
606 }
607 }
608
609 pub async fn project_memory_snapshot(
610 &mut self,
611 ) -> Result<(
612 hel::hel_project_memory::ProjectMemorySnapshot,
613 hel::hel_project_memory::ProjectMemorySnapshot,
614 )> {
615 let request = RelayRequest::ProjectMemorySnapshot;
616 if !request.supported_at(self.protocol_version) {
617 bail!(
618 "project memory synchronization requires relay protocol {}; this session negotiated {}",
619 request.minimum_protocol(),
620 self.protocol_version
621 );
622 }
623 match self.call(request).await? {
624 RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
625 Ok((baseline, replica))
626 }
627 _ => bail!("relay returned an unexpected project-memory response"),
628 }
629 }
630
631 pub async fn install_project_memory_snapshot(
632 &mut self,
633 snapshot: hel::hel_project_memory::ProjectMemorySnapshot,
634 ) -> Result<()> {
635 let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
636 if !request.supported_at(self.protocol_version) {
637 bail!(
638 "project memory synchronization requires relay protocol {}; this session negotiated {}",
639 request.minimum_protocol(),
640 self.protocol_version
641 );
642 }
643 match self.call(request).await? {
644 RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
645 _ => bail!("relay returned an unexpected project-memory install response"),
646 }
647 }
648
649 pub async fn install_skills(
653 &mut self,
654 archive_bytes: &[u8],
655 ) -> Result<hel::hel_skills::SkillsSyncState> {
656 skills_sync_state(
657 self.call(RelayRequest::InstallSkills {
658 data: BASE64.encode(archive_bytes),
659 })
660 .await?,
661 )
662 }
663
664 pub async fn ensure_attachment(
666 &mut self,
667 reference: &hel::hel_attachment::AttachmentRef,
668 ) -> Result<()> {
669 anyhow::ensure!(
670 self.protocol_version >= 8,
671 "photo attachments require an updated worker (protocol 8); upgrade the worker and retry"
672 );
673 match self
674 .call(RelayRequest::AttachmentPresent {
675 reference: reference.clone(),
676 })
677 .await?
678 {
679 RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
680 RelayResponsePayload::AttachmentPresent { present: false } => {}
681 _ => bail!("unexpected image presence response"),
682 }
683 let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
684 let reference_copy = reference.clone();
685 let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
686 .await
687 .context("image loading task failed")??;
688 match self
689 .call(RelayRequest::InstallAttachment {
690 reference: reference.clone(),
691 data: BASE64.encode(bytes),
692 })
693 .await?
694 {
695 RelayResponsePayload::AttachmentInstalled => Ok(()),
696 _ => bail!("unexpected image upload response"),
697 }
698 }
699
700 pub async fn cache_attachment(
702 &mut self,
703 reference: &hel::hel_attachment::AttachmentRef,
704 ) -> Result<()> {
705 let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
706 let local = store.clone();
707 let reference_copy = reference.clone();
708 if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
709 .await
710 .context("image lookup task failed")??
711 {
712 return Ok(());
713 }
714 let RelayResponsePayload::AttachmentData { data } = self
715 .call(RelayRequest::ReadAttachment {
716 reference: reference.clone(),
717 })
718 .await?
719 else {
720 bail!("unexpected image download response")
721 };
722 let reference = reference.clone();
723 tokio::task::spawn_blocking(move || {
724 anyhow::ensure!(
725 data.len() <= hel::hel_attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
726 "image download is too large"
727 );
728 store.install(&reference, &BASE64.decode(data)?)
729 })
730 .await
731 .context("image caching task failed")?
732 }
733
734 pub async fn submit(
735 &mut self,
736 command_id: impl Into<String>,
737 command: RelayCommand,
738 ) -> Result<u64> {
739 let command_id = command_id.into();
740 if let RelayCommand::Prompt { prompt } = &command {
741 for reference in hel::hel_attachment::references(prompt)? {
742 self.ensure_attachment(&reference).await?;
743 }
744 }
745 match self
746 .call(RelayRequest::Submit {
747 command_id: command_id.clone(),
748 command,
749 })
750 .await?
751 {
752 RelayResponsePayload::Accepted {
753 command_id: accepted_id,
754 ordinal,
755 } if accepted_id == command_id => Ok(ordinal),
756 RelayResponsePayload::Accepted {
757 command_id: accepted_id,
758 ..
759 } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
760 _ => bail!("relay returned an unexpected command response"),
761 }
762 }
763
764 pub async fn start_reviewer(
771 &mut self,
772 role: Option<&str>,
773 config: ReviewerLaunchConfig,
774 ) -> Result<StartedReviewer> {
775 let request = self.reviewer_request(
776 role,
777 ReviewerRequest::Start {
778 config: Box::new(config),
779 },
780 )?;
781 match self
782 .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
783 .await?
784 {
785 RelayResponsePayload::ReviewerStarted {
786 native_session_id,
787 config_options,
788 reused,
789 state,
790 } => Ok(StartedReviewer {
791 native_session_id,
792 config_options,
793 reused,
794 state: *state,
795 }),
796 _ => bail!("relay returned an unexpected reviewer start response"),
797 }
798 }
799
800 pub async fn attach_reviewer(
803 &mut self,
804 role: Option<&str>,
805 after_ordinal: u64,
806 after_digest: impl Into<String>,
807 ) -> Result<RelayAttachment> {
808 let after_digest = after_digest.into();
809 let request = self.reviewer_request(
810 role,
811 ReviewerRequest::Attach {
812 after_ordinal,
813 after_digest: after_digest.clone(),
814 },
815 )?;
816 let payload = self
817 .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
818 .await?;
819 let RelayResponsePayload::Attached {
820 state,
821 events,
822 through_ordinal,
823 through_digest,
824 } = payload
825 else {
826 bail!("relay returned an unexpected reviewer attach response");
827 };
828 let mut cursor = RelayCursor {
831 ordinal: after_ordinal,
832 digest: after_digest,
833 };
834 for event in &events {
835 validate_relay_event(cursor.ordinal, &cursor.digest, event)
836 .context("verify reviewer attachment event chain")?;
837 cursor.ordinal = event.ordinal;
838 cursor.digest.clone_from(&event.digest);
839 }
840 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
841 bail!("reviewer attachment frontier does not match its event chain");
842 }
843 Ok(RelayAttachment {
844 state,
845 events,
846 through_ordinal,
847 through_digest,
848 })
849 }
850
851 pub async fn acknowledge_reviewer(
854 &mut self,
855 role: Option<&str>,
856 through_ordinal: u64,
857 through_digest: impl Into<String>,
858 ) -> Result<RelayCursor> {
859 let request = self.reviewer_request(
860 role,
861 ReviewerRequest::Acknowledge {
862 through_ordinal,
863 through_digest: through_digest.into(),
864 },
865 )?;
866 match self
867 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
868 .await?
869 {
870 RelayResponsePayload::Acknowledged {
871 through_ordinal,
872 through_digest,
873 } => Ok(RelayCursor {
874 ordinal: through_ordinal,
875 digest: through_digest,
876 }),
877 _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
878 }
879 }
880
881 pub async fn submit_to_reviewer(
883 &mut self,
884 role: Option<&str>,
885 command_id: impl Into<String>,
886 command: RelayCommand,
887 ) -> Result<u64> {
888 let command_id = command_id.into();
889 let request = self.reviewer_request(
890 role,
891 ReviewerRequest::Submit {
892 command_id: command_id.clone(),
893 command,
894 },
895 )?;
896 match self.call(request).await? {
897 RelayResponsePayload::Accepted {
898 command_id: accepted_id,
899 ordinal,
900 } if accepted_id == command_id => Ok(ordinal),
901 RelayResponsePayload::Accepted {
902 command_id: accepted_id,
903 ..
904 } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
905 _ => bail!("relay returned an unexpected reviewer command response"),
906 }
907 }
908
909 pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
910 let request = self.reviewer_request(role, ReviewerRequest::Status)?;
911 match self.call(request).await? {
912 RelayResponsePayload::Status(status) => Ok(status),
913 _ => bail!("relay returned an unexpected reviewer status response"),
914 }
915 }
916
917 pub async fn respond_to_reviewer(
919 &mut self,
920 role: Option<&str>,
921 elicitation_id: String,
922 response: ElicitationResponse,
923 ) -> Result<()> {
924 let request = self.reviewer_request(
925 role,
926 ReviewerRequest::RespondElicitation {
927 elicitation_id: elicitation_id.clone(),
928 response,
929 },
930 )?;
931 match self.call(request).await? {
932 RelayResponsePayload::ElicitationResolved {
933 elicitation_id: resolved,
934 } if resolved == elicitation_id => Ok(()),
935 RelayResponsePayload::ElicitationResolved {
936 elicitation_id: resolved,
937 } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
938 _ => bail!("relay returned an unexpected reviewer elicitation response"),
939 }
940 }
941
942 pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
945 let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
946 match self
947 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
948 .await?
949 {
950 RelayResponsePayload::ReviewerPaused => Ok(()),
951 _ => bail!("relay returned an unexpected reviewer pause response"),
952 }
953 }
954
955 pub async fn capture_review_delta(
958 &mut self,
959 role: Option<&str>,
960 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
961 ) -> Result<Vec<hel::hel_worker::RepoDelta>> {
962 let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
963 match self
964 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
965 .await?
966 {
967 RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
968 _ => bail!("relay returned an unexpected review capture response"),
969 }
970 }
971
972 pub async fn advance_review_baseline(
975 &mut self,
976 role: Option<&str>,
977 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
978 ) -> Result<()> {
979 let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
980 match self
981 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
982 .await?
983 {
984 RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
985 _ => bail!("relay returned an unexpected review baseline response"),
986 }
987 }
988
989 pub async fn analyze_review_delta(
992 &mut self,
993 role: Option<&str>,
994 repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
995 ) -> Result<String> {
996 let request =
997 self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
998 match self
999 .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
1000 .await?
1001 {
1002 RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
1003 _ => bail!("relay returned an unexpected review analysis response"),
1004 }
1005 }
1006
1007 pub async fn take_lane_dispatches(
1010 &mut self,
1011 ) -> Result<Vec<hel::hel_review::lanes::ReviewSubagentRequest>> {
1012 let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
1013 match self.call(request).await? {
1014 RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
1015 _ => bail!("relay returned an unexpected lane dispatch response"),
1016 }
1017 }
1018
1019 fn reviewer_request(
1022 &self,
1023 role: Option<&str>,
1024 request: ReviewerRequest,
1025 ) -> Result<RelayRequest> {
1026 let request = RelayRequest::Reviewer {
1027 role: role.map(str::to_owned),
1028 request,
1029 };
1030 if !request.supported_at(self.protocol_version) {
1031 bail!(
1032 "a second opinion requires relay protocol {}; this session negotiated {}",
1033 request.minimum_protocol(),
1034 self.protocol_version
1035 );
1036 }
1037 Ok(request)
1038 }
1039
1040 pub async fn respond_elicitation(
1043 &mut self,
1044 elicitation_id: String,
1045 response: ElicitationResponse,
1046 ) -> Result<()> {
1047 let request = RelayRequest::RespondElicitation {
1048 elicitation_id: elicitation_id.clone(),
1049 response,
1050 };
1051 if !request.supported_at(self.protocol_version) {
1052 bail!(
1053 "elicitation responses require relay protocol {}; this session negotiated {}",
1054 request.minimum_protocol(),
1055 self.protocol_version
1056 );
1057 }
1058 match self.call(request).await? {
1059 RelayResponsePayload::ElicitationResolved {
1060 elicitation_id: resolved,
1061 } if resolved == elicitation_id => Ok(()),
1062 RelayResponsePayload::ElicitationResolved {
1063 elicitation_id: resolved,
1064 } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1065 _ => bail!("relay returned an unexpected elicitation response"),
1066 }
1067 }
1068
1069 pub async fn stop_background_task(&mut self, background_task_id: String) -> Result<()> {
1071 let request = RelayRequest::StopBackgroundTask {
1072 background_task_id: background_task_id.clone(),
1073 };
1074 if !request.supported_at(self.protocol_version) {
1075 bail!(
1076 "background task controls require relay protocol {}; this session negotiated {}",
1077 request.minimum_protocol(),
1078 self.protocol_version
1079 );
1080 }
1081 match self.call(request).await? {
1082 RelayResponsePayload::BackgroundTaskStopRequested {
1083 background_task_id: stopped,
1084 } if stopped == background_task_id => Ok(()),
1085 RelayResponsePayload::BackgroundTaskStopRequested {
1086 background_task_id: stopped,
1087 } => {
1088 bail!("relay stopped background task {stopped:?}, expected {background_task_id:?}")
1089 }
1090 _ => bail!("relay returned an unexpected background task stop response"),
1091 }
1092 }
1093
1094 pub async fn detach(mut self) -> Result<()> {
1095 self.input
1096 .take()
1097 .expect("connected relay owns proxy stdin")
1098 .shutdown()
1099 .await
1100 .context("close relay proxy stdin")?;
1101 let mut child = self.child.take().expect("connected relay owns proxy child");
1102 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1103 Ok(status) => {
1104 status.context("wait for relay proxy")?;
1105 }
1106 Err(_) => {
1107 if let Err(error) = child.start_kill().context("stop relay proxy") {
1108 tracing::warn!(
1109 session_id = %self.session_id,
1110 operation = "detach",
1111 %error,
1112 "could not stop relay proxy after detach timeout"
1113 );
1114 return Err(error);
1115 }
1116 if let Err(error) = child.wait().await {
1117 tracing::warn!(
1118 session_id = %self.session_id,
1119 operation = "detach",
1120 %error,
1121 "could not reap relay proxy after stopping it"
1122 );
1123 }
1124 }
1125 }
1126 Ok(())
1127 }
1128
1129 async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1130 self.call_with_timeout(request, self.request_timeout).await
1131 }
1132
1133 async fn call_with_timeout(
1134 &mut self,
1135 request: RelayRequest,
1136 timeout: Duration,
1137 ) -> Result<RelayResponsePayload> {
1138 let operation = request.method_name();
1139 let request_id = self.request_id();
1140 let envelope = RelayRequestEnvelope {
1141 request_id: request_id.clone(),
1142 protocol_version: self.protocol_version,
1143 request,
1144 };
1145 let line = match self
1146 .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1147 .await
1148 {
1149 Ok(line) => line,
1150 Err(error) => {
1151 log_relay_client_failure(self, operation, &request_id, &error);
1152 return Err(error);
1153 }
1154 };
1155 let result = decode_relay_response(&line, &request_id, self.protocol_version)
1156 .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1157 if let Err(error) = &result {
1158 log_relay_client_failure(self, operation, &request_id, error);
1159 }
1160 result
1161 }
1162
1163 async fn call_hello(
1164 &mut self,
1165 request: RelayRequest,
1166 timeout: Duration,
1167 ) -> Result<RelayResponsePayload> {
1168 let operation = request.method_name();
1169 let request_id = self.request_id();
1170 let envelope = RelayRequestEnvelope {
1171 request_id: request_id.clone(),
1172 protocol_version: RELAY_PROTOCOL_VERSION,
1173 request,
1174 };
1175 let line = match self
1176 .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1177 .await
1178 {
1179 Ok(line) => line,
1180 Err(error) => {
1181 log_relay_client_failure(self, operation, &request_id, &error);
1182 return Err(error);
1183 }
1184 };
1185 let result = decode_relay_hello_response(&line, &request_id);
1186 if let Err(error) = &result {
1187 log_relay_client_failure(self, operation, &request_id, error);
1188 }
1189 result
1190 }
1191
1192 async fn exchange(
1202 &mut self,
1203 envelope: &RelayRequestEnvelope,
1204 operation: &str,
1205 timeout: Duration,
1206 kind: ExchangeKind,
1207 ) -> Result<String> {
1208 if let Some(reason) = &self.abandoned {
1209 bail!("{reason}");
1210 }
1211 let mut frame = serde_json::to_vec(envelope)?;
1212 if frame.len() > MAX_FRAME_BYTES {
1213 bail!("relay {operation} request frame is too large");
1214 }
1215 frame.push(b'\n');
1216 let session_id = self.session_id.clone();
1217 let exchanged = tokio::time::timeout(timeout, async {
1218 self.input
1219 .as_mut()
1220 .expect("connected relay owns proxy stdin")
1221 .write_all(&frame)
1222 .await
1223 .map_err(|error| RelayTransportDead::from_io(error, kind))
1224 .with_context(|| format!("write relay {operation} request"))?;
1225 self.input
1226 .as_mut()
1227 .expect("connected relay owns proxy stdin")
1228 .flush()
1229 .await
1230 .map_err(|error| RelayTransportDead::from_io(error, kind))
1231 .with_context(|| format!("flush relay {operation} request"))?;
1232 let response = read_bounded_frame(&mut self.output, kind);
1233 tokio::pin!(response);
1234 let response = tokio::select! {
1235 response = &mut response => response,
1236 () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1237 tracing::warn!(
1238 %session_id,
1239 %operation,
1240 warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1241 timeout_seconds = timeout.as_secs_f64(),
1242 "relay operation is still waiting for its response"
1243 );
1244 response.await
1245 }
1246 };
1247 response
1248 .with_context(|| format!("read relay {operation} response"))?
1249 .ok_or_else(|| {
1250 anyhow::Error::new(RelayTransportDead::during_exchange(
1251 format!("relay proxy disconnected during {operation}"),
1252 kind,
1253 ))
1254 })
1255 })
1256 .await;
1257 match exchanged {
1258 Ok(line) => line,
1259 Err(_elapsed) => {
1260 let seconds = timeout.as_secs_f64();
1261 tracing::warn!(
1262 %session_id,
1263 %operation,
1264 timeout_seconds = seconds,
1265 "relay operation timed out; abandoning its sequential connection"
1266 );
1267 self.abandoned = Some(format!(
1268 "relay connection abandoned after {operation} timed out after {seconds} seconds"
1269 ));
1270 let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1271 Err(anyhow!(timed_out))
1272 }
1273 }
1274 }
1275
1276 fn request_id(&mut self) -> String {
1277 let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1278 self.next_request = self.next_request.wrapping_add(1);
1279 id
1280 }
1281}
1282
1283fn log_relay_client_failure(
1288 client: &RelayClient,
1289 operation: &str,
1290 request_id: &str,
1291 error: &anyhow::Error,
1292) {
1293 let rejection = error.chain().find_map(|cause| {
1294 cause
1295 .downcast_ref::<RelayRejected>()
1296 .map(|rejected| &rejected.0)
1297 });
1298 let transport_dead = RelayTransportDead::marks(error);
1299 match rejection {
1300 Some(rejection) => tracing::warn!(
1301 session_id = %client.session_id,
1302 relay_version = %client.relay_version,
1303 %operation,
1304 %request_id,
1305 relay_error_code = ?rejection.code,
1306 relay_retryable = rejection.retryable,
1307 transport_dead,
1308 error = %error,
1309 "relay request rejected"
1310 ),
1311 None => tracing::warn!(
1312 session_id = %client.session_id,
1313 relay_version = %client.relay_version,
1314 %operation,
1315 %request_id,
1316 transport_dead,
1317 error = %error,
1318 "relay request failed"
1319 ),
1320 }
1321}
1322
1323impl Drop for RelayClient {
1324 fn drop(&mut self) {
1325 drop(self.input.take());
1330 let Some(child) = self.child.take() else {
1331 return;
1332 };
1333 let session_id = self.session_id.clone();
1334 if let Err(error) = std::thread::Builder::new()
1335 .name("hel-relay-reaper".into())
1336 .spawn(move || reap_dropped_relay_proxy(child, session_id))
1337 {
1338 tracing::warn!(
1339 session_id = %self.session_id,
1340 %error,
1341 "could not start dropped relay proxy reaper"
1342 );
1343 }
1344 }
1345}
1346
1347fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1350 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1351 loop {
1352 match child.try_wait() {
1353 Ok(Some(status)) => {
1354 if !status.success() {
1355 tracing::warn!(
1356 %session_id,
1357 %status,
1358 "dropped relay proxy exited unsuccessfully"
1359 );
1360 }
1361 return;
1362 }
1363 Ok(None) if Instant::now() < deadline => {
1364 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1365 }
1366 Ok(None) => break,
1367 Err(error) => {
1368 tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1369 return;
1370 }
1371 }
1372 }
1373
1374 if let Err(error) = child.start_kill()
1375 && error.kind() != std::io::ErrorKind::NotFound
1376 {
1377 tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1378 return;
1379 }
1380 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1381 loop {
1382 match child.try_wait() {
1383 Ok(Some(_)) => return,
1384 Ok(None) if Instant::now() < deadline => {
1385 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1386 }
1387 Ok(None) => {
1388 tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1389 return;
1390 }
1391 Err(error) => {
1392 tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1393 return;
1394 }
1395 }
1396 }
1397}
1398
1399fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1400 match payload {
1401 RelayResponsePayload::CredentialState {
1402 present,
1403 fingerprint,
1404 freshness_epoch_ms,
1405 } => Ok(CredentialSnapshot {
1406 present,
1407 fingerprint,
1408 freshness_epoch_ms,
1409 }),
1410 _ => bail!("relay returned an unexpected credential state response"),
1411 }
1412}
1413
1414fn skills_sync_state(payload: RelayResponsePayload) -> Result<hel::hel_skills::SkillsSyncState> {
1415 match payload {
1416 RelayResponsePayload::SkillsState {
1417 present,
1418 fingerprint,
1419 } => Ok(hel::hel_skills::SkillsSyncState {
1420 present,
1421 fingerprint,
1422 }),
1423 _ => bail!("relay returned an unexpected skills state response"),
1424 }
1425}
1426
1427fn github_token_snapshot(
1428 payload: RelayResponsePayload,
1429) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
1430 match payload {
1431 RelayResponsePayload::GithubTokenState {
1432 present,
1433 fingerprint,
1434 } => Ok(hel::hel_credentials::GithubTokenSnapshot {
1435 present,
1436 fingerprint,
1437 }),
1438 _ => bail!("relay returned an unexpected GitHub token state response"),
1439 }
1440}
1441
1442async fn read_bounded_frame(
1443 reader: &mut (impl AsyncBufRead + Unpin),
1444 kind: ExchangeKind,
1445) -> Result<Option<String>> {
1446 read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1447}
1448
1449async fn read_bounded_frame_with_limit(
1450 reader: &mut (impl AsyncBufRead + Unpin),
1451 maximum_bytes: usize,
1452 kind: ExchangeKind,
1453) -> Result<Option<String>> {
1454 let mut frame = Vec::new();
1455 loop {
1456 let available = reader
1460 .fill_buf()
1461 .await
1462 .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1463 if available.is_empty() {
1464 if frame.is_empty() {
1465 return Ok(None);
1466 }
1467 return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1468 "relay proxy disconnected in the middle of a response frame",
1469 kind,
1470 )));
1471 }
1472 let newline = available.iter().position(|byte| *byte == b'\n');
1473 let consumed = newline.map_or(available.len(), |position| position + 1);
1474 let payload = newline.map_or(available, |position| &available[..position]);
1475 if frame.len().saturating_add(payload.len()) > maximum_bytes {
1476 bail!("relay response frame is too large");
1477 }
1478 frame.extend_from_slice(payload);
1479 reader.consume(consumed);
1480 if newline.is_some() {
1481 if frame.last() == Some(&b'\r') {
1482 frame.pop();
1483 }
1484 return String::from_utf8(frame)
1485 .context("relay response is not UTF-8")
1486 .map(Some);
1487 }
1488 }
1489}
1490
1491fn clip_catch_up_page(
1492 page: RelayAttachment,
1493 previous: &RelayCursor,
1494 frontier: &RelayCursor,
1495) -> Result<RelayEventPage> {
1496 if previous.ordinal > frontier.ordinal {
1497 bail!("relay catch-up starts beyond its fixed frontier");
1498 }
1499 if previous.ordinal == frontier.ordinal {
1500 if previous != frontier {
1501 bail!("relay catch-up cursor digest differs from its fixed frontier");
1502 }
1503 if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1504 bail!("relay attachment advanced beyond its advertised frontier");
1505 }
1506 return Ok(RelayEventPage {
1507 events: Vec::new(),
1508 through_ordinal: previous.ordinal,
1509 through_digest: previous.digest.clone(),
1510 });
1511 }
1512 if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1513 bail!("relay catch-up page did not advance");
1514 }
1515 if page.through_ordinal <= frontier.ordinal {
1516 let through = RelayCursor {
1517 ordinal: page.through_ordinal,
1518 digest: page.through_digest.clone(),
1519 };
1520 if through.ordinal == frontier.ordinal && through != *frontier {
1521 bail!("relay catch-up page digest differs from its fixed frontier");
1522 }
1523 return Ok(RelayEventPage {
1524 events: page.events,
1525 through_ordinal: through.ordinal,
1526 through_digest: through.digest,
1527 });
1528 }
1529
1530 let events = page
1531 .events
1532 .into_iter()
1533 .take_while(|event| event.ordinal <= frontier.ordinal)
1534 .collect::<Vec<_>>();
1535 let reached = events
1536 .last()
1537 .map(|event| RelayCursor {
1538 ordinal: event.ordinal,
1539 digest: event.digest.clone(),
1540 })
1541 .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1542 if reached != *frontier {
1543 bail!("relay catch-up page does not contain its fixed frontier");
1544 }
1545 Ok(RelayEventPage {
1546 events,
1547 through_ordinal: reached.ordinal,
1548 through_digest: reached.digest,
1549 })
1550}
1551
1552fn decode_relay_response(
1553 line: &str,
1554 request_id: &str,
1555 protocol: u32,
1556) -> Result<RelayResponsePayload> {
1557 let response: RelayResponseEnvelope =
1558 serde_json::from_str(line).context("decode relay response")?;
1559 if response.request_id != request_id {
1560 bail!(
1561 "relay response ID mismatch: expected {request_id}, got {}",
1562 response.request_id
1563 );
1564 }
1565 if response.protocol_version != protocol {
1566 bail!(
1567 "relay response protocol mismatch: expected {protocol}, got {}",
1568 response.protocol_version
1569 );
1570 }
1571 match response.body {
1572 RelayResponseBody::Ok { payload } => Ok(payload),
1573 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1574 }
1575}
1576
1577fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1578 let response: RelayResponseEnvelope =
1579 serde_json::from_str(line).context("decode relay hello response")?;
1580 if response.request_id != request_id {
1581 bail!(
1582 "relay response ID mismatch: expected {request_id}, got {}",
1583 response.request_id
1584 );
1585 }
1586 match response.body {
1587 RelayResponseBody::Ok {
1588 payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1589 } => {
1590 if response.protocol_version != negotiated {
1591 bail!(
1592 "relay hello envelope uses protocol {}, negotiated {negotiated}",
1593 response.protocol_version
1594 );
1595 }
1596 Ok(payload)
1597 }
1598 RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1599 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1600 }
1601}
1602
1603pub struct CredentialSyncCoordinator {
1604 handle: CredentialSyncHandle,
1605 results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1606}
1607
1608impl CredentialSyncCoordinator {
1609 pub fn spawn() -> Self {
1610 let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1611 let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1612 let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1613 let (results_tx, results_rx) = mpsc::unbounded_channel();
1614 tokio::spawn(async move {
1615 let mut tick = tokio::time::interval_at(
1616 tokio::time::Instant::now() + SYNC_INTERVAL,
1617 SYNC_INTERVAL,
1618 );
1619 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1620 let mut busy = BTreeSet::<String>::new();
1623 let mut queue = VecDeque::<SyncTrigger>::new();
1624 loop {
1625 tokio::select! {
1626 _ = tick.tick() => {
1627 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1628 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1629 }
1630 }
1631 changed = targets_rx.changed() => {
1632 if changed.is_err() { break; }
1633 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1634 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1635 }
1636 }
1637 trigger = triggers_rx.recv() => {
1638 let Some(trigger) = trigger else { break };
1639 enqueue(&mut queue, trigger);
1640 }
1641 completed = completed_rx.recv() => {
1642 let Some(result) = completed else { break };
1643 busy.remove(&result.profile_id);
1644 if result.trigger.is_some()
1645 || result.failure.is_some()
1646 || !result.outcomes.is_empty()
1647 {
1648 let profile_id = result.profile_id.clone();
1649 if results_tx.send(result).is_err() {
1650 tracing::debug!(
1651 %profile_id,
1652 operation = "credential_sync_result",
1653 "credential sync result receiver was already closed"
1654 );
1655 }
1656 }
1657 }
1658 }
1659
1660 let mut deferred = VecDeque::new();
1661 while let Some(trigger) = queue.pop_front() {
1662 if busy.contains(&trigger.profile_id) {
1663 deferred.push_back(trigger);
1664 continue;
1665 }
1666 let targets: Vec<_> = targets_rx
1667 .borrow()
1668 .iter()
1669 .filter(|target| target.profile_id == trigger.profile_id)
1670 .cloned()
1671 .collect();
1672 if targets.is_empty() {
1673 if trigger.cause.is_some() {
1674 let profile_id = trigger.profile_id.clone();
1675 if results_tx
1676 .send(CredentialSyncResult {
1677 profile_id: trigger.profile_id,
1678 trigger: trigger.cause,
1679 failure: None,
1680 outcomes: Vec::new(),
1681 })
1682 .is_err()
1683 {
1684 tracing::debug!(
1685 %profile_id,
1686 operation = "credential_sync_result",
1687 "credential sync result receiver was already closed"
1688 );
1689 }
1690 }
1691 continue;
1692 }
1693 busy.insert(trigger.profile_id.clone());
1694 let completed_tx = completed_tx.clone();
1695 let handle = tokio::runtime::Handle::current();
1696 tokio::spawn(async move {
1699 let joined = tokio::task::spawn_blocking(move || {
1700 handle.block_on(reconcile_profile(&targets))
1701 })
1702 .await;
1703 let (failure, outcomes) = match joined {
1704 Ok(outcomes) => (None, outcomes),
1705 Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1706 };
1707 let profile_id = trigger.profile_id.clone();
1708 if completed_tx
1709 .send(CredentialSyncResult {
1710 profile_id: trigger.profile_id,
1711 trigger: trigger.cause,
1712 failure,
1713 outcomes,
1714 })
1715 .is_err()
1716 {
1717 tracing::debug!(
1718 %profile_id,
1719 operation = "credential_sync_completion",
1720 "credential sync coordinator stopped before receiving completion"
1721 );
1722 }
1723 });
1724 }
1725 queue = deferred;
1726 }
1727 });
1728 Self {
1729 handle: CredentialSyncHandle {
1730 targets: Arc::new(targets_tx),
1731 triggers: triggers_tx,
1732 },
1733 results: results_rx,
1734 }
1735 }
1736
1737 pub fn handle(&self) -> CredentialSyncHandle {
1738 self.handle.clone()
1739 }
1740
1741 pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1742 self.results.try_recv().ok()
1743 }
1744
1745 pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1751 self.results.recv().await
1752 }
1753}
1754
1755async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1762 let github_token = targets
1763 .iter()
1764 .any(|target| target.sync_github_token)
1765 .then(crate::hel_controller::controller_github_token)
1766 .flatten();
1767 let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
1768 for pass in 0..2 {
1769 let mut pulled = false;
1770 for target in targets {
1771 match reconcile_session(target, github_token.as_deref()).await {
1772 Ok(actions) if actions.is_empty() => {}
1773 Ok(actions) => {
1774 pulled |= actions.contains(&CredentialSyncAction::Pulled);
1775 outcomes.insert(
1776 target.session_id.clone(),
1777 CredentialSyncOutcome {
1778 session_id: target.session_id.clone(),
1779 outcome: Ok(actions),
1780 },
1781 );
1782 }
1783 Err(error) => {
1784 tracing::warn!(
1785 session_id = %target.session_id,
1786 profile_id = %target.profile_id,
1787 pass = pass + 1,
1788 error = %error,
1789 "credential synchronization failed for relay session"
1790 );
1791 outcomes.insert(
1792 target.session_id.clone(),
1793 CredentialSyncOutcome {
1794 session_id: target.session_id.clone(),
1795 outcome: Err(format!("{error:#}")),
1796 },
1797 );
1798 }
1799 }
1800 }
1801 if !pulled || pass == 1 {
1802 break;
1803 }
1804 }
1805 outcomes.into_values().collect()
1806}
1807
1808async fn reconcile_session(
1810 target: &CredentialSyncTarget,
1811 github_token: Option<&str>,
1812) -> Result<Vec<CredentialSyncAction>> {
1813 let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
1814 let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
1815 let canonical_skills = hel::hel_skills::collect_skills(target.harness, &target.profile_home)
1816 .with_context(|| {
1817 format!(
1818 "collect canonical skills for profile {} from {}",
1819 target.profile_id,
1820 target.profile_home.display()
1821 )
1822 })?;
1823 let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
1824 let result = reconcile_connected(
1825 &mut client,
1826 target,
1827 &canonical_path,
1828 &canonical,
1829 &canonical_bytes,
1830 &canonical_skills,
1831 github_token,
1832 )
1833 .await;
1834 if let Err(error) = client.detach().await {
1838 tracing::warn!(
1839 session_id = %target.session_id,
1840 "could not close the credential sync connection: {error:#}"
1841 );
1842 }
1843 result
1844}
1845
1846async fn reconcile_connected(
1847 client: &mut RelayClient,
1848 target: &CredentialSyncTarget,
1849 canonical_path: &Path,
1850 canonical: &CredentialSnapshot,
1851 canonical_bytes: &[u8],
1852 canonical_skills: &hel::hel_skills::SkillsArchive,
1853 github_token: Option<&str>,
1854) -> Result<Vec<CredentialSyncAction>> {
1855 let mut actions = Vec::new();
1856 let session = client.credential_state().await?;
1857 match reconcile(canonical, &session) {
1858 SyncAction::None => {
1859 if canonical.present
1860 && session.present
1861 && canonical.fingerprint != session.fingerprint
1862 && canonical.freshness_epoch_ms.is_none()
1863 && session.freshness_epoch_ms.is_none()
1864 {
1865 tracing::warn!(
1866 session_id = %target.session_id,
1867 profile_id = %target.profile_id,
1868 "credential copies differ but neither reports a refresh time; leaving both alone"
1869 );
1870 }
1871 }
1872 SyncAction::Push => {
1873 client.install_credentials(canonical_bytes).await?;
1874 actions.push(CredentialSyncAction::Pushed);
1875 }
1876 SyncAction::Pull => {
1877 let bytes = client.read_credentials().await?;
1878 validate_credential_payload(target.harness, &bytes).with_context(|| {
1879 format!(
1880 "session {} returned an unusable credential file",
1881 target.session_id
1882 )
1883 })?;
1884 write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
1885 format!(
1886 "install fresher credentials from session {} for profile {}",
1887 target.session_id, target.profile_id
1888 )
1889 })?;
1890 actions.push(CredentialSyncAction::Pulled);
1891 }
1892 }
1893 if reconcile_skills(client, target, canonical_skills).await? {
1894 actions.push(CredentialSyncAction::SkillsPushed);
1895 }
1896 if target.sync_github_token
1897 && let Some(action) = reconcile_github_token(client, target, github_token).await?
1898 {
1899 actions.push(action);
1900 }
1901 Ok(actions)
1902}
1903
1904async fn reconcile_github_token(
1905 client: &mut RelayClient,
1906 target: &CredentialSyncTarget,
1907 canonical: Option<&str>,
1908) -> Result<Option<CredentialSyncAction>> {
1909 let session = match client.github_token_state().await {
1910 Ok(state) => state,
1911 Err(error) if sync_method_unsupported(&error) => {
1912 tracing::debug!(
1913 session_id = %target.session_id,
1914 profile_id = %target.profile_id,
1915 "worker predates GitHub token sync; skipping until the target is re-provisioned"
1916 );
1917 return Ok(None);
1918 }
1919 Err(error) => return Err(error),
1920 };
1921 match canonical {
1922 Some(token) => {
1923 let canonical = hel::hel_credentials::GithubTokenSnapshot::of(token);
1924 if session == canonical {
1925 return Ok(None);
1926 }
1927 let installed = client.install_github_token(token).await?;
1928 if installed != canonical {
1929 bail!(
1930 "session {} GitHub token fingerprint does not match the controller after install",
1931 target.session_id
1932 );
1933 }
1934 Ok(Some(CredentialSyncAction::GithubTokenPushed))
1935 }
1936 None if session.present => {
1937 let removed = client.remove_github_token().await?;
1938 if removed.present {
1939 bail!(
1940 "session {} retained its GitHub token after removal",
1941 target.session_id
1942 );
1943 }
1944 Ok(Some(CredentialSyncAction::GithubTokenRemoved))
1945 }
1946 None => Ok(None),
1947 }
1948}
1949
1950async fn reconcile_skills(
1955 client: &mut RelayClient,
1956 target: &CredentialSyncTarget,
1957 canonical: &hel::hel_skills::SkillsArchive,
1958) -> Result<bool> {
1959 let canonical_state = canonical.state();
1960 let session = match client.skills_state().await {
1961 Ok(state) => state,
1962 Err(error) if sync_method_unsupported(&error) => {
1963 tracing::debug!(
1964 session_id = %target.session_id,
1965 profile_id = %target.profile_id,
1966 "worker predates skills sync; skipping until the target is re-provisioned"
1967 );
1968 return Ok(false);
1969 }
1970 Err(error) => return Err(error),
1971 };
1972 if session == canonical_state {
1973 return Ok(false);
1974 }
1975 let installed = client.install_skills(&canonical.encode()).await?;
1976 if installed != canonical_state {
1977 bail!(
1978 "session {} skills fingerprint {} does not match the canonical {} after install",
1979 target.session_id,
1980 installed.fingerprint,
1981 canonical_state.fingerprint
1982 );
1983 }
1984 Ok(true)
1985}
1986
1987fn sync_method_unsupported(error: &anyhow::Error) -> bool {
1988 error
1989 .downcast_ref::<RelayRejected>()
1990 .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995 use super::*;
1996 use hel::hel_worker::{DurableRelay, RelayObservation};
1997 const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
1998
1999 #[test]
2000 fn relay_decoder_preserves_explicit_desynchronization() {
2001 let response = RelayResponseEnvelope {
2002 request_id: "relay-1".into(),
2003 protocol_version: RELAY_PROTOCOL_VERSION,
2004 body: RelayResponseBody::Error {
2005 error: RelayProtocolError {
2006 code: RelayErrorCode::Desynchronized,
2007 message: "journal gap".into(),
2008 retryable: false,
2009 detail: None,
2010 },
2011 },
2012 };
2013 let encoded = serde_json::to_string(&response).unwrap();
2014 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2015 assert!(
2016 error
2017 .downcast_ref::<RelayRejected>()
2018 .is_some_and(RelayRejected::is_desynchronized)
2019 );
2020 }
2021
2022 #[test]
2023 fn relay_decoder_rejects_crossed_request_ids() {
2024 let response = RelayResponseEnvelope {
2025 request_id: "other".into(),
2026 protocol_version: RELAY_PROTOCOL_VERSION,
2027 body: RelayResponseBody::Ok {
2028 payload: RelayResponsePayload::Acknowledged {
2029 through_ordinal: 4,
2030 through_digest: "a".repeat(64),
2031 },
2032 },
2033 };
2034 let encoded = serde_json::to_string(&response).unwrap();
2035 assert!(
2036 decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
2037 .unwrap_err()
2038 .to_string()
2039 .contains("ID mismatch")
2040 );
2041 }
2042
2043 #[test]
2044 fn command_spec_preserves_argv_boundaries() {
2045 let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
2046 assert_eq!(spec.program, "ssh");
2047 assert_eq!(spec.args.len(), 2);
2048 assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
2049 }
2050
2051 #[test]
2052 fn relay_protocol_version_range_contains_current_version() {
2053 assert_eq!(
2054 RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
2055 Some(RELAY_PROTOCOL_VERSION)
2056 );
2057 assert_eq!(
2058 RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
2059 Some(1)
2060 );
2061 }
2062
2063 #[cfg(unix)]
2064 #[tokio::test]
2065 async fn controller_accepts_negotiated_protocol_v1() {
2066 let script = format!(
2067 r#"python3 -c '
2068import json, sys
2069session = {session:?}
2070req = json.loads(sys.stdin.readline())
2071assert req["request"]["method"] == "hello"
2072supported = req["request"]["params"]["supported"]
2073assert supported["min"] <= 1 <= supported["max"]
2074print(json.dumps({{
2075 "request_id": req["request_id"],
2076 "protocol_version": 1,
2077 "result": "ok",
2078 "payload": {{
2079 "type": "hello",
2080 "data": {{
2081 "negotiated": 1,
2082 "relay_version": "v1-fixture",
2083 "session_id": session,
2084 }},
2085 }},
2086}}), flush=True)
2087sys.stdin.read()
2088'"#,
2089 session = SESSION_ID
2090 );
2091 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2092 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2093 .await
2094 .expect("protocol v1 hello must be accepted");
2095 assert_eq!(client.protocol_version(), 1);
2096 assert_eq!(client.relay_version(), "v1-fixture");
2097 }
2098
2099 #[cfg(unix)]
2103 #[tokio::test]
2104 async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2105 let hello = |build: Option<&str>| {
2106 let data = match build {
2107 Some(build) => format!(
2108 r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2109 ),
2110 None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2111 .to_owned(),
2112 };
2113 format!(
2114 r#"
2115IFS= read -r hello
2116id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2117printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2118' "$id" "$1"
2119sh -c 'while :; do sleep 30; done'
2120"#
2121 )
2122 };
2123 for reported in [None, Some("a".repeat(64).as_str())] {
2124 let spec = CommandSpec::new(
2125 "sh",
2126 [
2127 "-c".to_owned(),
2128 hello(reported),
2129 "hel-relay-build-fixture".to_owned(),
2130 SESSION_ID.to_owned(),
2131 ],
2132 )
2133 .purpose("relay worker build fixture");
2134 let client =
2135 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2136 .await
2137 .expect("hello must be accepted with and without a worker build");
2138 assert_eq!(client.worker_build(), reported);
2139 }
2140 }
2141
2142 #[cfg(unix)]
2143 #[tokio::test]
2144 async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2145 let directory = tempfile::tempdir().unwrap();
2146 let eof = directory.path().join("proxy-saw-eof");
2147 let script = r#"
2148IFS= read -r hello
2149id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2150printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2151if IFS= read -r _; then exit 9; fi
2152: > "$2"
2153"#;
2154 let spec = CommandSpec::new(
2155 "sh",
2156 [
2157 "-c".to_owned(),
2158 script.to_owned(),
2159 "hel-relay-eof-fixture".to_owned(),
2160 SESSION_ID.to_owned(),
2161 eof.to_string_lossy().into_owned(),
2162 ],
2163 )
2164 .purpose("relay proxy EOF fixture");
2165 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2166 .await
2167 .unwrap();
2168
2169 drop(client);
2170 tokio::time::timeout(Duration::from_secs(2), async {
2171 while !eof.exists() {
2172 tokio::time::sleep(Duration::from_millis(10)).await;
2173 }
2174 })
2175 .await
2176 .expect("proxy launcher was killed before it observed stdin EOF");
2177 }
2178
2179 #[cfg(unix)]
2180 #[tokio::test]
2181 async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2182 let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2183 let script = format!(
2184 r#"python3 -c '
2185import json, sys
2186session = {session:?}
2187req = json.loads(sys.stdin.readline())
2188print(json.dumps({{
2189 "request_id": req["request_id"],
2190 "protocol_version": {future_protocol},
2191 "result": "ok",
2192 "payload": {{
2193 "type": "hello",
2194 "data": {{
2195 "negotiated": {future_protocol},
2196 "relay_version": "future",
2197 "session_id": session,
2198 }},
2199 }},
2200}}), flush=True)
2201sys.stdin.read()
2202'"#,
2203 session = SESSION_ID,
2204 future_protocol = future_protocol,
2205 );
2206 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2207 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2208 .await
2209 .err()
2210 .expect("a future protocol hello must be rejected");
2211 assert!(
2212 error.to_string().contains(&format!(
2213 "negotiated unsupported protocol {future_protocol}"
2214 )),
2215 "{error:#}"
2216 );
2217 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2220 }
2221
2222 #[cfg(unix)]
2225 #[tokio::test]
2226 async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2227 let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2228
2229 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2230 .await
2231 .err()
2232 .expect("a proxy that exits cannot complete hello");
2233
2234 assert!(RelayTransportDead::marks(&error), "{error:#}");
2235 assert!(RelayTransportDead::marks_failed_handshake(&error));
2236 }
2237
2238 #[cfg(unix)]
2239 #[tokio::test]
2240 async fn silent_proxy_handshake_has_a_bounded_deadline() {
2241 let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2242 let started = std::time::Instant::now();
2243
2244 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2245 .await
2246 .err()
2247 .expect("silent relay must time out");
2248
2249 assert!(error.to_string().contains("relay hello timed out"));
2250 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2254 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2255 assert!(started.elapsed() < Duration::from_secs(2));
2256 }
2257
2258 #[cfg(unix)]
2261 const STALLING_RELAY: &str = r#"
2262IFS= read -r hello
2263id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2264printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2265IFS= read -r stalled
2266id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2267sleep 5
2268printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2269cat > /dev/null
2270"#;
2271
2272 #[cfg(unix)]
2273 #[tokio::test]
2274 async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2275 let spec = CommandSpec::new(
2276 "sh",
2277 ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2278 )
2279 .purpose("stalling relay fixture");
2280 let mut client =
2281 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2282 .await
2283 .expect("the fixture answers hello immediately");
2284
2285 let timed_out = client
2286 .status()
2287 .await
2288 .expect_err("the stalled status call must time out");
2289 assert!(
2290 format!("{timed_out:#}").contains("relay status timed out"),
2291 "{timed_out:#}"
2292 );
2293 assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2296
2297 let started = std::time::Instant::now();
2302 let subsequent = client
2303 .status()
2304 .await
2305 .expect_err("a call on an abandoned connection must fail");
2306 let elapsed = started.elapsed();
2307 assert!(
2308 format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2309 "{subsequent:#}"
2310 );
2311 assert!(
2312 elapsed < Duration::from_millis(250),
2313 "an abandoned connection must fail fast, took {elapsed:?}"
2314 );
2315
2316 let repeated = client
2317 .status()
2318 .await
2319 .expect_err("the connection stays abandoned");
2320 assert!(
2321 format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2322 "{repeated:#}"
2323 );
2324 }
2325
2326 #[test]
2327 fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2328 let response = hel::hel_worker::unsupported_relay_method_response(
2332 "relay-1".into(),
2333 RELAY_PROTOCOL_VERSION,
2334 "skills_state".into(),
2335 );
2336 let encoded = serde_json::to_string(&response).unwrap();
2337 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2338 assert!(sync_method_unsupported(&error), "{error:#}");
2339 }
2340
2341 #[tokio::test]
2342 async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2343 let profile = tempfile::tempdir().unwrap();
2344 let mut coordinator = CredentialSyncCoordinator::spawn();
2345 coordinator.handle().set_targets(vec![CredentialSyncTarget {
2346 session_id: SESSION_ID.into(),
2347 profile_id: "work".into(),
2348 harness: hel::hel_config::HarnessKind::Codex,
2349 profile_home: profile.path().to_path_buf(),
2350 sync_github_token: false,
2351 spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2352 }]);
2353
2354 let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2355 .await
2356 .expect("target publication must not wait for the 60-second periodic tick")
2357 .expect("credential coordinator stopped");
2358 assert_eq!(result.profile_id, "work");
2359 assert_eq!(result.outcomes.len(), 1);
2360 assert!(result.outcomes[0].outcome.is_err());
2361 }
2362
2363 #[tokio::test]
2364 async fn response_frame_limit_is_enforced_before_newline() {
2365 let (mut writer, reader) = tokio::io::duplex(32);
2366 let write = tokio::spawn(async move {
2367 writer.write_all(b"123456789\n").await.unwrap();
2368 });
2369 let mut reader = BufReader::new(reader);
2370
2371 let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2372 .await
2373 .unwrap_err();
2374
2375 write.await.unwrap();
2376 assert!(error.to_string().contains("frame is too large"));
2377 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2380 }
2381
2382 #[tokio::test]
2383 async fn a_half_written_response_frame_reports_a_dead_transport() {
2384 let (mut writer, reader) = tokio::io::duplex(32);
2385 writer.write_all(b"{\"partial\":").await.unwrap();
2386 drop(writer);
2387 let mut reader = BufReader::new(reader);
2388
2389 let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2390 .await
2391 .unwrap_err();
2392
2393 assert!(RelayTransportDead::marks(&error), "{error:#}");
2394 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2395 }
2396
2397 #[test]
2398 fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2399 let temp = tempfile::tempdir().unwrap();
2400 let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2401 for message in ["one", "two", "arrived concurrently"] {
2402 relay
2403 .record_observation(RelayObservation::Warning {
2404 message: message.into(),
2405 })
2406 .unwrap();
2407 }
2408 let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2409 let previous = RelayCursor {
2410 ordinal: all[0].ordinal,
2411 digest: all[0].digest.clone(),
2412 };
2413 let frontier = RelayCursor {
2414 ordinal: all[1].ordinal,
2415 digest: all[1].digest.clone(),
2416 };
2417 let page = RelayAttachment {
2418 state: relay.operational_state(),
2419 events: all[1..].to_vec(),
2420 through_ordinal: all[2].ordinal,
2421 through_digest: all[2].digest.clone(),
2422 };
2423 let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2424 assert_eq!(clipped.through_ordinal, frontier.ordinal);
2425 assert_eq!(clipped.through_digest, frontier.digest);
2426 assert_eq!(clipped.events.len(), 1);
2427 assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2428 }
2429}