Skip to main content

mj_controller/worker_client/
relay.rs

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