1use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use okbot_core::{
7 ConversationKey, Delegation, PeerError, PeerInfo, PeerPrompt, PeerTask, PeerTaskRecord,
8 PeerTaskState, Peers, async_trait,
9};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12
13use crate::a2a::{self, Part};
14use crate::node::{Inner, Team, lock};
15
16const POLL_FIRST: Duration = Duration::from_millis(500);
18const POLL_MAX: Duration = Duration::from_secs(3);
19
20#[derive(Clone)]
23pub struct Node {
24 pub(crate) inner: Arc<Inner>,
25}
26
27impl std::fmt::Debug for Node {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.debug_struct("Node")
30 .field("teams", &self.inner.teams.keys().collect::<Vec<_>>())
31 .finish_non_exhaustive()
32 }
33}
34
35impl Node {
36 pub fn new(
38 config: crate::NodeConfig,
39 store: Arc<dyn okbot_core::ConversationStore>,
40 clock: Arc<dyn okbot_core::Clock>,
41 ) -> Result<Self, crate::Error> {
42 Ok(Self {
43 inner: Arc::new(Inner::new(config, store, clock)?),
44 })
45 }
46
47 pub async fn start(&self) -> Vec<String> {
50 let mut problems = Vec::new();
51 for team in self.inner.teams.values() {
52 if team.signed()
53 && let Err(error) = self.inner.refresh(team).await
54 {
55 problems.push(format!("team `{}`: {error}", team.config.id));
56 }
57 }
58 problems.extend(self.inner.check_membership());
59 problems.extend(
60 self.inner
61 .teams
62 .values()
63 .filter_map(|t| self.inner.expiry_warning(t)),
64 );
65 match self
66 .inner
67 .store
68 .interrupt_peer_tasks(self.inner.now())
69 .await
70 {
71 Ok(0) => {}
72 Ok(n) => tracing::warn!(
73 tasks = n,
74 "peer tasks interrupted by the last stop were marked failed"
75 ),
76 Err(error) => tracing::warn!(%error, "cannot mark interrupted peer tasks"),
77 }
78 problems
79 }
80
81 pub fn channels(&self) -> Vec<Arc<dyn okbot_core::Channel>> {
84 self.inner
85 .teams
86 .values()
87 .map(|t| {
88 Arc::new(crate::channel::PeerChannel::new(
89 self.inner.clone(),
90 &t.config.id,
91 )) as Arc<dyn okbot_core::Channel>
92 })
93 .collect()
94 }
95
96 pub async fn check(&self) -> Vec<(String, bool, String)> {
100 let mut out = Vec::new();
101 for team in self.inner.teams.values() {
102 let label = format!("team {}", team.config.id);
103 if let Err(error) = self.inner.refresh(team).await {
104 out.push((label, false, error.to_string()));
105 continue;
106 }
107 let manifest = team.manifest();
108 let problems: Vec<String> = self
109 .inner
110 .check_membership()
111 .into_iter()
112 .filter(|p| p.starts_with(&format!("team `{}`", team.config.id)))
113 .collect();
114 if !problems.is_empty() {
115 out.push((label, false, problems.join("; ")));
116 continue;
117 }
118 if let Some(warning) = self.inner.expiry_warning(team) {
119 out.push((label.clone(), false, warning));
120 }
121 let callees = manifest.callees(&team.config.member);
122 out.push((
123 label.clone(),
124 true,
125 format!(
126 "member `{}`, version {}{}; may call {}; {}",
127 team.config.member,
128 manifest.team.version,
129 manifest
130 .team
131 .expires
132 .map(|e| format!(" (expires {e})"))
133 .unwrap_or_default(),
134 if callees.is_empty() {
135 "nobody".into()
136 } else {
137 callees.join(", ")
138 },
139 if team.config.accept {
140 "accepts tasks"
141 } else {
142 "accepts no tasks"
143 },
144 ),
145 ));
146 for name in callees {
147 let member = &manifest.members[name];
148 let (ok, detail) = match (&member.dir, &member.url, &member.relay) {
149 (Some(dir), _, _) => {
150 let socket = crate::node::socket_path(&self.inner.sockets, dir);
151 if socket.exists() {
152 (true, format!("socket {}", socket.display()))
153 } else {
154 (
155 false,
156 format!("{} is missing: is that bot running?", socket.display()),
157 )
158 }
159 }
160 (_, Some(url), _) => match self.inner.http.get(url).send().await {
161 Ok(r) if r.status().as_u16() == 401 => (true, url.clone()),
164 Ok(r)
165 if team.config.id == crate::external::TEAM
166 && r.status().as_u16() < 500 =>
167 {
168 (true, url.clone())
169 }
170 Ok(r) => (
171 false,
172 format!("{url} answered HTTP {}", r.status().as_u16()),
173 ),
174 Err(e) => (false, format!("{url}: {}", e.without_url())),
175 },
176 (_, _, Some(relay)) => (true, format!("through `{relay}`")),
177 _ => (false, "no way to reach it".into()),
178 };
179 out.push((format!("{label} → {name}"), ok, detail));
180 }
181 }
182 out
183 }
184
185 pub fn teams(&self) -> Vec<String> {
187 self.inner.teams.keys().cloned().collect()
188 }
189}
190
191pub(crate) fn task_from(team: &str, peer: &str, value: &Value) -> Result<PeerTask, PeerError> {
193 let task = a2a::Task::from_wire(a2a::unwrap_send_result(value))
194 .ok_or_else(|| PeerError::Refused(format!("`{peer}` sent an invalid task")))?;
195 let state = task.state;
196 let message = task.message.as_ref();
197 let text = message.map(a2a::Message::text).filter(|t| !t.is_empty());
198 let prompt = message
199 .and_then(a2a::Message::data)
200 .filter(|_| state == PeerTaskState::InputRequired)
201 .and_then(|data| {
202 let id = data.get("prompt")?.as_str()?.to_string();
203 let buttons = data
204 .get("buttons")?
205 .as_array()?
206 .iter()
207 .filter_map(|b| {
208 Some((
209 b.get("id")?.as_str()?.to_string(),
210 b.get("label")?.as_str()?.to_string(),
211 ))
212 })
213 .collect();
214 Some(PeerPrompt { id, buttons })
215 });
216 let tokens = message
217 .and_then(|m| m.metadata.as_ref())
218 .and_then(|m| m.pointer(&format!("/{}/tokens", a2a::USAGE_KEY.replace('/', "~1"))))
219 .and_then(Value::as_u64);
220 Ok(PeerTask {
221 team: team.to_string(),
222 peer: peer.to_string(),
223 id: task.id,
224 state,
225 text,
226 prompt,
227 tokens,
228 })
229}
230
231impl Node {
232 fn team(&self, id: &str) -> Result<&Team, PeerError> {
233 self.inner
234 .teams
235 .get(id)
236 .ok_or_else(|| PeerError::NoRoute(format!("no team `{id}`")))
237 }
238
239 async fn record(&self, task: &PeerTask, delegation: Option<(&Delegation, &str)>) {
240 let now = self.inner.now();
241 let existing = self
242 .inner
243 .store
244 .peer_task(false, &task.id)
245 .await
246 .ok()
247 .flatten();
248 let record = match (existing, delegation) {
249 (Some(old), _) => PeerTaskRecord {
250 state: task.state,
251 text: task.text.clone(),
252 tokens: task.tokens.or(old.tokens),
253 updated_at: now,
254 ..old
255 },
256 (None, Some((d, context_id))) => PeerTaskRecord {
257 id: task.id.clone(),
258 inbound: false,
259 team: task.team.clone(),
260 peer: task.peer.clone(),
261 context_id: context_id.to_string(),
262 message_id: d.message_id.clone(),
263 state: task.state,
264 conversation: d
265 .context
266 .origin
267 .conversation
268 .parse::<ConversationKey>()
269 .ok(),
270 trace: d.context.trace.clone(),
271 text: task.text.clone(),
272 requester: d.requester.clone(),
273 direct: d.direct,
274 tokens: task.tokens,
275 created_at: now,
276 updated_at: now,
277 },
278 (None, None) => return,
279 };
280 if let Err(error) = self.inner.store.put_peer_task(record).await {
281 tracing::warn!(%error, "cannot record a delegated task");
282 }
283 }
284
285 async fn get(&self, team: &Team, peer: &str, id: &str) -> Result<PeerTask, PeerError> {
286 let version = self.inner.version_of(team, peer);
287 let request = a2a::Request::new(a2a::method(a2a::Op::Get, version), json!({"id": id}));
288 let value = self.inner.call(team, peer, &request).await?;
289 task_from(&team.config.id, peer, &value)
290 }
291
292 async fn wait(&self, team: &Team, mut task: PeerTask, until: Instant) -> PeerTask {
294 let mut every = POLL_FIRST;
295 while !task.state.is_final() && task.state != PeerTaskState::InputRequired {
296 let left = until.saturating_duration_since(Instant::now());
297 if left.is_zero() {
298 break;
299 }
300 tokio::time::sleep(every.min(left)).await;
301 every = (every * 2).min(POLL_MAX);
302 match self.get(team, &task.peer, &task.id).await {
303 Ok(next) => task = next,
304 Err(error) => {
305 tracing::debug!(%error, "polling a delegated task failed");
306 }
307 }
308 }
309 task
310 }
311}
312
313#[async_trait]
314impl Peers for Node {
315 async fn directory(&self) -> Result<Vec<PeerInfo>, PeerError> {
316 let mut out = Vec::new();
317 for team in self.inner.teams.values() {
318 let manifest = team.manifest();
319 for name in manifest.callees(&team.config.member) {
320 let member = &manifest.members[name];
321 out.push(PeerInfo {
322 team: team.config.id.clone(),
323 name: name.to_string(),
324 roles: member.roles.clone(),
325 skills: member.skills.clone(),
326 description: member.description.clone(),
327 });
328 }
329 }
330 Ok(out)
331 }
332
333 async fn delegate(&self, delegation: Delegation) -> Result<PeerTask, PeerError> {
334 let started = Instant::now();
335 let mut candidates = Vec::new();
336 for team in self.inner.teams.values() {
337 if delegation
338 .team
339 .as_ref()
340 .is_some_and(|t| *t != team.config.id)
341 {
342 continue;
343 }
344 let manifest = team.manifest();
345 for member in manifest.route(&team.config.member, &delegation.to) {
346 candidates.push((team, member.to_string()));
347 }
348 }
349 if candidates.is_empty() {
350 return Err(PeerError::NoRoute(delegation.to.to_string()));
351 }
352 candidates.sort_by_key(|(team, member)| self.inner.is_down(&team.config.id, member));
354 let mut last = None;
355 for (team, member) in candidates {
356 let mut context = delegation.context.clone();
357 if context.origin.bot.is_empty() {
358 context.origin.bot.clone_from(&team.config.member);
359 }
360 context.chain.push(team.config.member.clone());
361 let own = self.inner.now()
362 + jiff::SignedDuration::try_from(team.config.task_timeout)
363 .unwrap_or(jiff::SignedDuration::from_secs(600));
364 context.deadline = context.deadline.min(own);
365 let context_id = hex::encode(Sha256::digest(format!(
368 "{}\n{}",
369 context.origin.conversation, team.config.member
370 )))[..24]
371 .to_string();
372 let mut message = a2a::Message::user(
373 &delegation.message_id,
374 &context_id,
375 vec![Part::Text(delegation.task.clone())],
376 );
377 if team.config.id == crate::external::TEAM {
378 message.extensions.clear();
380 } else {
381 message = message.with_context(&context);
382 }
383 let version = self.inner.version_of(team, &member);
384 let request = a2a::Request::new(
385 a2a::method(a2a::Op::Send, version),
386 json!({"message": message.to_wire(version)}),
387 );
388 match self.inner.call(team, &member, &request).await {
389 Ok(value) => {
390 let task = task_from(&team.config.id, &member, &value)?;
391 tracing::info!(team = %team.config.id, %member, task = %task.id, trace = %context.trace, "task delegated");
392 self.record(&task, Some((&delegation, &context_id))).await;
393 let task = self.wait(team, task, started + delegation.wait).await;
394 self.record(&task, None).await;
395 return Ok(task);
396 }
397 Err(error) => {
398 tracing::warn!(team = %team.config.id, %member, %error, "delegation failed");
399 last = Some(error);
400 }
401 }
402 }
403 Err(last.unwrap_or_else(|| PeerError::NoRoute(delegation.to.to_string())))
404 }
405
406 async fn status(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError> {
407 let team = self.team(team)?;
408 let task = self.get(team, peer, task).await?;
409 self.record(&task, None).await;
410 Ok(task)
411 }
412
413 async fn cancel(&self, team: &str, peer: &str, task: &str) -> Result<PeerTask, PeerError> {
414 let team = self.team(team)?;
415 let version = self.inner.version_of(team, peer);
416 let request = a2a::Request::new(a2a::method(a2a::Op::Cancel, version), json!({"id": task}));
417 let value = self.inner.call(team, peer, &request).await?;
418 let task = task_from(&team.config.id, peer, &value)?;
419 self.record(&task, None).await;
420 Ok(task)
421 }
422
423 async fn report_usage(&self, conversation: &ConversationKey, task: Option<&str>, tokens: u64) {
424 crate::inbound::usage(&self.inner, conversation, task, tokens);
425 }
426
427 fn progress(&self, conversation: &ConversationKey, task: Option<&str>, text: &str) {
428 crate::inbound::progress(&self.inner, conversation, task, text);
429 }
430
431 async fn fail(&self, conversation: &ConversationKey, task: Option<&str>, reason: &str) {
432 crate::inbound::fail(&self.inner, conversation, task, reason).await;
433 }
434
435 async fn answer(
436 &self,
437 task: &PeerTask,
438 prompt: &str,
439 button: &str,
440 ) -> Result<PeerTask, PeerError> {
441 let team = self.team(&task.team)?;
442 let mut message = a2a::Message::user(
443 &hex::encode(rand::random::<[u8; 8]>()),
444 "",
445 vec![Part::Data(json!({"prompt": prompt, "button": button}))],
446 );
447 message.context_id = None;
448 message.task_id = Some(task.id.clone());
449 let version = self.inner.version_of(team, &task.peer);
450 let request = a2a::Request::new(
451 a2a::method(a2a::Op::Send, version),
452 json!({"message": message.to_wire(version)}),
453 );
454 let value = self.inner.call(team, &task.peer, &request).await?;
455 let next = task_from(&team.config.id, &task.peer, &value)?;
456 self.record(&next, None).await;
457 Ok(next)
458 }
459
460 async fn caller(&self, conversation: &ConversationKey) -> Option<okbot_core::PeerCaller> {
461 lock(&self.inner.callers).get(conversation).cloned()
462 }
463
464 fn team_person(&self, account: &str) -> Option<String> {
465 self.inner
466 .teams
467 .values()
468 .find_map(|t| t.manifest().person_of(account).map(str::to_string))
469 }
470
471 fn member_name(&self, team: &str) -> Option<String> {
472 self.inner.teams.get(team).map(|t| t.config.member.clone())
473 }
474}