| 1 | //! Sessions across a workspace (`g1t_contracts::work::workspace_sessions`): |
| 2 | //! every kind of session in every repository of it the viewer may read, |
| 3 | //! in one list, with the merge queues waiting and what just landed. |
| 4 | //! |
| 5 | //! Harness and hosted sessions are the `sessions` table's; g1t's agent is |
| 6 | //! `agent_runs`; a recorded one is a pull request with session entries |
| 7 | //! and nothing else behind it. Who may see what inside a session is the |
| 8 | //! repository's rule (shown.rs), applied to each as it is in its own list. |
| 9 | |
| 10 | use std::collections::{HashMap, HashSet}; |
| 11 | |
| 12 | use g1t_contracts::agents::AgentRun; |
| 13 | use g1t_contracts::repos::{ReadableArgs, Repo, RepoPath}; |
| 14 | use g1t_contracts::time::{parse_rfc3339, rfc3339}; |
| 15 | use g1t_contracts::work::*; |
| 16 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 17 | use g1t_kit::now_ms; |
| 18 | use serde::Deserialize; |
| 19 | use worker::Result; |
| 20 | use worker::wasm_bindgen::JsValue; |
| 21 | |
| 22 | use super::links::count_subagents; |
| 23 | use super::{SESSION_COLUMNS, SessionRow, shown}; |
| 24 | use crate::Work; |
| 25 | use crate::rows::{PULL_COLUMNS, PullRow}; |
| 26 | use crate::runs::RecordedRow; |
| 27 | |
| 28 | /// The most repositories of a workspace looked in. |
| 29 | const MAX_REPOS: u32 = 500; |
| 30 | /// The most pull requests Just landed gives. |
| 31 | const LANDED: u32 = 8; |
| 32 | /// The most merge queues given. |
| 33 | const QUEUES: usize = 12; |
| 34 | /// The most trees given whole with `subagents`. |
| 35 | const TREES: usize = 30; |
| 36 | |
| 37 | /// The workspace's sessions in the repositories `?1` (a JSON array of |
| 38 | /// ids): `?2` is the time before which a quiet one reads as ended, `?3` |
| 39 | /// whether subagents are listed, `?4` a harness, `?5` a username, `?6` a |
| 40 | /// work item's id, `?7` how many, `?8` a kind (`harness` or `hosted`). |
| 41 | pub(crate) fn workspace_sessions_sql(shown: SessionsShown) -> String { |
| 42 | let state = match shown { |
| 43 | SessionsShown::Live => "AND state = 'active' AND last_seen_at > ?2", |
| 44 | SessionsShown::Recent => "AND ?2 IS NOT NULL", |
| 45 | SessionsShown::Ended => "AND (state = 'ended' OR last_seen_at <= ?2)", |
| 46 | }; |
| 47 | format!( |
| 48 | "SELECT {SESSION_COLUMNS} FROM sessions |
| 49 | WHERE repo_id IN (SELECT value FROM json_each(?1)) {state} |
| 50 | AND (?3 = 1 OR parent_id IS NULL) |
| 51 | AND (?4 IS NULL OR harness = ?4) |
| 52 | AND (?5 IS NULL OR lower(actor_name) = lower(?5)) |
| 53 | AND (?6 IS NULL OR item_id = ?6 |
| 54 | OR id IN (SELECT session_id FROM session_links WHERE kind = 'item' AND item_id = ?6)) |
| 55 | AND (?8 IS NULL OR (?8 = 'hosted') = (harness = 'claude_managed_agents')) |
| 56 | ORDER BY last_seen_at DESC LIMIT ?7" |
| 57 | ) |
| 58 | } |
| 59 | |
| 60 | /// The titles and states of some pull requests, by `{r, n}` pairs in `?1`. |
| 61 | pub(crate) const PULL_FACTS: &str = "SELECT p.repo_id, p.number, p.title, p.status, |
| 62 | (SELECT number FROM work_items WHERE work_items.id = p.item_id) AS item |
| 63 | FROM json_each(?1) j JOIN pulls p ON p.repo_id = json_extract(j.value, '$.r') AND p.number = json_extract(j.value, '$.n')"; |
| 64 | |
| 65 | /// The repositories of `?1` (a JSON array of ids) with something waiting |
| 66 | /// in their merge queue, the busiest first. |
| 67 | pub(crate) const QUEUED_REPOS: &str = "SELECT repo_id AS value FROM queue_entries |
| 68 | WHERE repo_id IN (SELECT value FROM json_each(?1)) AND state IN ('waiting', 'testing', 'passed') |
| 69 | GROUP BY repo_id ORDER BY COUNT(*) DESC, MIN(created_at) LIMIT ?2"; |
| 70 | |
| 71 | #[derive(Debug, Deserialize)] |
| 72 | pub(crate) struct PullFact { |
| 73 | pub(crate) repo_id: String, |
| 74 | pub(crate) number: u32, |
| 75 | pub(crate) title: String, |
| 76 | pub(crate) status: PullStatus, |
| 77 | #[serde(default)] |
| 78 | pub(crate) item: Option<u32>, |
| 79 | } |
| 80 | |
| 81 | #[derive(Deserialize)] |
| 82 | struct Value { |
| 83 | value: String, |
| 84 | } |
| 85 | |
| 86 | #[derive(Deserialize)] |
| 87 | struct Titled { |
| 88 | number: u32, |
| 89 | title: String, |
| 90 | } |
| 91 | |
| 92 | #[derive(Deserialize)] |
| 93 | struct Placed { |
| 94 | repo_id: String, |
| 95 | path: String, |
| 96 | } |
| 97 | |
| 98 | /// A harness or hosted session, as one of the workspace's. |
| 99 | pub(crate) fn from_session(mut session: Session, path: &str, now: u64) -> WorkspaceSession { |
| 100 | let last = parse_rfc3339(&session.last_seen_at).unwrap_or(now); |
| 101 | session.state = settled_state(session.state, last, now); |
| 102 | if session.state == SessionState::Ended && session.ended_at.is_none() { |
| 103 | session.end_reason.get_or_insert_with(|| "timed_out".to_owned()); |
| 104 | } |
| 105 | WorkspaceSession { |
| 106 | kind: SessionKind::of(session.harness), |
| 107 | id: session.id.clone(), |
| 108 | repo: path.to_owned(), |
| 109 | repo_id: session.repo_id.clone(), |
| 110 | title: session.title.clone(), |
| 111 | actor: Some(session.actor.username.clone()), |
| 112 | harness: Some(session.harness), |
| 113 | agent: None, |
| 114 | branch: session.branch.clone(), |
| 115 | item: session.item, |
| 116 | item_title: None, |
| 117 | pull: session.pull, |
| 118 | pull_title: None, |
| 119 | pull_status: None, |
| 120 | state: session.state, |
| 121 | started_at: session.started_at.clone(), |
| 122 | last_activity_at: session.last_seen_at.clone(), |
| 123 | subagents: session.subagents, |
| 124 | step: None, |
| 125 | session: Some(session), |
| 126 | children: Vec::new(), |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// A run of g1t's agent, as one of the workspace's sessions. |
| 131 | pub(crate) fn from_run(run: AgentRun, repo_id: &str) -> WorkspaceSession { |
| 132 | let state = if run.status.is_active() { SessionState::Active } else { SessionState::Ended }; |
| 133 | WorkspaceSession { |
| 134 | kind: SessionKind::Agent, |
| 135 | id: run.id, |
| 136 | repo: format!("{}/{}", run.repo.namespace, run.repo.name), |
| 137 | repo_id: repo_id.to_owned(), |
| 138 | title: run.title, |
| 139 | actor: Some(run.started_by.unwrap_or_else(|| "g1t".to_owned())), |
| 140 | harness: None, |
| 141 | agent: Some(run.agent), |
| 142 | branch: None, |
| 143 | item: None, |
| 144 | item_title: None, |
| 145 | pull: run.number, |
| 146 | pull_title: None, |
| 147 | pull_status: None, |
| 148 | state, |
| 149 | started_at: run.started_at.unwrap_or_else(|| run.created_at.clone()), |
| 150 | last_activity_at: run.updated_at, |
| 151 | subagents: 0, |
| 152 | step: run.step, |
| 153 | session: None, |
| 154 | children: Vec::new(), |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// A pull request's recorded session, as one of the workspace's. |
| 159 | pub(crate) fn from_recorded(row: RecordedRow, path: &str, now: u64) -> WorkspaceSession { |
| 160 | let last = parse_rfc3339(&row.last_at).unwrap_or(0); |
| 161 | WorkspaceSession { |
| 162 | kind: SessionKind::Recorded, |
| 163 | id: row.pull_id, |
| 164 | repo: path.to_owned(), |
| 165 | repo_id: row.repo_id, |
| 166 | title: Some(row.title.clone()), |
| 167 | actor: Some(row.author_name), |
| 168 | harness: None, |
| 169 | agent: Some(row.agent), |
| 170 | branch: None, |
| 171 | item: row.item, |
| 172 | item_title: None, |
| 173 | pull: Some(row.number), |
| 174 | pull_title: Some(row.title), |
| 175 | pull_status: Some(row.status), |
| 176 | state: quiet_state(false, last, now), |
| 177 | started_at: row.started_at, |
| 178 | last_activity_at: row.last_at, |
| 179 | subagents: 0, |
| 180 | step: None, |
| 181 | session: None, |
| 182 | children: Vec::new(), |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | /// Filters for the kinds that are not in the `sessions` table. |
| 187 | pub(crate) struct Wanted<'a> { |
| 188 | pub(crate) shown: SessionsShown, |
| 189 | pub(crate) kind: Option<SessionKind>, |
| 190 | pub(crate) harness: Option<Harness>, |
| 191 | pub(crate) person: Option<&'a str>, |
| 192 | pub(crate) item: Option<u32>, |
| 193 | } |
| 194 | |
| 195 | impl Wanted<'_> { |
| 196 | fn admits(&self, session: &WorkspaceSession) -> bool { |
| 197 | self.admits_listed(session) && self.item.is_none_or(|item| session.item == Some(item)) |
| 198 | } |
| 199 | |
| 200 | /// For a row of the `sessions` table, which its query already chose by |
| 201 | /// work item, the session's own or one it is linked to |
| 202 | /// ([`workspace_sessions_sql`]): all but that. |
| 203 | fn admits_listed(&self, session: &WorkspaceSession) -> bool { |
| 204 | self.shown.admits(session.state) |
| 205 | && self.kind.is_none_or(|kind| kind == session.kind) |
| 206 | && self.harness.is_none_or(|harness| session.harness == Some(harness)) |
| 207 | && self.person.is_none_or(|person| session.actor.as_deref().is_some_and(|actor| actor.eq_ignore_ascii_case(person))) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /// One list of every kind: a recorded session of a pull request that a |
| 212 | /// harness session or a run of g1t's agent is already on is that one, |
| 213 | /// and is left out. Filtered, most recently heard from first, at most |
| 214 | /// `limit`. |
| 215 | pub(crate) fn merged( |
| 216 | sessions: Vec<WorkspaceSession>, |
| 217 | runs: Vec<WorkspaceSession>, |
| 218 | recorded: Vec<(WorkspaceSession, bool)>, |
| 219 | wanted: &Wanted, |
| 220 | limit: usize, |
| 221 | ) -> Vec<WorkspaceSession> { |
| 222 | let covered: HashSet<(String, u32)> = |
| 223 | sessions.iter().chain(&runs).filter_map(|session| session.pull.map(|pull| (session.repo_id.clone(), pull))).collect(); |
| 224 | let mut all: Vec<WorkspaceSession> = sessions.into_iter().filter(|session| wanted.admits_listed(session)).collect(); |
| 225 | all.extend(runs.into_iter().filter(|run| wanted.admits(run))); |
| 226 | all.extend( |
| 227 | recorded |
| 228 | .into_iter() |
| 229 | .filter(|(session, has_runs)| !has_runs && !session.pull.is_some_and(|pull| covered.contains(&(session.repo_id.clone(), pull)))) |
| 230 | .map(|(session, _)| session) |
| 231 | .filter(|session| wanted.admits(session)), |
| 232 | ); |
| 233 | all.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at).then_with(|| a.id.cmp(&b.id))); |
| 234 | all.truncate(limit); |
| 235 | all |
| 236 | } |
| 237 | |
| 238 | impl Work { |
| 239 | /// The repositories of `workspace` the viewer may read, or of them the |
| 240 | /// one named `only`. |
| 241 | async fn readable_in(&self, workspace: &str, viewer: &Viewer, only: Option<&str>) -> Result<Vec<Repo>> { |
| 242 | let placed = self |
| 243 | .db |
| 244 | .prepare("SELECT repo_id, path FROM repo_workspaces WHERE workspace = ?1 AND (?2 IS NULL OR lower(path) = lower(?1 || '/' || ?2)) ORDER BY path LIMIT ?3") |
| 245 | .bind(&[workspace.into(), only.map_or(JsValue::NULL, JsValue::from), MAX_REPOS.into()])? |
| 246 | .all() |
| 247 | .await? |
| 248 | .results::<Placed>()?; |
| 249 | if placed.is_empty() { |
| 250 | return Ok(Vec::new()); |
| 251 | } |
| 252 | let ids: Vec<String> = placed.iter().map(|row| row.repo_id.clone()).collect(); |
| 253 | let mut readable: Vec<Repo> = g1t_kit::call(&self.repos, "readable", &ReadableArgs { ids, viewer: viewer.clone() }).await?; |
| 254 | readable.retain(|repo| repo.namespace.eq_ignore_ascii_case(workspace)); |
| 255 | readable.sort_by(|a, b| a.name.cmp(&b.name)); |
| 256 | Ok(readable) |
| 257 | } |
| 258 | |
| 259 | pub(crate) async fn list_workspace_sessions(&self, a: ListWorkspaceSessionsArgs) -> Result<Outcome<WorkspaceSessions>> { |
| 260 | let workspace = a.workspace.trim().to_lowercase(); |
| 261 | if workspace.is_empty() { |
| 262 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace by its slug.")); |
| 263 | } |
| 264 | let only = a.repo.as_deref().map(str::trim).filter(|name| !name.is_empty()).map(|name| name.rsplit('/').next().unwrap_or(name).to_owned()); |
| 265 | let repos = self.readable_in(&workspace, &a.viewer, only.as_deref()).await?; |
| 266 | if repos.is_empty() && only.is_some() { |
| 267 | return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")); |
| 268 | } |
| 269 | let ids: Vec<String> = repos.iter().map(|repo| repo.id.clone()).collect(); |
| 270 | let path_of: HashMap<&str, String> = repos.iter().map(|repo| (repo.id.as_str(), format!("{}/{}", repo.namespace, repo.name))).collect(); |
| 271 | let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)); |
| 272 | let limit = a.limit.unwrap_or(DEFAULT_WORKSPACE_SESSIONS).clamp(1, MAX_WORKSPACE_SESSIONS); |
| 273 | let now = now_ms(); |
| 274 | let person = a.person.as_deref().map(|person| person.trim().trim_start_matches('@')).filter(|person| !person.is_empty()); |
| 275 | // A work item by number, in this workspace. |
| 276 | let item_id = match a.item { |
| 277 | Some(number) => match crate::items::by_number(self, &workspace, number).await? { |
| 278 | Some(held) => Some(held.item.id), |
| 279 | None => return Ok(Outcome::Ok(WorkspaceSessions { repos: sessions_repos(&repos), ..WorkspaceSessions::default() })), |
| 280 | }, |
| 281 | None => None, |
| 282 | }; |
| 283 | |
| 284 | // Harness and hosted sessions. |
| 285 | let from_table = !matches!(a.kind, Some(SessionKind::Agent | SessionKind::Recorded)); |
| 286 | let mut sessions: Vec<Session> = if from_table && !ids.is_empty() { |
| 287 | self.db |
| 288 | .prepare(workspace_sessions_sql(a.shown)) |
| 289 | .bind(&[ |
| 290 | serde_json::to_string(&ids)?.into(), |
| 291 | rfc3339(now.saturating_sub(SESSION_TIMEOUT_MS)).into(), |
| 292 | u32::from(false).into(), |
| 293 | a.harness.map_or(JsValue::NULL, |harness| harness.as_str().into()), |
| 294 | person.map_or(JsValue::NULL, JsValue::from), |
| 295 | item_id.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 296 | limit.into(), |
| 297 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), |
| 298 | ])? |
| 299 | .all() |
| 300 | .await? |
| 301 | .results::<SessionRow>()? |
| 302 | .into_iter() |
| 303 | .map(|row| row.at(now)) |
| 304 | .collect() |
| 305 | } else { |
| 306 | Vec::new() |
| 307 | }; |
| 308 | self.fill_subagents(&mut sessions).await?; |
| 309 | self.fill_first_prompts(&mut sessions).await?; |
| 310 | shown::sessions(&mut sessions, &a.viewer, &workspace); |
| 311 | let mut trees: HashMap<String, Vec<Session>> = HashMap::new(); |
| 312 | if a.subagents { |
| 313 | for session in sessions.iter().filter(|session| session.subagents > 0).take(TREES) { |
| 314 | let mut children = self.subagents_of(&session.id, now).await?; |
| 315 | count_subagents(&mut children); |
| 316 | shown::sessions(&mut children, &a.viewer, &workspace); |
| 317 | trees.insert(session.id.clone(), children); |
| 318 | } |
| 319 | } |
| 320 | let table: Vec<WorkspaceSession> = sessions |
| 321 | .into_iter() |
| 322 | .filter_map(|session| { |
| 323 | let path = path_of.get(session.repo_id.as_str())?.clone(); |
| 324 | let mut listed = from_session(session, &path, now); |
| 325 | listed.children = trees.remove(&listed.id).unwrap_or_default(); |
| 326 | Some(listed) |
| 327 | }) |
| 328 | .collect(); |
| 329 | |
| 330 | // g1t's agent: the workspace's business, as its fleet is. |
| 331 | let wants = |kind: SessionKind| a.kind.is_none_or(|wanted| wanted == kind) && a.harness.is_none(); |
| 332 | let runs: Vec<WorkspaceSession> = if member && wants(SessionKind::Agent) { |
| 333 | self.agent_runs_in(&workspace, &ids, a.shown == SessionsShown::Live, member, limit) |
| 334 | .await? |
| 335 | .into_iter() |
| 336 | .map(|(repo_id, run)| from_run(run, &repo_id)) |
| 337 | .collect() |
| 338 | } else { |
| 339 | Vec::new() |
| 340 | }; |
| 341 | let recorded: Vec<(WorkspaceSession, bool)> = if wants(SessionKind::Recorded) { |
| 342 | self.recorded_in(&ids, limit) |
| 343 | .await? |
| 344 | .into_iter() |
| 345 | .filter_map(|row| { |
| 346 | let path = path_of.get(row.repo_id.as_str())?.clone(); |
| 347 | let has_runs = row.has_runs != 0; |
| 348 | Some((from_recorded(row, &path, now), has_runs)) |
| 349 | }) |
| 350 | .collect() |
| 351 | } else { |
| 352 | Vec::new() |
| 353 | }; |
| 354 | let mut runs = runs; |
| 355 | self.fill_pulls(&mut runs).await?; |
| 356 | let wanted = Wanted { shown: a.shown, kind: a.kind, harness: a.harness, person, item: a.item }; |
| 357 | let mut listed = merged(table, runs, recorded, &wanted, limit as usize); |
| 358 | self.fill_pulls(&mut listed).await?; |
| 359 | if member { |
| 360 | self.fill_item_titles(&workspace, &mut listed).await?; |
| 361 | } |
| 362 | |
| 363 | let (queue, landed) = if a.sessions_only { (Vec::new(), Vec::new()) } else { (self.queues_in(&repos, &a.viewer).await?, self.landed_in(&repos).await?) }; |
| 364 | Ok(Outcome::Ok(WorkspaceSessions { sessions: listed, queue, landed, repos: sessions_repos(&repos) })) |
| 365 | } |
| 366 | |
| 367 | /// Each session's pull request's title and status, and, for one with |
| 368 | /// no work item of its own, the pull request's. |
| 369 | async fn fill_pulls(&self, sessions: &mut [WorkspaceSession]) -> Result<()> { |
| 370 | let pairs: Vec<serde_json::Value> = sessions |
| 371 | .iter() |
| 372 | .filter(|session| session.pull_title.is_none() || session.item.is_none()) |
| 373 | .filter_map(|session| session.pull.map(|number| serde_json::json!({ "r": session.repo_id, "n": number }))) |
| 374 | .collect(); |
| 375 | if pairs.is_empty() { |
| 376 | return Ok(()); |
| 377 | } |
| 378 | let facts = self.db.prepare(PULL_FACTS).bind(&[serde_json::to_string(&pairs)?.into()])?.all().await?.results::<PullFact>()?; |
| 379 | for session in sessions.iter_mut() { |
| 380 | let Some(fact) = session.pull.and_then(|number| facts.iter().find(|fact| fact.repo_id == session.repo_id && fact.number == number)) else { |
| 381 | continue; |
| 382 | }; |
| 383 | session.pull_title.get_or_insert_with(|| fact.title.clone()); |
| 384 | session.pull_status.get_or_insert(fact.status); |
| 385 | if session.item.is_none() { |
| 386 | session.item = fact.item; |
| 387 | } |
| 388 | if session.title.is_none() && session.kind == SessionKind::Agent { |
| 389 | session.title = Some(fact.title.clone()); |
| 390 | } |
| 391 | } |
| 392 | Ok(()) |
| 393 | } |
| 394 | |
| 395 | /// Each session's work item's title, for a member of the workspace. |
| 396 | async fn fill_item_titles(&self, workspace: &str, sessions: &mut [WorkspaceSession]) -> Result<()> { |
| 397 | let mut numbers: Vec<u32> = sessions.iter().filter_map(|session| session.item).collect(); |
| 398 | numbers.sort_unstable(); |
| 399 | numbers.dedup(); |
| 400 | if numbers.is_empty() { |
| 401 | return Ok(()); |
| 402 | } |
| 403 | let titles: HashMap<u32, String> = self |
| 404 | .db |
| 405 | .prepare("SELECT number, title FROM work_items WHERE workspace = ?1 AND number IN (SELECT value FROM json_each(?2))") |
| 406 | .bind(&[workspace.into(), serde_json::to_string(&numbers)?.into()])? |
| 407 | .all() |
| 408 | .await? |
| 409 | .results::<Titled>()? |
| 410 | .into_iter() |
| 411 | .map(|row| (row.number, row.title)) |
| 412 | .collect(); |
| 413 | for session in sessions { |
| 414 | session.item_title = session.item.and_then(|number| titles.get(&number).cloned()); |
| 415 | } |
| 416 | Ok(()) |
| 417 | } |
| 418 | |
| 419 | /// The merge queues of `repos` with something waiting, busiest first. |
| 420 | async fn queues_in(&self, repos: &[Repo], viewer: &Viewer) -> Result<Vec<RepoQueue>> { |
| 421 | if repos.is_empty() { |
| 422 | return Ok(Vec::new()); |
| 423 | } |
| 424 | let ids: Vec<&str> = repos.iter().map(|repo| repo.id.as_str()).collect(); |
| 425 | let busy = self |
| 426 | .db |
| 427 | .prepare(QUEUED_REPOS) |
| 428 | .bind(&[serde_json::to_string(&ids)?.into(), (QUEUES as u32).into()])? |
| 429 | .all() |
| 430 | .await? |
| 431 | .results::<Value>()?; |
| 432 | let mut queues = Vec::new(); |
| 433 | for id in busy { |
| 434 | let Some(repo) = repos.iter().find(|repo| repo.id == id.value) else { continue }; |
| 435 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 436 | if let Outcome::Ok(queue) = self.queue(QueueArgs { repo: path, viewer: viewer.clone() }).await? { |
| 437 | queues.push(RepoQueue { repo: format!("{}/{}", repo.namespace, repo.name), default_branch: repo.default_branch.clone(), queue }); |
| 438 | } |
| 439 | } |
| 440 | Ok(queues) |
| 441 | } |
| 442 | |
| 443 | /// The pull requests of `repos` merged most recently. |
| 444 | async fn landed_in(&self, repos: &[Repo]) -> Result<Vec<RepoPull>> { |
| 445 | if repos.is_empty() { |
| 446 | return Ok(Vec::new()); |
| 447 | } |
| 448 | let ids: Vec<&str> = repos.iter().map(|repo| repo.id.as_str()).collect(); |
| 449 | let pulls = self |
| 450 | .db |
| 451 | .prepare(format!( |
| 452 | "SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id IN (SELECT value FROM json_each(?1)) AND status = 'merged' AND merged_at IS NOT NULL |
| 453 | ORDER BY merged_at DESC LIMIT ?2" |
| 454 | )) |
| 455 | .bind(&[serde_json::to_string(&ids)?.into(), LANDED.into()])? |
| 456 | .all() |
| 457 | .await? |
| 458 | .results::<PullRow>()?; |
| 459 | Ok(pulls |
| 460 | .into_iter() |
| 461 | .map(Pull::from) |
| 462 | .filter_map(|mut pull| { |
| 463 | let repo = repos.iter().find(|repo| repo.id == pull.repo_id)?; |
| 464 | crate::fill_base(&mut pull, repo); |
| 465 | Some(RepoPull { repo: format!("{}/{}", repo.namespace, repo.name), pull }) |
| 466 | }) |
| 467 | .collect()) |
| 468 | } |
| 469 | |
| 470 | /// One harness or hosted session of a workspace by its id, read as its |
| 471 | /// repository's own list reads it. |
| 472 | pub(crate) async fn get_workspace_session(&self, a: GetWorkspaceSessionArgs) -> Result<Outcome<WorkspaceSessionDetail>> { |
| 473 | let workspace = a.workspace.trim().to_lowercase(); |
| 474 | let placed = self |
| 475 | .db |
| 476 | .prepare( |
| 477 | "SELECT s.repo_id, rw.path FROM sessions s JOIN repo_workspaces rw ON rw.repo_id = s.repo_id |
| 478 | WHERE s.id = ?1 AND rw.workspace = ?2", |
| 479 | ) |
| 480 | .bind(&[a.id.trim().into(), workspace.as_str().into()])? |
| 481 | .first::<Placed>(None) |
| 482 | .await?; |
| 483 | let Some(placed) = placed else { |
| 484 | return Ok(Outcome::fail(FailureCode::NotFound, "Session not found.")); |
| 485 | }; |
| 486 | let Some((namespace, name)) = placed.path.split_once('/') else { |
| 487 | return Ok(Outcome::fail(FailureCode::NotFound, "Session not found.")); |
| 488 | }; |
| 489 | let repo = RepoPath { namespace: namespace.to_owned(), name: name.to_owned() }; |
| 490 | let args = WorkSessionArgs { viewer: a.viewer, repo, id: a.id.trim().to_owned(), after_seq: a.after_seq }; |
| 491 | Ok(match self.get_work_session(args).await? { |
| 492 | Outcome::Ok(detail) => Outcome::Ok(WorkspaceSessionDetail { repo: placed.path, detail }), |
| 493 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 494 | }) |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | fn sessions_repos(repos: &[Repo]) -> Vec<SessionsRepo> { |
| 499 | repos |
| 500 | .iter() |
| 501 | .map(|repo| SessionsRepo { id: repo.id.clone(), path: format!("{}/{}", repo.namespace, repo.name), default_branch: repo.default_branch.clone() }) |
| 502 | .collect() |
| 503 | } |
| 504 | |
| 505 | #[cfg(test)] |
| 506 | mod tests { |
| 507 | use super::*; |
| 508 | use crate::runs::RECORDED_IN; |
| 509 | use rusqlite::{Connection, params}; |
| 510 | |
| 511 | const NOW: u64 = 1_791_800_000_000; |
| 512 | const MINUTE: u64 = 60_000; |
| 513 | |
| 514 | fn at(ago_minutes: u64) -> String { |
| 515 | rfc3339(NOW - ago_minutes * MINUTE) |
| 516 | } |
| 517 | |
| 518 | /// Two repositories of acme and one of another workspace, a work item, |
| 519 | /// and pull requests in each. |
| 520 | fn database() -> Connection { |
| 521 | let db = crate::migrated::database(); |
| 522 | db.execute_batch( |
| 523 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES |
| 524 | ('rep_web', 'acme', 'acme/web', 1), ('rep_api', 'acme', 'acme/api', 1), ('rep_x', 'other', 'other/x', 0); |
| 525 | INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at) |
| 526 | VALUES ('wki_4', 'acme', 4, 'Greet people', '', 'usr_1', 'ana', '', ''); |
| 527 | INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at, item_id) VALUES |
| 528 | ('pr_web_9', 'rep_web', 9, 'Say hello', 'claude-code', 'external', 'usr_1', 'ana', '', '', 'wki_4'), |
| 529 | ('pr_api_7', 'rep_api', 7, 'Recorded only', 'cursor', 'external', 'usr_2', 'bo', '', '', NULL), |
| 530 | ('pr_api_8', 'rep_api', 8, 'By g1t', 'g1t', 'external', 'usr_2', 'bo', '', '', NULL), |
| 531 | ('pr_x_1', 'rep_x', 1, 'Elsewhere', 'codex', 'external', 'usr_3', 'cy', '', '', NULL);", |
| 532 | ) |
| 533 | .unwrap(); |
| 534 | db |
| 535 | } |
| 536 | |
| 537 | #[allow(clippy::too_many_arguments)] |
| 538 | fn session(db: &Connection, id: &str, repo: &str, item: Option<&str>, pull: Option<u32>, actor: &str, harness: &str, state: &str, last: u64, parent: Option<&str>) { |
| 539 | db.execute( |
| 540 | "INSERT INTO sessions (id, repo_id, item_id, pull, branch, actor_id, actor_name, harness, harness_session_id, |
| 541 | title, state, head_commit, started_at, last_seen_at, parent_id) |
| 542 | VALUES (?1, ?2, ?3, ?4, 'main', ?5, ?5, ?6, ?1, NULL, ?7, NULL, ?8, ?8, ?9)", |
| 543 | params![id, repo, item, pull, actor, harness, state, at(last), parent], |
| 544 | ) |
| 545 | .unwrap(); |
| 546 | } |
| 547 | |
| 548 | #[allow(clippy::too_many_arguments)] |
| 549 | fn listed(db: &Connection, shown: SessionsShown, repos: &[&str], harness: Option<&str>, person: Option<&str>, item: Option<&str>, kind: Option<&str>) -> Vec<String> { |
| 550 | let sql = workspace_sessions_sql(shown); |
| 551 | let mut statement = db.prepare(&sql).unwrap(); |
| 552 | statement |
| 553 | .query_map( |
| 554 | params![serde_json::to_string(repos).unwrap(), rfc3339(NOW - SESSION_TIMEOUT_MS), 0, harness, person, item, 100, kind], |
| 555 | |row| row.get::<_, String>("id"), |
| 556 | ) |
| 557 | .unwrap() |
| 558 | .map(Result::unwrap) |
| 559 | .collect() |
| 560 | } |
| 561 | |
| 562 | fn seeded() -> Connection { |
| 563 | let db = database(); |
| 564 | session(&db, "ses_cc", "rep_web", Some("wki_4"), Some(9), "ana", "claude_code", "active", 1, None); |
| 565 | session(&db, "ses_sub", "rep_web", Some("wki_4"), None, "ana", "claude_code", "active", 1, Some("ses_cc")); |
| 566 | session(&db, "ses_codex", "rep_api", None, None, "bo", "codex", "active", 20, None); |
| 567 | session(&db, "ses_cursor", "rep_api", None, None, "bo", "cursor", "active", 45, None); |
| 568 | session(&db, "ses_hosted", "rep_api", None, None, "ana", "claude_managed_agents", "active", 3, None); |
| 569 | session(&db, "ses_done", "rep_web", None, None, "cy", "other", "ended", 90, None); |
| 570 | session(&db, "ses_x", "rep_x", None, None, "cy", "codex", "active", 1, None); |
| 571 | db |
| 572 | } |
| 573 | |
| 574 | #[test] |
| 575 | fn only_the_repositories_looked_in_are_listed_and_subagents_stay_under_their_parent() { |
| 576 | let db = seeded(); |
| 577 | let all = listed(&db, SessionsShown::Recent, &["rep_web", "rep_api"], None, None, None, None); |
| 578 | assert_eq!(all, ["ses_cc", "ses_hosted", "ses_codex", "ses_cursor", "ses_done"]); |
| 579 | // A repository the viewer may not read is never looked in. |
| 580 | let web = listed(&db, SessionsShown::Recent, &["rep_web"], None, None, None, None); |
| 581 | assert_eq!(web, ["ses_cc", "ses_done"]); |
| 582 | assert!(!all.contains(&"ses_x".to_owned())); |
| 583 | } |
| 584 | |
| 585 | #[test] |
| 586 | fn live_is_what_is_going_and_ended_takes_those_gone_quiet_too() { |
| 587 | let db = seeded(); |
| 588 | let repos = ["rep_web", "rep_api"]; |
| 589 | // Idle (20 minutes) is still live; quiet past the timeout (45) is not. |
| 590 | assert_eq!(listed(&db, SessionsShown::Live, &repos, None, None, None, None), ["ses_cc", "ses_hosted", "ses_codex"]); |
| 591 | assert_eq!(listed(&db, SessionsShown::Ended, &repos, None, None, None, None), ["ses_cursor", "ses_done"]); |
| 592 | } |
| 593 | |
| 594 | #[test] |
| 595 | fn sessions_filter_by_harness_person_kind_and_work_item() { |
| 596 | let db = seeded(); |
| 597 | let repos = ["rep_web", "rep_api"]; |
| 598 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, Some("codex"), None, None, None), ["ses_codex"]); |
| 599 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, None, Some("BO"), None, None), ["ses_codex", "ses_cursor"]); |
| 600 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, None, None, None, Some("hosted")), ["ses_hosted"]); |
| 601 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, None, None, None, Some("harness")), ["ses_cc", "ses_codex", "ses_cursor", "ses_done"]); |
| 602 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, None, None, Some("wki_4"), None), ["ses_cc"]); |
| 603 | // Tied to the work item another way: a link. |
| 604 | db.execute(super::super::links::INSERT_LINK, params!["ses_codex", "rep_api", "item", 0, "ref", at(1), "wki_4"]).unwrap(); |
| 605 | assert_eq!(listed(&db, SessionsShown::Recent, &repos, None, None, Some("wki_4"), None), ["ses_cc", "ses_codex"]); |
| 606 | } |
| 607 | |
| 608 | fn recorded(db: &Connection, repos: &[&str]) -> Vec<RecordedRow> { |
| 609 | let mut statement = db.prepare(RECORDED_IN).unwrap(); |
| 610 | let rows = statement |
| 611 | .query_map(params![serde_json::to_string(repos).unwrap(), 50], |row| { |
| 612 | Ok(RecordedRow { |
| 613 | pull_id: row.get("pull_id")?, |
| 614 | repo_id: row.get("repo_id")?, |
| 615 | number: row.get("number")?, |
| 616 | title: row.get("title")?, |
| 617 | status: PullStatus::Open, |
| 618 | agent: row.get("agent")?, |
| 619 | author_name: row.get("author_name")?, |
| 620 | item: row.get("item")?, |
| 621 | started_at: row.get("started_at")?, |
| 622 | last_at: row.get("last_at")?, |
| 623 | has_runs: row.get("has_runs")?, |
| 624 | }) |
| 625 | }) |
| 626 | .unwrap(); |
| 627 | rows.map(Result::unwrap).collect() |
| 628 | } |
| 629 | |
| 630 | fn run(number: u32, status: &str) -> AgentRun { |
| 631 | serde_json::from_value(serde_json::json!({ |
| 632 | "id": "run_1", "repo": { "namespace": "acme", "name": "api" }, "number": number, "title": null, "kind": "implement", |
| 633 | "agent": "g1t", "model": null, "status": status, "step": "Running tests", "stepCount": 3, "startedBy": null, |
| 634 | "error": null, "costUsd": null, "turns": null, "createdAt": at(10), "startedAt": at(10), "finishedAt": null, "updatedAt": at(1) |
| 635 | })) |
| 636 | .unwrap() |
| 637 | } |
| 638 | |
| 639 | #[test] |
| 640 | fn a_pull_request_recorded_directly_is_a_session_and_one_moved_from_a_harness_is_not() { |
| 641 | let db = seeded(); |
| 642 | db.execute_batch(&format!( |
| 643 | "INSERT INTO session_entries (pull_id, seq, kind, text, at) VALUES ('pr_api_7', 1, 'prompt', 'Fix it', '{a}'), ('pr_api_7', 2, 'message', 'Done', '{b}'); |
| 644 | INSERT INTO session_entries (pull_id, seq, kind, text, at, session_id, session_seq) VALUES ('pr_web_9', 1, 'message', 'Moved', '{b}', 'ses_cc', 1); |
| 645 | INSERT INTO session_entries (pull_id, seq, kind, text, at) VALUES ('pr_x_1', 1, 'message', 'Elsewhere', '{b}'); |
| 646 | INSERT INTO session_entries (pull_id, seq, kind, text, at) VALUES ('pr_api_8', 1, 'message', 'g1t', '{c}'); |
| 647 | INSERT INTO agent_runs (id, workspace, repo_id, repo, number, pull_id, kind, agent, status, sandbox, token_hash, created_at, updated_at) |
| 648 | VALUES ('run_1', 'acme', 'rep_api', 'acme/api', 8, 'pr_api_8', 'implement', 'g1t', 'running', 'sb', 'h', '{a}', '{b}');", |
| 649 | a = at(10), |
| 650 | b = at(2), |
| 651 | c = at(5) |
| 652 | )) |
| 653 | .unwrap(); |
| 654 | let rows = recorded(&db, &["rep_web", "rep_api"]); |
| 655 | let numbers: Vec<u32> = rows.iter().map(|row| row.number).collect(); |
| 656 | assert_eq!(numbers, [7, 8]); |
| 657 | assert_eq!(rows[0].started_at, at(10)); |
| 658 | assert_eq!(rows[0].has_runs, 0); |
| 659 | assert_eq!(rows[1].has_runs, 1); |
| 660 | // Together: the run of g1t's agent stands for its pull request, and |
| 661 | // the recorded one with nothing behind it is a session of its own. |
| 662 | let recorded: Vec<(WorkspaceSession, bool)> = rows |
| 663 | .into_iter() |
| 664 | .map(|row| { |
| 665 | let runs = row.has_runs != 0; |
| 666 | (from_recorded(row, "acme/api", NOW), runs) |
| 667 | }) |
| 668 | .collect(); |
| 669 | let wanted = Wanted { shown: SessionsShown::Recent, kind: None, harness: None, person: None, item: None }; |
| 670 | let all = merged(Vec::new(), vec![from_run(run(8, "running"), "rep_api")], recorded, &wanted, 10); |
| 671 | let kinds: Vec<(SessionKind, &str)> = all.iter().map(|session| (session.kind, session.id.as_str())).collect(); |
| 672 | assert_eq!(kinds, [(SessionKind::Agent, "run_1"), (SessionKind::Recorded, "pr_api_7")]); |
| 673 | assert_eq!(all[0].actor.as_deref(), Some("g1t")); |
| 674 | assert_eq!(all[0].state, SessionState::Active); |
| 675 | assert_eq!(all[1].state, SessionState::Active); |
| 676 | let live_by_bo = Wanted { shown: SessionsShown::Live, kind: Some(SessionKind::Recorded), harness: None, person: Some("bo"), item: None }; |
| 677 | assert_eq!(merged(Vec::new(), Vec::new(), vec![(all[1].clone(), false)], &live_by_bo, 10).len(), 1); |
| 678 | let by_harness = Wanted { shown: SessionsShown::Recent, kind: None, harness: Some(Harness::Codex), person: None, item: None }; |
| 679 | assert!(merged(Vec::new(), Vec::new(), vec![(all[1].clone(), false)], &by_harness, 10).is_empty()); |
| 680 | // A finished run is not live. |
| 681 | let live = Wanted { shown: SessionsShown::Live, kind: None, harness: None, person: None, item: None }; |
| 682 | assert!(merged(Vec::new(), vec![from_run(run(8, "succeeded"), "rep_api")], Vec::new(), &live, 10).is_empty()); |
| 683 | } |
| 684 | |
| 685 | #[test] |
| 686 | fn every_kind_reads_as_one_shape_and_a_quiet_session_reads_as_ended() { |
| 687 | let session: Session = serde_json::from_value(serde_json::json!({ |
| 688 | "id": "ses_q", "repoId": "rep_api", "item": null, "pull": 7, "branch": "fix", "actor": { "id": "usr_2", "username": "bo" }, |
| 689 | "harness": "claude_managed_agents", "harnessSessionId": null, "title": "Fix it", "state": "idle", "headCommit": null, |
| 690 | "startedAt": at(60), "lastSeenAt": at(40), "endedAt": null, "endReason": null, "subagents": 2 |
| 691 | })) |
| 692 | .unwrap(); |
| 693 | let listed = from_session(session, "acme/api", NOW); |
| 694 | assert_eq!(listed.kind, SessionKind::Hosted); |
| 695 | assert_eq!(listed.state, SessionState::Ended); |
| 696 | assert_eq!(listed.session.as_ref().unwrap().end_reason.as_deref(), Some("timed_out")); |
| 697 | assert_eq!(listed.subagents, 2); |
| 698 | assert_eq!(listed.repo, "acme/api"); |
| 699 | // A recorded one on the pull request this session is on is this session. |
| 700 | let recorded = RecordedRow { |
| 701 | pull_id: "pr_api_7".into(), |
| 702 | repo_id: "rep_api".into(), |
| 703 | number: 7, |
| 704 | title: "Fix it".into(), |
| 705 | status: PullStatus::Open, |
| 706 | agent: "cursor".into(), |
| 707 | author_name: "bo".into(), |
| 708 | item: None, |
| 709 | started_at: at(60), |
| 710 | last_at: at(50), |
| 711 | has_runs: 0, |
| 712 | }; |
| 713 | let wanted = Wanted { shown: SessionsShown::Recent, kind: None, harness: None, person: None, item: None }; |
| 714 | let all = merged(vec![listed], Vec::new(), vec![(from_recorded(recorded, "acme/api", NOW), false)], &wanted, 10); |
| 715 | assert_eq!(all.len(), 1); |
| 716 | assert_eq!(all[0].id, "ses_q"); |
| 717 | } |
| 718 | |
| 719 | #[test] |
| 720 | fn a_session_linked_to_a_work_item_is_listed_for_it() { |
| 721 | // On no work item of its own, but linked to #12 (a claim, a trailer). |
| 722 | let session: Session = serde_json::from_value(serde_json::json!({ |
| 723 | "id": "ses_l", "repoId": "rep_api", "item": null, "pull": null, "branch": null, "actor": { "id": "usr_2", "username": "bo" }, |
| 724 | "harness": "codex", "harnessSessionId": null, "title": null, "state": "active", "headCommit": null, |
| 725 | "startedAt": at(5), "lastSeenAt": at(1), "endedAt": null, "endReason": null, "subagents": 0 |
| 726 | })) |
| 727 | .unwrap(); |
| 728 | let listed = from_session(session, "acme/api", NOW); |
| 729 | let for_item = Wanted { shown: SessionsShown::Recent, kind: None, harness: None, person: None, item: Some(12) }; |
| 730 | assert_eq!(merged(vec![listed], Vec::new(), Vec::new(), &for_item, 10).len(), 1); |
| 731 | } |
| 732 | |
| 733 | #[test] |
| 734 | fn pull_facts_and_busy_queues_are_read_across_repositories() { |
| 735 | let db = seeded(); |
| 736 | let pairs = serde_json::json!([{ "r": "rep_web", "n": 9 }, { "r": "rep_api", "n": 7 }, { "r": "rep_api", "n": 99 }]).to_string(); |
| 737 | let mut statement = db.prepare(PULL_FACTS).unwrap(); |
| 738 | let mut facts: Vec<(String, u32, String, Option<u32>)> = statement |
| 739 | .query_map(params![pairs], |row| Ok((row.get("repo_id")?, row.get("number")?, row.get("title")?, row.get("item")?))) |
| 740 | .unwrap() |
| 741 | .map(Result::unwrap) |
| 742 | .collect(); |
| 743 | facts.sort(); |
| 744 | assert_eq!(facts, [("rep_api".into(), 7, "Recorded only".into(), None), ("rep_web".into(), 9, "Say hello".into(), Some(4))]); |
| 745 | db.execute_batch( |
| 746 | "INSERT INTO queue_entries (id, repo_id, pull_id, number, state, enqueued_by, created_at) VALUES |
| 747 | ('q1', 'rep_api', 'pr_api_7', 7, 'waiting', '{}', '1'), ('q2', 'rep_api', 'pr_api_8', 8, 'testing', '{}', '2'), |
| 748 | ('q3', 'rep_web', 'pr_web_9', 9, 'landed', '{}', '3'), ('q4', 'rep_x', 'pr_x_1', 1, 'waiting', '{}', '4');", |
| 749 | ) |
| 750 | .unwrap(); |
| 751 | let mut statement = db.prepare(QUEUED_REPOS).unwrap(); |
| 752 | let busy: Vec<String> = |
| 753 | statement.query_map(params![r#"["rep_web","rep_api"]"#, 12], |row| row.get::<_, String>("value")).unwrap().map(Result::unwrap).collect(); |
| 754 | assert_eq!(busy, ["rep_api"]); |
| 755 | } |
| 756 | } |