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