Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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