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