Skip to content
614 linesCodeBlameRaw
1//! What sessions touched, and the issues and pull requests they worked on.
2//!
3//! A session is tied to an issue or pull request in as many ways as it
4//! was (`LinkVia`): attached when it started or updated, a claim it made,
5//! a pull request it opened or a branch or commit of one, a
6//! `G1t-Session:` trailer on a commit pushed to one, and `#N` in its
7//! prompt, its task or a subagent's. Each way is a row of
8//! `session_links`; reading a session gathers its whole tree's
9//! (`gather_links`). Files two sessions touched make them overlap
10//! (`overlap.detected`, once per pair).
11
12use std::collections::{HashMap, HashSet};
13
14use g1t_contracts::Outcome;
15use g1t_contracts::repos::{Commit, LogArgs, RepoPath};
16use g1t_contracts::time::rfc3339;
17use g1t_contracts::work::*;
18use g1t_kit::now_ms;
19use serde::Deserialize;
20use worker::Result;
21
22use super::{SESSION_COLUMNS, SessionRow};
23use crate::Work;
24
25/// The start (`?2` characters) of the first prompt each session in `?1`
26/// (a JSON list of ids) recorded, by the session's own numbering.
27pub(crate) const FIRST_PROMPTS: &str = "SELECT session_id, text FROM (
28 SELECT ids.value AS session_id,
29 (SELECT substr(e.text, 1, ?2) FROM session_entries e
30 WHERE e.session_id = ids.value AND e.kind = 'prompt' ORDER BY e.session_seq LIMIT 1) AS text
31 FROM json_each(?1) AS ids)
32 WHERE text IS NOT NULL";
33
34/// The most `#N` of one text looked up.
35const MAX_REFS: usize = 10;
36/// The most issues and pull requests a session's links name.
37const MAX_LINKED: usize = 40;
38/// The most commits of one push read for trailers.
39const MAX_TRAILER_COMMITS: u32 = 20;
40/// The most subagents a tree shows.
41pub(crate) const MAX_TREE: u32 = 200;
42
43/// Ties session `?1` of repository `?2` to issue or pull request (`?3`)
44/// `?4` by way of `?5`, at `?6`; nothing when it is tied that way already.
45pub(crate) const INSERT_LINK: &str = "INSERT INTO session_links (session_id, repo_id, kind, number, via, at)
46 VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT DO NOTHING";
47
48/// Records that session `?1` of repository `?2` touched (`?3`) `?4`, at
49/// `?5`; returns it only the first time.
50pub(crate) const INSERT_TOUCH: &str = "INSERT INTO session_touches (session_id, repo_id, kind, value, at)
51 VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT DO NOTHING RETURNING kind, value";
52
53/// Session `?1`'s subagents, and theirs, in the order they started.
54pub(crate) fn tree_sql() -> String {
55 format!(
56 "WITH RECURSIVE tree(id) AS (
57 SELECT id FROM sessions WHERE parent_id = ?1
58 UNION SELECT s.id FROM sessions s JOIN tree ON s.parent_id = tree.id)
59 SELECT {SESSION_COLUMNS} FROM sessions WHERE id IN (SELECT id FROM tree)
60 ORDER BY started_at, id LIMIT {MAX_TREE}"
61 )
62}
63
64/// Ends session `?1`'s subagents still going, and theirs, at `?2` for
65/// `?3`.
66pub(crate) fn end_tree_sql() -> String {
67 format!(
68 "WITH RECURSIVE tree(id) AS (
69 SELECT id FROM sessions WHERE parent_id = ?1
70 UNION SELECT s.id FROM sessions s JOIN tree ON s.parent_id = tree.id)
71 UPDATE sessions SET state = 'ended', ended_at = ?2, end_reason = ?3, last_seen_at = ?2
72 WHERE id IN (SELECT id FROM tree) AND state = 'active'
73 RETURNING {SESSION_COLUMNS}"
74 )
75}
76
77/// Session `?1`'s line: its parents up to the top, and its subagents and
78/// theirs. Never told of overlapping with each other.
79pub(crate) const FAMILY: &str = "WITH RECURSIVE
80 up(id, parent) AS (
81 SELECT id, parent_id FROM sessions WHERE id = ?1
82 UNION SELECT s.id, s.parent_id FROM sessions s JOIN up ON s.id = up.parent),
83 down(id) AS (
84 SELECT id FROM sessions WHERE parent_id = ?1
85 UNION SELECT s.id FROM sessions s JOIN down ON s.parent_id = down.id)
86 SELECT id FROM up UNION SELECT id FROM down";
87
88/// The files of `?2` (JSON) that active sessions of repository `?1` other
89/// than `?3` touched.
90pub(crate) const TOUCHED_BY_OTHERS: &str = "SELECT t.session_id AS session_id, t.value AS value
91 FROM session_touches t JOIN sessions s ON s.id = t.session_id
92 WHERE t.repo_id = ?1 AND t.kind = 'file' AND t.value IN (SELECT value FROM json_each(?2))
93 AND t.session_id <> ?3 AND s.state = 'active'";
94
95/// The sessions of repository `?1` that pushed the branch of one of the
96/// pull requests `?2` (JSON numbers).
97pub(crate) const BRANCH_SESSIONS: &str = "SELECT DISTINCT t.session_id AS id FROM session_touches t
98 JOIN pulls p ON p.repo_id = t.repo_id AND p.source_branch = t.value AND p.fork_repo_id IS NULL
99 WHERE t.repo_id = ?1 AND t.kind = 'branch' AND p.number IN (SELECT value FROM json_each(?2)) LIMIT 200";
100
101/// Announces the pair `?2`, `?3` of repository `?1` as overlapping on
102/// `?4` (JSON); returns it only the first time.
103pub(crate) const INSERT_OVERLAP: &str = "INSERT INTO session_overlaps (repo_id, low, high, files, noticed_at)
104 VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT DO NOTHING RETURNING low";
105
106#[derive(Deserialize)]
107struct IdRow {
108 id: String,
109}
110
111#[derive(Deserialize)]
112struct NumberOnly {
113 number: u32,
114}
115
116#[derive(Deserialize)]
117pub(crate) struct LinkRow {
118 session_id: String,
119 kind: String,
120 number: u32,
121 via: String,
122}
123
124#[derive(Deserialize)]
125pub(crate) struct TouchRow {
126 session_id: String,
127 kind: String,
128 value: String,
129 at: String,
130}
131
132#[derive(Deserialize)]
133struct PairRow {
134 session_id: String,
135 value: String,
136}
137
138#[derive(Deserialize)]
139struct SkillRow {
140 session_id: String,
141 name: String,
142 uses: u32,
143}
144
145fn kind_name(kind: LinkKind) -> &'static str {
146 match kind {
147 LinkKind::Issue => "issue",
148 LinkKind::Pull => "pull",
149 }
150}
151
152impl Work {
153 /// Ties `session_id` to an issue or pull request one way. Never fails
154 /// what it follows: a problem is logged.
155 pub(crate) async fn link(&self, session_id: &str, repo_id: &str, kind: LinkKind, number: u32, via: LinkVia) {
156 let at = rfc3339(now_ms());
157 let linked = async {
158 self.db
159 .prepare(INSERT_LINK)
160 .bind(&[session_id.into(), repo_id.into(), kind_name(kind).into(), number.into(), via.as_str().into(), at.into()])?
161 .run()
162 .await
163 };
164 if let Err(error) = linked.await {
165 worker::console_error!("session {session_id}: not tied to {} #{number}: {error}", kind_name(kind));
166 }
167 }
168
169 /// Ties a session to the issues and pull requests `#N` in `text` names
170 /// that the repository has.
171 pub(crate) async fn link_refs(&self, session_id: &str, repo_id: &str, text: &str) -> Result<()> {
172 for number in refs_in(text).into_iter().take(MAX_REFS) {
173 if self.issue(repo_id, number).await?.is_some() {
174 self.link(session_id, repo_id, LinkKind::Issue, number, LinkVia::Ref).await;
175 } else if self.pull(repo_id, number).await?.is_some() {
176 self.link(session_id, repo_id, LinkKind::Pull, number, LinkVia::Ref).await;
177 }
178 }
179 Ok(())
180 }
181
182 /// Ties a session to the pull requests of `branch`.
183 pub(crate) async fn link_branch(&self, session_id: &str, repo_id: &str, branch: &str) -> Result<()> {
184 let pulls = self
185 .db
186 .prepare("SELECT number FROM pulls WHERE repo_id = ? AND source_branch = ? AND fork_repo_id IS NULL")
187 .bind(&[repo_id.into(), branch.into()])?
188 .all()
189 .await?
190 .results::<NumberOnly>()?;
191 for pull in pulls {
192 self.link(session_id, repo_id, LinkKind::Pull, pull.number, LinkVia::Branch).await;
193 }
194 Ok(())
195 }
196
197 /// Ties a session to the pull requests whose head is `commit` (a full
198 /// or abbreviated hash).
199 pub(crate) async fn link_commit(&self, session_id: &str, repo_id: &str, commit: &str) -> Result<()> {
200 let pulls = self
201 .db
202 .prepare("SELECT number FROM pulls WHERE repo_id = ? AND head_commit IS NOT NULL AND substr(head_commit, 1, length(?2)) = ?2")
203 .bind(&[repo_id.into(), commit.into()])?
204 .all()
205 .await?
206 .results::<NumberOnly>()?;
207 for pull in pulls {
208 self.link(session_id, repo_id, LinkKind::Pull, pull.number, LinkVia::Commit).await;
209 }
210 Ok(())
211 }
212
213 /// Records what a session touched, ties it to what that names, and
214 /// tells it and the other sessions on the same files that they
215 /// overlap. Returns how many were new.
216 pub(crate) async fn record_touches(&self, session: &Session, touches: &[NewTouch]) -> Result<u32> {
217 let touches = tidy_touches(touches);
218 if touches.is_empty() {
219 return Ok(0);
220 }
221 let at = rfc3339(now_ms());
222 let mut statements = Vec::with_capacity(touches.len());
223 for touch in &touches {
224 statements.push(self.db.prepare(INSERT_TOUCH).bind(&[
225 session.id.as_str().into(),
226 session.repo_id.as_str().into(),
227 touch.kind.as_str().into(),
228 touch.value.as_str().into(),
229 at.as_str().into(),
230 ])?);
231 }
232 let mut new: Vec<NewTouch> = Vec::new();
233 for result in self.db.batch(statements).await? {
234 for row in result.results::<serde_json::Value>()? {
235 if let (Some(kind), Some(value)) = (row["kind"].as_str().and_then(TouchKind::parse), row["value"].as_str()) {
236 new.push(NewTouch { kind, value: value.to_owned() });
237 }
238 }
239 }
240 let mut files = Vec::new();
241 for touch in &new {
242 let number = touch.value.parse::<u32>().ok();
243 match (touch.kind, number) {
244 (TouchKind::Pull, Some(number)) => self.link(&session.id, &session.repo_id, LinkKind::Pull, number, LinkVia::Opened).await,
245 (TouchKind::Issue, Some(number)) => {
246 if self.issue(&session.repo_id, number).await?.is_some() {
247 self.link(&session.id, &session.repo_id, LinkKind::Issue, number, LinkVia::Ref).await;
248 }
249 }
250 (TouchKind::Branch, _) => self.link_branch(&session.id, &session.repo_id, &touch.value).await?,
251 (TouchKind::Commit, _) => self.link_commit(&session.id, &session.repo_id, &touch.value).await?,
252 (TouchKind::File, _) => files.push(touch.value.clone()),
253 _ => {}
254 }
255 }
256 if !files.is_empty() {
257 self.announce_session_overlaps(session, &files).await?;
258 }
259 Ok(new.len() as u32)
260 }
261
262 /// The ids of a session's line: its parents and its subagents.
263 pub(crate) async fn family(&self, session_id: &str) -> Result<HashSet<String>> {
264 Ok(self
265 .db
266 .prepare(FAMILY)
267 .bind(&[session_id.into()])?
268 .all()
269 .await?
270 .results::<IdRow>()?
271 .into_iter()
272 .map(|row| row.id)
273 .collect())
274 }
275
276 /// Tells `session` and each other active session that touched one of
277 /// `files` (outside its own line) that they overlap, once per pair.
278 async fn announce_session_overlaps(&self, session: &Session, files: &[String]) -> Result<()> {
279 let touched: Vec<(String, String)> = self
280 .db
281 .prepare(TOUCHED_BY_OTHERS)
282 .bind(&[session.repo_id.as_str().into(), serde_json::to_string(files)?.into(), session.id.as_str().into()])?
283 .all()
284 .await?
285 .results::<PairRow>()?
286 .into_iter()
287 .map(|row| (row.session_id, row.value))
288 .collect();
289 if touched.is_empty() {
290 return Ok(());
291 }
292 let family = self.family(&session.id).await?;
293 let at = rfc3339(now_ms());
294 for (other_id, files) in session_overlaps(&session.id, &family, &touched) {
295 let (low, high) = session_pair(&session.id, &other_id);
296 let new = self
297 .db
298 .prepare(INSERT_OVERLAP)
299 .bind(&[session.repo_id.as_str().into(), low.into(), high.into(), serde_json::to_string(&files)?.into(), at.as_str().into()])?
300 .first::<serde_json::Value>(None)
301 .await?;
302 if new.is_none() {
303 continue;
304 }
305 let Some(other) = self.session_row(&session.repo_id, &other_id).await? else {
306 continue;
307 };
308 let other = other.at(now_ms());
309 let ours = SessionOverlapDetected {
310 repo_id: session.repo_id.clone(),
311 number: session.pull.or(session.issue),
312 session_id: session.id.clone(),
313 other_session_id: other.id.clone(),
314 other_actor: other.actor.username.clone(),
315 files: files.clone(),
316 issue: session.issue,
317 other_issue: other.issue,
318 };
319 let theirs = SessionOverlapDetected {
320 number: other.pull.or(other.issue),
321 session_id: other.id.clone(),
322 other_session_id: session.id.clone(),
323 other_actor: session.actor.username.clone(),
324 issue: other.issue,
325 other_issue: session.issue,
326 ..ours.clone()
327 };
328 self.publish_as("overlap.detected", &session.repo_id, Some(session.actor.id.clone()), ours).await?;
329 self.publish_as("overlap.detected", &session.repo_id, Some(session.actor.id.clone()), theirs).await?;
330 }
331 Ok(())
332 }
333
334 /// A session's subagents, and theirs, in the order they started, each
335 /// with how many it started.
336 pub(crate) async fn subagents_of(&self, session_id: &str, now: u64) -> Result<Vec<Session>> {
337 let rows = self.db.prepare(tree_sql()).bind(&[session_id.into()])?.all().await?.results::<SessionRow>()?;
338 let mut children: Vec<Session> = rows.into_iter().map(|row| row.at(now)).collect();
339 count_subagents(&mut children);
340 Ok(children)
341 }
342
343 /// How many subagents each of `sessions` started, filled in.
344 pub(crate) async fn fill_subagents(&self, sessions: &mut [Session]) -> Result<()> {
345 if sessions.is_empty() {
346 return Ok(());
347 }
348 let ids: Vec<&str> = sessions.iter().map(|session| session.id.as_str()).collect();
349 #[derive(Deserialize)]
350 struct Count {
351 parent_id: String,
352 n: u32,
353 }
354 let counts = self
355 .db
356 .prepare("SELECT parent_id, COUNT(*) AS n FROM sessions WHERE parent_id IN (SELECT value FROM json_each(?)) GROUP BY parent_id")
357 .bind(&[serde_json::to_string(&ids)?.into()])?
358 .all()
359 .await?
360 .results::<Count>()?;
361 let counts: HashMap<String, u32> = counts.into_iter().map(|count| (count.parent_id, count.n)).collect();
362 for session in sessions {
363 session.subagents = counts.get(&session.id).copied().unwrap_or_default();
364 }
365 Ok(())
366 }
367
368 /// Gives each session with no title the start of the first prompt it
369 /// recorded, as `first_prompt`, for saying what it is for.
370 pub(crate) async fn fill_first_prompts(&self, sessions: &mut [Session]) -> Result<()> {
371 let ids: Vec<&str> = sessions.iter().filter(|session| session.title.is_none()).map(|session| session.id.as_str()).collect();
372 if ids.is_empty() {
373 return Ok(());
374 }
375 #[derive(Deserialize)]
376 struct First {
377 session_id: String,
378 text: String,
379 }
380 let found = self
381 .db
382 .prepare(FIRST_PROMPTS)
383 .bind(&[serde_json::to_string(&ids)?.into(), (MAX_FIRST_PROMPT_CHARS as u32).into()])?
384 .all()
385 .await?
386 .results::<First>()?;
387 let found: HashMap<String, String> = found.into_iter().map(|first| (first.session_id, first.text)).collect();
388 for session in sessions.iter_mut().filter(|session| session.title.is_none()) {
389 session.first_prompt = found.get(&session.id).map(|text| text.trim().to_owned()).filter(|text| !text.is_empty());
390 }
391 Ok(())
392 }
393
394 /// What a tree of sessions touched, its links, and the skills it used.
395 pub(crate) async fn tree_facts(&self, repo_id: &str, ids: &[String]) -> Result<(Vec<SessionTouch>, Vec<SessionLink>, Vec<SessionSkill>)> {
396 let ids_json = serde_json::to_string(ids)?;
397 let touches = self
398 .db
399 .prepare(
400 "SELECT session_id, kind, value, at FROM session_touches
401 WHERE session_id IN (SELECT value FROM json_each(?)) ORDER BY at, rowid LIMIT 1000",
402 )
403 .bind(&[ids_json.as_str().into()])?
404 .all()
405 .await?
406 .results::<TouchRow>()?;
407 // A pull request opened on a branch after the session pushed it.
408 let branches: HashSet<(String, String)> = touches
409 .iter()
410 .filter(|touch| touch.kind == "branch")
411 .map(|touch| (touch.session_id.clone(), touch.value.clone()))
412 .collect();
413 for (session_id, branch) in branches.iter().take(20) {
414 self.link_branch(session_id, repo_id, branch).await?;
415 }
416 let records: Vec<LinkRecord> = self
417 .db
418 .prepare(
419 "SELECT session_id, kind, number, via FROM session_links
420 WHERE session_id IN (SELECT value FROM json_each(?)) ORDER BY at",
421 )
422 .bind(&[ids_json.as_str().into()])?
423 .all()
424 .await?
425 .results::<LinkRow>()?
426 .into_iter()
427 .filter_map(|row| {
428 let kind = match row.kind.as_str() {
429 "issue" => LinkKind::Issue,
430 "pull" => LinkKind::Pull,
431 _ => return None,
432 };
433 Some(LinkRecord { session_id: row.session_id, kind, number: row.number, via: LinkVia::parse(&row.via)? })
434 })
435 .collect();
436 let mut known = HashMap::new();
437 let mut wanted: Vec<(LinkKind, u32)> = Vec::new();
438 for record in &records {
439 if !wanted.contains(&(record.kind, record.number)) {
440 wanted.push((record.kind, record.number));
441 }
442 }
443 for (kind, number) in wanted.into_iter().take(MAX_LINKED) {
444 let found = match kind {
445 LinkKind::Issue => self.issue(repo_id, number).await?.map(|issue| (issue.title, state_name(&issue.state))),
446 LinkKind::Pull => self.pull(repo_id, number).await?.map(|pull| (pull.title, state_name(&pull.status))),
447 };
448 if let Some(found) = found {
449 known.insert((kind, number), found);
450 }
451 }
452 let links = gather_links(&records, &known);
453 let skills = self
454 .db
455 .prepare(
456 "SELECT session_id, text AS name, COUNT(*) AS uses FROM session_entries
457 WHERE session_id IN (SELECT value FROM json_each(?)) AND kind = 'skill'
458 GROUP BY session_id, text ORDER BY MIN(session_seq)",
459 )
460 .bind(&[ids_json.as_str().into()])?
461 .all()
462 .await?
463 .results::<SkillRow>()?
464 .into_iter()
465 .map(|row| SessionSkill { session_id: row.session_id, name: row.name, uses: row.uses })
466 .collect();
467 let touched = touches
468 .into_iter()
469 .filter_map(|row| {
470 Some(SessionTouch { session_id: row.session_id, kind: TouchKind::parse(&row.kind)?, value: row.value, at: row.at })
471 })
472 .collect();
473 Ok((touched, links, skills))
474 }
475
476 /// After a push moved `pulls` on `branch` to `after`: the sessions that
477 /// pushed that branch, and those its new commits name in
478 /// `G1t-Session:` trailers, are tied to them. Never fails the push's
479 /// handling: a problem is logged.
480 pub(crate) async fn link_push(&self, repo_id: &str, branch: &str, after: &str, commits: Option<u32>, pulls: &[Pull]) {
481 if pulls.is_empty() {
482 return;
483 }
484 let linked = async {
485 let pushed_by = self
486 .db
487 .prepare("SELECT DISTINCT session_id AS id FROM session_touches WHERE repo_id = ? AND kind = 'branch' AND value = ?")
488 .bind(&[repo_id.into(), branch.into()])?
489 .all()
490 .await?
491 .results::<IdRow>()?;
492 for session in &pushed_by {
493 for pull in pulls {
494 self.link(&session.id, repo_id, LinkKind::Pull, pull.number, LinkVia::Branch).await;
495 }
496 }
497 // Trailers are read only while someone records sessions here.
498 let recording = self
499 .db
500 .prepare("SELECT id FROM sessions WHERE repo_id = ? AND state = 'active' LIMIT 1")
501 .bind(&[repo_id.into()])?
502 .first::<IdRow>(None)
503 .await?;
504 if recording.is_none() {
505 return Ok::<(), worker::Error>(());
506 }
507 let Some(pull) = pulls.first() else { return Ok(()) };
508 let path: Option<RepoPath> =
509 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id: repo_id.to_owned() }).await?;
510 let Some(path) = path else { return Ok(()) };
511 let log = LogArgs {
512 path,
513 viewer: self.owner_viewer(pull).await?,
514 git_ref: Some(after.to_owned()),
515 limit: commits.unwrap_or(MAX_TRAILER_COMMITS).clamp(1, MAX_TRAILER_COMMITS),
516 };
517 let Outcome::Ok(history) = g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &log).await? else {
518 return Ok(());
519 };
520 let mut named: Vec<String> = Vec::new();
521 for commit in &history {
522 for id in session_trailers(&commit.message) {
523 if !named.contains(&id) {
524 named.push(id);
525 }
526 }
527 }
528 for id in named {
529 if self.session_row(repo_id, &id).await?.is_none() {
530 continue;
531 }
532 for pull in pulls {
533 self.link(&id, repo_id, LinkKind::Pull, pull.number, LinkVia::Trailer).await;
534 }
535 }
536 Ok(())
537 };
538 if let Err(error) = linked.await {
539 worker::console_error!("push to {branch}: sessions not tied to its pull requests: {error}");
540 }
541 }
542
543 /// Sessions tied to an issue or pull request (`kind`, `number`) by a
544 /// link, by id.
545 pub(crate) async fn linked_sessions(&self, repo_id: &str, kind: LinkKind, number: u32) -> Result<Vec<String>> {
546 Ok(self
547 .db
548 .prepare("SELECT DISTINCT session_id AS id FROM session_links WHERE repo_id = ? AND kind = ? AND number = ? LIMIT 200")
549 .bind(&[repo_id.into(), kind_name(kind).into(), number.into()])?
550 .all()
551 .await?
552 .results::<IdRow>()?
553 .into_iter()
554 .map(|row| row.id)
555 .collect())
556 }
557
558 /// Sessions that pushed one of `pulls`' branches, by id: tied to them
559 /// even when the pull request was opened after the push.
560 pub(crate) async fn branch_sessions(&self, repo_id: &str, pulls: &[u32]) -> Result<Vec<String>> {
561 if pulls.is_empty() {
562 return Ok(Vec::new());
563 }
564 Ok(self
565 .db
566 .prepare(BRANCH_SESSIONS)
567 .bind(&[repo_id.into(), serde_json::to_string(pulls)?.into()])?
568 .all()
569 .await?
570 .results::<IdRow>()?
571 .into_iter()
572 .map(|row| row.id)
573 .collect())
574 }
575
576 /// Sessions by id, as they read now.
577 pub(crate) async fn sessions_by_id(&self, repo_id: &str, ids: &[String]) -> Result<Vec<Session>> {
578 if ids.is_empty() {
579 return Ok(Vec::new());
580 }
581 let now = now_ms();
582 Ok(self
583 .db
584 .prepare(format!(
585 "SELECT {SESSION_COLUMNS} FROM sessions WHERE repo_id = ? AND id IN (SELECT value FROM json_each(?))"
586 ))
587 .bind(&[repo_id.into(), serde_json::to_string(ids)?.into()])?
588 .all()
589 .await?
590 .results::<SessionRow>()?
591 .into_iter()
592 .map(|row| row.at(now))
593 .collect())
594 }
595}
596
597/// An issue's state or a pull request's status, as its JSON name.
598fn state_name<T: serde::Serialize>(state: &T) -> String {
599 serde_json::to_value(state).ok().and_then(|value| value.as_str().map(str::to_owned)).unwrap_or_default()
600}
601
602/// Fills in how many subagents each session of a tree started, from the
603/// tree itself.
604pub(crate) fn count_subagents(sessions: &mut [Session]) {
605 let mut counts: HashMap<String, u32> = HashMap::new();
606 for session in sessions.iter() {
607 if let Some(parent) = &session.parent_id {
608 *counts.entry(parent.clone()).or_default() += 1;
609 }
610 }
611 for session in sessions.iter_mut() {
612 session.subagents = counts.get(&session.id).copied().unwrap_or_default();
613 }
614}