Skip to main content

mj_controller/worker_client/
relay.rs

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