Skip to main content

mj_controller/worker_client/
relay.rs

1use super::*;
2
3impl RelayClient {
4    pub fn session_id(&self) -> &str {
5        &self.session_id
6    }
7
8    pub fn supports_project_memory_sync(&self) -> bool {
9        RelayRequest::ProjectMemorySnapshot.supported_at(self.protocol_version)
10    }
11
12    pub fn relay_version(&self) -> &str {
13        &self.relay_version
14    }
15
16    /// Content address of the executable serving this connection, or `None`
17    /// from a worker too old to report one. A controller reads `None` as
18    /// outdated: it predates the field, so it predates this controller.
19    pub fn worker_build(&self) -> Option<&str> {
20        self.worker_build.as_deref()
21    }
22
23    pub fn protocol_version(&self) -> u32 {
24        self.protocol_version
25    }
26
27    pub fn latest_ordinal(&self) -> u64 {
28        self.latest_ordinal
29    }
30
31    pub fn latest_digest(&self) -> &str {
32        &self.latest_digest
33    }
34
35    pub async fn attach(
36        &mut self,
37        after_ordinal: u64,
38        after_digest: impl Into<String>,
39    ) -> Result<RelayAttachment> {
40        let after_digest = after_digest.into();
41        match self
42            .call_with_timeout(
43                RelayRequest::Attach {
44                    after_ordinal,
45                    after_digest: after_digest.clone(),
46                },
47                RELAY_HISTORY_TIMEOUT,
48            )
49            .await?
50        {
51            RelayResponsePayload::Attached {
52                state,
53                events,
54                through_ordinal,
55                through_digest,
56            } => {
57                let mut cursor = RelayCursor {
58                    ordinal: after_ordinal,
59                    digest: after_digest,
60                };
61                for event in &events {
62                    validate_relay_event(cursor.ordinal, &cursor.digest, event)
63                        .context("verify relay attachment event chain")?;
64                    cursor.ordinal = event.ordinal;
65                    cursor.digest.clone_from(&event.digest);
66                }
67                if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
68                    bail!("relay attachment frontier does not match its event chain");
69                }
70                self.latest_ordinal = state.latest_ordinal;
71                self.latest_digest = state.latest_digest.clone();
72                Ok(RelayAttachment {
73                    state,
74                    events,
75                    through_ordinal,
76                    through_digest,
77                })
78            }
79            _ => bail!("relay returned an unexpected attach response"),
80        }
81    }
82
83    /// Start a bounded catch-up by capturing the relay frontier before the
84    /// caller applies anything. Callers persist `first_page`, request further
85    /// pages with [`Self::next_catch_up_page`], and may acknowledge the fixed
86    /// frontier after all of those pages are durable.
87    pub async fn begin_catch_up(
88        &mut self,
89        after_ordinal: u64,
90        after_digest: impl Into<String>,
91    ) -> Result<RelayCatchUp> {
92        let after_digest = after_digest.into();
93        let first = self.attach(after_ordinal, after_digest.clone()).await?;
94        let frontier = RelayCursor {
95            ordinal: first.state.latest_ordinal,
96            digest: first.state.latest_digest.clone(),
97        };
98        let previous = RelayCursor {
99            ordinal: after_ordinal,
100            digest: after_digest,
101        };
102        let state = first.state.clone();
103        let first_page = clip_catch_up_page(first, &previous, &frontier)?;
104        Ok(RelayCatchUp {
105            state,
106            frontier,
107            first_page,
108        })
109    }
110
111    /// Fetch the next bounded page without chasing events that arrived after
112    /// `frontier` was captured. A response may contain such newer events; the
113    /// returned page is clipped at the exact ordinal-and-digest frontier.
114    pub async fn next_catch_up_page(
115        &mut self,
116        previous: &RelayCursor,
117        frontier: &RelayCursor,
118    ) -> Result<RelayEventPage> {
119        if previous.ordinal >= frontier.ordinal {
120            bail!("relay catch-up is already at its fixed frontier");
121        }
122        let attachment = self
123            .attach(previous.ordinal, previous.digest.clone())
124            .await?;
125        clip_catch_up_page(attachment, previous, frontier)
126    }
127
128    pub async fn acknowledge(
129        &mut self,
130        through_ordinal: u64,
131        through_digest: impl Into<String>,
132    ) -> Result<RelayCursor> {
133        match self
134            .call_with_timeout(
135                RelayRequest::Acknowledge {
136                    through_ordinal,
137                    through_digest: through_digest.into(),
138                },
139                RELAY_ACKNOWLEDGE_TIMEOUT,
140            )
141            .await?
142        {
143            RelayResponsePayload::Acknowledged {
144                through_ordinal,
145                through_digest,
146            } => Ok(RelayCursor {
147                ordinal: through_ordinal,
148                digest: through_digest,
149            }),
150            _ => bail!("relay returned an unexpected acknowledgement response"),
151        }
152    }
153
154    pub async fn status(&mut self) -> Result<RelayOperationalState> {
155        match self.call(RelayRequest::Status).await? {
156            RelayResponsePayload::Status(status) => {
157                self.latest_ordinal = status.latest_ordinal;
158                self.latest_digest = status.latest_digest.clone();
159                Ok(status)
160            }
161            _ => bail!("relay returned an unexpected status response"),
162        }
163    }
164
165    /// Return the fingerprint and freshness of this session's harness
166    /// credentials without exposing the credential bytes.
167    pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
168        credential_snapshot(self.call(RelayRequest::CredentialState).await?)
169    }
170
171    /// Read this session's credential file. Callers must keep these bytes out
172    /// of durable relay observations, logs, and archives.
173    pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
174        match self.call(RelayRequest::ReadCredentials).await? {
175            RelayResponsePayload::Credentials { data } => BASE64
176                .decode(data.as_bytes())
177                .context("decode relay credential payload"),
178            _ => bail!("relay returned an unexpected credential response"),
179        }
180    }
181
182    /// Install credentials into the harness home fixed by this session's
183    /// launch config.
184    pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
185        credential_snapshot(
186            self.call(RelayRequest::InstallCredentials {
187                data: BASE64.encode(bytes),
188            })
189            .await?,
190        )
191    }
192
193    pub async fn github_token_state(
194        &mut self,
195    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
196        github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
197    }
198
199    pub async fn install_github_token(
200        &mut self,
201        token: &str,
202    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
203        github_token_snapshot(
204            self.call(RelayRequest::InstallGithubToken {
205                data: BASE64.encode(token.as_bytes()),
206            })
207            .await?,
208        )
209    }
210
211    pub async fn remove_github_token(
212        &mut self,
213    ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
214        github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
215    }
216
217    /// Return the fingerprint of this session's synced skills trees without
218    /// transferring the tree itself.
219    pub async fn skills_state(&mut self) -> Result<mj_core::skills::SkillsSyncState> {
220        skills_sync_state(self.call(RelayRequest::SkillsState).await?)
221    }
222
223    /// Install background text that only the target harness sees, prepended
224    /// to the next real prompt without creating a synthetic transcript turn.
225    pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
226        let request = RelayRequest::InstallPromptContext { text };
227        match self.call(request).await? {
228            RelayResponsePayload::PromptContextInstalled => Ok(()),
229            _ => bail!("relay returned an unexpected prompt-context response"),
230        }
231    }
232
233    pub async fn project_memory_snapshot(
234        &mut self,
235    ) -> Result<(
236        mj_core::project_memory::ProjectMemorySnapshot,
237        mj_core::project_memory::ProjectMemorySnapshot,
238    )> {
239        let request = RelayRequest::ProjectMemorySnapshot;
240        match self.call(request).await? {
241            RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
242                Ok((baseline, replica))
243            }
244            _ => bail!("relay returned an unexpected project-memory response"),
245        }
246    }
247
248    pub async fn install_project_memory_snapshot(
249        &mut self,
250        snapshot: mj_core::project_memory::ProjectMemorySnapshot,
251    ) -> Result<()> {
252        let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
253        match self.call(request).await? {
254            RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
255            _ => bail!("relay returned an unexpected project-memory install response"),
256        }
257    }
258
259    /// Replace this session's synced skills trees with an encoded
260    /// `skills::SkillsArchive`. The destination directories are fixed by
261    /// the session's launch config and the harness skills whitelist.
262    pub async fn install_skills(
263        &mut self,
264        archive_bytes: &[u8],
265    ) -> Result<mj_core::skills::SkillsSyncState> {
266        skills_sync_state(
267            self.call(RelayRequest::InstallSkills {
268                data: BASE64.encode(archive_bytes),
269            })
270            .await?,
271        )
272    }
273
274    /// Copy a verified controller blob to this session before admitting its reference.
275    pub async fn ensure_attachment(
276        &mut self,
277        reference: &mj_core::attachment::AttachmentRef,
278    ) -> Result<()> {
279        match self
280            .call(RelayRequest::AttachmentPresent {
281                reference: reference.clone(),
282            })
283            .await?
284        {
285            RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
286            RelayResponsePayload::AttachmentPresent { present: false } => {}
287            _ => bail!("unexpected image presence response"),
288        }
289        let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
290        let reference_copy = reference.clone();
291        let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
292            .await
293            .context("image loading task failed")??;
294        match self
295            .call(RelayRequest::InstallAttachment {
296                reference: reference.clone(),
297                data: BASE64.encode(bytes),
298            })
299            .await?
300        {
301            RelayResponsePayload::AttachmentInstalled => Ok(()),
302            _ => bail!("unexpected image upload response"),
303        }
304    }
305
306    /// Recover the local copy needed for queue editing and resubmission.
307    pub async fn cache_attachment(
308        &mut self,
309        reference: &mj_core::attachment::AttachmentRef,
310    ) -> Result<()> {
311        let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
312        let local = store.clone();
313        let reference_copy = reference.clone();
314        if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
315            .await
316            .context("image lookup task failed")??
317        {
318            return Ok(());
319        }
320        let RelayResponsePayload::AttachmentData { data } = self
321            .call(RelayRequest::ReadAttachment {
322                reference: reference.clone(),
323            })
324            .await?
325        else {
326            bail!("unexpected image download response")
327        };
328        let reference = reference.clone();
329        tokio::task::spawn_blocking(move || {
330            anyhow::ensure!(
331                data.len() <= mj_core::attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
332                "image download is too large"
333            );
334            store.install(&reference, &BASE64.decode(data)?)
335        })
336        .await
337        .context("image caching task failed")?
338    }
339
340    pub async fn submit(
341        &mut self,
342        command_id: impl Into<String>,
343        command: RelayCommand,
344    ) -> Result<u64> {
345        let command_id = command_id.into();
346        if let RelayCommand::Prompt { prompt } = &command {
347            for reference in mj_core::attachment::references(prompt)? {
348                self.ensure_attachment(&reference).await?;
349            }
350        }
351        match self
352            .call(RelayRequest::Submit {
353                command_id: command_id.clone(),
354                command,
355            })
356            .await?
357        {
358            RelayResponsePayload::Accepted {
359                command_id: accepted_id,
360                ordinal,
361            } if accepted_id == command_id => Ok(ordinal),
362            RelayResponsePayload::Accepted {
363                command_id: accepted_id,
364                ..
365            } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
366            _ => bail!("relay returned an unexpected command response"),
367        }
368    }
369}