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.

1,107 linesCodeBlameRaw
1//! Sessions: one run of someone's own harness on a repository.
2//!
3//! Starting one is idempotent on the harness's own session id, so a hook
4//! that fires twice, or a harness that reconnects, gets the same session
5//! back. Every update, and every batch of entries, is a heartbeat: it keeps
6//! the session active, brings back one that timed out, and renews the
7//! claims made from it. Only the person whose token started a session
8//! changes it; anyone with write access may also end it, for one left
9//! behind (recorded as ended by hand by them).
10//!
11//! A subagent is a session of its own with a parent (`parent_id`): a
12//! Claude Code subagent, a Managed Agents thread. It starts on what its
13//! parent is on unless it says otherwise, keeps the task, prompt and
14//! result its parent gave and got (cut short), and once ended stays ended
15//! even if a late hook starts it again: a subagent never comes back. Its
16//! heartbeats keep its parent seen, and ending a session ends the
17//! subagents it left going.
18
19use g1t_contracts::events::SessionAppended;
20use g1t_contracts::repos::Repo;
21use g1t_contracts::time::rfc3339;
22use g1t_contracts::work::*;
23use g1t_contracts::{FailureCode, Outcome, User, new_id};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27use worker::wasm_bindgen::JsValue;
28
29use super::links::{PULL_SESSIONS, PullSessionRow, count_subagents, end_tree_sql, pull_sessions_of, tree_sql};
30
31/// The most pull requests one `pull_sessions` asks about.
32const MAX_PULL_SESSIONS: usize = 100;
33use super::updates::push_delivery;
34use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, titles, unwrap_or_fail};
35use crate::Work;
36use crate::items;
37use crate::rows::NumberRow;
38
39/// The most entries one look at a session returns.
40const ENTRY_PAGE: u32 = 500;
41/// The most sessions one list returns.
42const LIST_LIMIT: u32 = 100;
43const MAX_TITLE_CHARS: usize = MAX_SESSION_TITLE_CHARS;
44const MAX_BRANCH_CHARS: usize = 255;
45const MAX_ID_CHARS: usize = 200;
46const MAX_COMMIT_CHARS: usize = 64;
47const MAX_AGENT_CHARS: usize = 100;
48const MAX_MODEL_CHARS: usize = 100;
49/// How long one prompt entry may be before its `#N` go unread.
50const MAX_REF_TEXT: usize = 8_000;
51
52fn not_found<T>() -> Outcome<T> {
53 Outcome::fail(FailureCode::NotFound, "Session not found.")
54}
55
56fn text_or_null(value: &Option<String>) -> JsValue {
57 value.as_deref().map_or(JsValue::NULL, JsValue::from)
58}
59
60fn number_or_null(value: Option<u32>) -> JsValue {
61 value.map_or(JsValue::NULL, JsValue::from)
62}
63
64/// Long text as kept: trimmed, cut to `limit` characters with an ellipsis.
65fn clip(text: Option<&str>, limit: usize) -> Option<String> {
66 let text = text?.trim();
67 if text.is_empty() {
68 return None;
69 }
70 if text.chars().count() <= limit {
71 return Some(text.to_owned());
72 }
73 let cut: String = text.chars().take(limit.saturating_sub(1)).collect();
74 Some(format!("{}…", cut.trim_end()))
75}
76
77/// What a session's heartbeat may change, tidied.
78#[derive(Default)]
79struct Fields {
80 /// Its work item, by id and number.
81 item: Option<(String, u32)>,
82 pull: Option<u32>,
83 branch: Option<String>,
84 head_commit: Option<String>,
85 title: Option<String>,
86 agent_type: Option<String>,
87 task: Option<String>,
88 prompt: Option<String>,
89 model: Option<String>,
90 result: Option<String>,
91}
92
93impl Fields {
94 fn binds(&self) -> Vec<JsValue> {
95 vec![
96 self.item.as_ref().map_or(JsValue::NULL, |(id, _)| JsValue::from(id.as_str())),
97 number_or_null(self.pull),
98 text_or_null(&self.branch),
99 text_or_null(&self.head_commit),
100 text_or_null(&self.title),
101 text_or_null(&self.agent_type),
102 text_or_null(&self.task),
103 text_or_null(&self.prompt),
104 text_or_null(&self.model),
105 text_or_null(&self.result),
106 ]
107 }
108
109 /// The text whose `#N` tie the session to work items and pull requests.
110 fn named(&self) -> String {
111 [&self.title, &self.task, &self.prompt].into_iter().flatten().cloned().collect::<Vec<_>>().join("\n")
112 }
113}
114
115#[derive(Deserialize)]
116struct RenewRow {
117 id: String,
118 ttl_minutes: u32,
119 expires_at: String,
120}
121
122/// Starts a session; nothing when one with the same harness session id
123/// was started already.
124pub(crate) fn insert_session_sql() -> String {
125 format!(
126 "INSERT INTO sessions
127 (id, repo_id, item_id, pull, branch, actor_id, actor_name, harness, harness_session_id,
128 title, state, head_commit, started_at, last_seen_at, parent_id, agent_type, task, prompt, model,
129 title_source, title_settled_at)
130 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18,
131 CASE WHEN ?10 IS NULL THEN NULL ELSE 'agent' END, CASE WHEN ?10 IS NULL THEN NULL ELSE ?12 END)
132 ON CONFLICT (repo_id, actor_id, harness, harness_session_id)
133 WHERE harness_session_id IS NOT NULL DO NOTHING
134 RETURNING {SESSION_COLUMNS}"
135 )
136}
137
138/// A person's session by its harness's id for it.
139pub(crate) fn harness_session_sql() -> String {
140 format!(
141 "SELECT {SESSION_COLUMNS} FROM sessions
142 WHERE repo_id = ?1 AND actor_id = ?2 AND harness = ?3 AND harness_session_id = ?4"
143 )
144}
145
146/// The fields `?1`–`?10` of [`Fields::binds`], each kept when not given;
147/// a title as [`titles::SET_TITLE`] takes it, from `?13` (`?11` now).
148const SET_FIELDS: &str = "item_id = COALESCE(?1, item_id), pull = COALESCE(?2, pull), branch = COALESCE(?3, branch),
149 head_commit = COALESCE(?4, head_commit),
150 agent_type = COALESCE(?6, agent_type), task = COALESCE(?7, task), prompt = COALESCE(?8, prompt),
151 model = COALESCE(?9, model), result = COALESCE(?10, result)";
152
153/// A heartbeat: seen now (`?11`), active again, with whichever fields are
154/// given, of session `?12`; a title given came from `?13`. One that ended
155/// is named again as it ends again.
156pub(crate) fn resume_sql() -> String {
157 format!(
158 "UPDATE sessions
159 SET {SET_FIELDS}, {set_title},
160 title_final = CASE WHEN state = 'ended' THEN 0 ELSE title_final END,
161 last_seen_at = ?11, state = 'active', ended_at = NULL, end_reason = NULL
162 WHERE id = ?12
163 RETURNING {SESSION_COLUMNS}",
164 set_title = titles::SET_TITLE,
165 )
166}
167
168/// The fields of session `?12`, changed without bringing it back: for a
169/// subagent's late hooks (the binds are the same).
170pub(crate) fn refresh_sql() -> String {
171 format!(
172 "UPDATE sessions SET {SET_FIELDS}, {set_title}
173 WHERE id = ?12 AND ?11 IS NOT NULL
174 RETURNING {SESSION_COLUMNS}",
175 set_title = titles::SET_TITLE,
176 )
177}
178
179/// A subagent's heartbeat keeps its parent `?2` seen at `?1`.
180pub(crate) const SEE_PARENT: &str = "UPDATE sessions SET last_seen_at = ?1 WHERE id = ?2 AND state = 'active' AND last_seen_at < ?1";
181
182/// Records one entry in session `?7`, and in pull request `?1` when it
183/// is on one. Each takes the next number of the session, and of its pull
184/// request, itself, so two batches at once cannot collide; an entry whose
185/// key (`?8`) the session has recorded already is skipped.
186pub(crate) const INSERT_ENTRY: &str = "INSERT INTO session_entries
187 (pull_id, seq, kind, text, tool, \"commit\", at, session_id, session_seq, key)
188 SELECT ?1,
189 CASE WHEN ?1 IS NULL
190 THEN (SELECT COALESCE(MAX(session_seq), 0) + 1 FROM session_entries WHERE session_id = ?7)
191 ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM session_entries WHERE pull_id = ?1) END,
192 ?2, ?3, ?4, ?5, ?6, ?7,
193 (SELECT COALESCE(MAX(session_seq), 0) + 1 FROM session_entries WHERE session_id = ?7),
194 ?8
195 WHERE true
196 ON CONFLICT (session_id, key) WHERE session_id IS NOT NULL AND key IS NOT NULL DO NOTHING
197 RETURNING session_seq AS n";
198
199/// Entries of session `?3` recorded before it named its pull request
200/// `?1` join it, numbered after its last (`?2`), in their order.
201pub(crate) const MOVE_ENTRIES: &str = "UPDATE session_entries SET pull_id = ?1, seq = ?2 + session_seq
202 WHERE session_id = ?3 AND pull_id IS NULL";
203
204/// Renews active claim `?2` to `?1`.
205pub(crate) const RENEW_CLAIM: &str = "UPDATE claims SET expires_at = ?1 WHERE id = ?2 AND state = 'active'";
206
207/// Marks session `?2` as announced at `?1` when it was last announced at
208/// or before `?3` (or never): a row back means it is time to announce.
209pub(crate) const MARK_PROGRESS: &str = "UPDATE sessions SET progressed_at = ?1
210 WHERE id = ?2 AND (progressed_at IS NULL OR progressed_at <= ?3) RETURNING progressed_at";
211
212/// When a claim of `minutes` renewed at `now` lapses, if that is later
213/// than it does already (`expires_at`): a heartbeat never shortens one.
214pub(crate) fn renewal(now: u64, minutes: u32, expires_at: &str) -> Option<String> {
215 let renewed = after_minutes(now, minutes);
216 (renewed.as_str() > expires_at).then_some(renewed)
217}
218
219/// Whether a session is a subagent that ended on purpose: it stays ended.
220fn finished_subagent(row: &SessionRow) -> bool {
221 row.parent_id.is_some() && row.state == "ended" && !revives(row.end_reason.as_deref())
222}
223
224impl Work {
225 /// The work item and pull request a session names, checked: a work
226 /// item of the repository's workspace, and a pull request of the
227 /// repository. A pull request for a work item names that item when no
228 /// item was given.
229 async fn session_subjects(&self, repo: &Repo, item: Option<u32>, pull: Option<u32>) -> Result<Outcome<(Option<(String, u32)>, Option<u32>)>> {
230 let workspace = repo.namespace.to_lowercase();
231 let mut found_item = None;
232 if let Some(number) = item.filter(|number| *number > 0) {
233 match items::by_number(self, &workspace, number).await? {
234 Some(held) => found_item = Some((held.item.id, number)),
235 None => return Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is not a work item in {workspace}."))),
236 }
237 }
238 let pull = pull.filter(|number| *number > 0);
239 if let Some(number) = pull {
240 match self.pull(&repo.id, number).await? {
241 Some(found) => {
242 if found_item.is_none()
243 && let (Some(id), Some(number)) = (self.pull_item_id(&found.id).await?, found.item)
244 {
245 found_item = Some((id, number));
246 }
247 }
248 None => {
249 return Ok(Outcome::fail(FailureCode::Invalid, format!("#{number} is not a pull request in this repository.")));
250 }
251 }
252 }
253 Ok(Outcome::Ok((found_item, pull)))
254 }
255
256 /// Ties a session to what it said it is on, and what its text names.
257 async fn link_fields(&self, session: &Session, fields: &Fields) -> Result<()> {
258 if let Some((id, _)) = &fields.item {
259 self.link_item(&session.id, &session.repo_id, id, LinkVia::Attached).await;
260 }
261 if let Some(number) = fields.pull {
262 self.link_pull(&session.id, &session.repo_id, number, LinkVia::Attached).await;
263 }
264 if let Some(branch) = &fields.branch {
265 self.link_branch(&session.id, &session.repo_id, branch).await?;
266 }
267 let named = fields.named();
268 if !named.is_empty() {
269 self.link_refs(&session.id, &session.repo_id, &named).await?;
270 }
271 Ok(())
272 }
273
274 pub(crate) async fn start_session(&self, a: StartSessionArgs) -> Result<Outcome<Session>> {
275 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
276 // A subagent's parent: one of the actor's sessions here.
277 let parent = match a.parent_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
278 Some(id) => match self.session_row(&repo.id, id).await? {
279 Some(row) if row.actor_id == a.actor.id => Some(row),
280 _ => return Ok(Outcome::fail(FailureCode::Invalid, "parent_id is not one of your sessions in this repository.")),
281 },
282 None => None,
283 };
284 let (item, pull) = unwrap_or_fail!(self.session_subjects(&repo, a.item, a.pull).await?);
285 let mut fields = Fields {
286 item,
287 pull,
288 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
289 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
290 title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
291 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
292 task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
293 prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
294 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
295 result: None,
296 };
297 let harness_session_id = tidy(a.harness_session_id.as_deref(), MAX_ID_CHARS);
298 let now = now_ms();
299 let at = rfc3339(now);
300 // The same harness session again: the one already started.
301 if let Some(harness_id) = &harness_session_id
302 && let Some(row) = self.harness_session(&repo.id, &a.actor, a.harness, harness_id).await?
303 {
304 return self.resume(&repo, row, &a.actor, fields).await;
305 }
306 // A subagent works on what its parent does unless it says.
307 if let Some(parent) = &parent {
308 fields.item = fields.item.clone().or(parent.item_id.clone().zip(parent.item));
309 fields.pull = fields.pull.or(parent.pull);
310 fields.branch = fields.branch.clone().or(parent.branch.clone());
311 fields.head_commit = fields.head_commit.clone().or(parent.head_commit.clone());
312 }
313 let parent_id = parent.as_ref().map(|parent| parent.id.clone());
314 let id = new_id("ses", now);
315 // The session as the insert makes it, announced with it.
316 let expected = SessionRow {
317 id: id.clone(),
318 repo_id: repo.id.clone(),
319 item_id: fields.item.as_ref().map(|(id, _)| id.clone()),
320 item: fields.item.as_ref().map(|(_, number)| *number),
321 pull: fields.pull,
322 branch: fields.branch.clone(),
323 actor_id: a.actor.id.clone(),
324 actor_name: a.actor.username.clone(),
325 harness: a.harness.as_str().to_owned(),
326 harness_session_id: harness_session_id.clone(),
327 title: fields.title.clone(),
328 state: "active".to_owned(),
329 head_commit: fields.head_commit.clone(),
330 started_at: at.clone(),
331 last_seen_at: at.clone(),
332 ended_at: None,
333 end_reason: None,
334 parent_id: parent_id.clone(),
335 agent_type: fields.agent_type.clone(),
336 task: fields.task.clone(),
337 prompt: fields.prompt.clone(),
338 result: None,
339 model: fields.model.clone(),
340 title_source: fields.title.as_ref().map(|_| TitleSource::Agent.as_str().to_owned()),
341 title_settled_at: fields.title.as_ref().map(|_| at.clone()),
342 }
343 .at(now);
344 let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
345 let mut statements = vec![self
346 .db
347 .prepare(insert_session_sql())
348 .bind(&[
349 id.as_str().into(),
350 repo.id.as_str().into(),
351 fields.item.as_ref().map_or(JsValue::NULL, |(id, _)| JsValue::from(id.as_str())),
352 number_or_null(fields.pull),
353 text_or_null(&fields.branch),
354 a.actor.id.as_str().into(),
355 a.actor.username.as_str().into(),
356 a.harness.as_str().into(),
357 text_or_null(&harness_session_id),
358 text_or_null(&fields.title),
359 text_or_null(&fields.head_commit),
360 at.as_str().into(),
361 at.as_str().into(),
362 text_or_null(&parent_id),
363 text_or_null(&fields.agent_type),
364 text_or_null(&fields.task),
365 text_or_null(&fields.prompt),
366 text_or_null(&fields.model),
367 ])?];
368 // Started at the same moment by another call: none, and no update.
369 let sent = push_delivery(
370 &mut statements,
371 self.delivery(
372 "session.started",
373 &repo.id,
374 started(&expected),
375 &format!("{id}:started"),
376 "EXISTS (SELECT 1 FROM sessions WHERE id = ?7)",
377 vec![id.as_str().into()],
378 )?,
379 );
380 let inserted = match self.db.batch(statements).await?.into_iter().next() {
381 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
382 None => None,
383 };
384 let Some(row) = inserted else {
385 // Started at the same moment by another call: that one.
386 let Some(harness_id) = &harness_session_id else {
387 return Ok(not_found());
388 };
389 return match self.harness_session(&repo.id, &a.actor, a.harness, harness_id).await? {
390 Some(row) => Ok(Outcome::Ok(row.at(now))),
391 None => Ok(not_found()),
392 };
393 };
394 let session = row.at(now);
395 self.link_fields(&session, &fields).await?;
396 if let Some(parent) = &parent_id {
397 self.db.prepare(SEE_PARENT).bind(&[at.as_str().into(), parent.as_str().into()])?.run().await?;
398 }
399 self.delivered(sent, "session.started", &repo.id, Some(a.actor.id.clone()), started(&session)).await?;
400 Ok(Outcome::Ok(session))
401 }
402
403 async fn harness_session(&self, repo_id: &str, actor: &User, harness: Harness, harness_id: &str) -> Result<Option<SessionRow>> {
404 self.db
405 .prepare(harness_session_sql())
406 .bind(&[repo_id.into(), actor.id.as_str().into(), harness.as_str().into(), harness_id.into()])?
407 .first::<SessionRow>(None)
408 .await
409 }
410
411 /// A heartbeat, with whatever `fields` change: the session is seen now,
412 /// brought back if it had ended (announced as started again), its
413 /// claims renewed, and entries recorded before it named its pull
414 /// request moved onto it. A subagent that finished only takes the
415 /// fields, and stays ended.
416 async fn resume(&self, repo: &Repo, row: SessionRow, actor: &User, fields: Fields) -> Result<Outcome<Session>> {
417 let now = now_ms();
418 let was_ended = row.state == "ended";
419 let finished = finished_subagent(&row);
420 let had_pull = row.pull;
421 let parent_id = row.parent_id.clone();
422 let id = row.id.clone();
423 let at = rfc3339(now);
424 let current_source = row.title_source.as_deref().and_then(TitleSource::parse);
425 // A title given: its agent's, unless it is what g1t calls it already.
426 let given = fields.title.as_deref().map(|title| titles::given_source(title, row.title.as_deref(), current_source));
427 let pinned = current_source == Some(TitleSource::Person);
428 let mut binds = fields.binds();
429 binds.push(at.as_str().into());
430 binds.push(id.as_str().into());
431 binds.push(given.map_or(JsValue::NULL, |source| source.as_str().into()));
432 let sql = if finished { refresh_sql() } else { resume_sql() };
433 let mut statements = vec![self.db.prepare(sql).bind(&binds)?];
434 // Brought back from having ended: announced with the change.
435 let sent = if was_ended && !finished {
436 let expected = SessionRow {
437 item_id: fields.item.as_ref().map(|(id, _)| id.clone()).or(row.item_id.clone()),
438 item: fields.item.as_ref().map(|(_, number)| *number).or(row.item),
439 pull: fields.pull.or(row.pull),
440 branch: fields.branch.clone().or(row.branch.clone()),
441 head_commit: fields.head_commit.clone().or(row.head_commit.clone()),
442 title: if pinned { row.title.clone() } else { fields.title.clone().or(row.title.clone()) },
443 title_source: if pinned { row.title_source.clone() } else { given.map(|source| source.as_str().to_owned()).or(row.title_source.clone()) },
444 agent_type: fields.agent_type.clone().or(row.agent_type.clone()),
445 task: fields.task.clone().or(row.task.clone()),
446 prompt: fields.prompt.clone().or(row.prompt.clone()),
447 model: fields.model.clone().or(row.model.clone()),
448 result: fields.result.clone().or(row.result.clone()),
449 last_seen_at: at.clone(),
450 state: "active".to_owned(),
451 ended_at: None,
452 end_reason: None,
453 ..row.clone()
454 }
455 .at(now);
456 push_delivery(
457 &mut statements,
458 self.delivery(
459 "session.started",
460 &repo.id,
461 g1t_contracts::events::marked(SessionEvent::of(&expected), Some(actor)),
462 &format!("{id}:started:{at}"),
463 "EXISTS (SELECT 1 FROM sessions WHERE id = ?7 AND state = 'active' AND last_seen_at = ?8)",
464 vec![id.as_str().into(), at.as_str().into()],
465 )?,
466 )
467 } else {
468 None
469 };
470 let updated = match self.db.batch(statements).await?.into_iter().next() {
471 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
472 None => None,
473 };
474 let Some(updated) = updated else {
475 return Ok(not_found());
476 };
477 let session = updated.at(now);
478 self.link_fields(&session, &fields).await?;
479 if finished {
480 return Ok(Outcome::Ok(session));
481 }
482 self.renew_claims(&id, now).await?;
483 if let Some(parent) = &parent_id {
484 self.db.prepare(SEE_PARENT).bind(&[at.as_str().into(), parent.as_str().into()])?.run().await?;
485 }
486 if let Some(number) = session.pull
487 && had_pull != Some(number)
488 {
489 self.move_entries_onto_pull(&repo.id, &id, number).await?;
490 }
491 if was_ended {
492 let data = g1t_contracts::events::marked(SessionEvent::of(&session), Some(actor));
493 self.delivered(sent, "session.started", &repo.id, Some(actor.id.clone()), data).await?;
494 }
495 Ok(Outcome::Ok(session))
496 }
497
498 /// Renews the active claims made from a session by their own length.
499 async fn renew_claims(&self, session_id: &str, now: u64) -> Result<()> {
500 let claims = self
501 .db
502 .prepare("SELECT id, ttl_minutes, expires_at FROM claims WHERE session_id = ? AND state = 'active'")
503 .bind(&[session_id.into()])?
504 .all()
505 .await?
506 .results::<RenewRow>()?;
507 let mut statements = Vec::new();
508 for claim in claims {
509 if let Some(renewed) = renewal(now, claim.ttl_minutes, &claim.expires_at) {
510 statements.push(self.db.prepare(RENEW_CLAIM).bind(&[renewed.into(), claim.id.into()])?);
511 }
512 }
513 if !statements.is_empty() {
514 self.db.batch(statements).await?;
515 }
516 Ok(())
517 }
518
519 /// Entries a session recorded before it named its pull request join
520 /// that pull request's session, after what it has, in their order.
521 async fn move_entries_onto_pull(&self, repo_id: &str, session_id: &str, number: u32) -> Result<()> {
522 let Some(pull) = self.pull(repo_id, number).await? else {
523 return Ok(());
524 };
525 let base = self
526 .db
527 .prepare("SELECT COALESCE(MAX(seq), 0) AS n FROM session_entries WHERE pull_id = ?")
528 .bind(&[pull.id.as_str().into()])?
529 .first::<NumberRow>(None)
530 .await?
531 .map_or(0, |row| row.n);
532 let moved = self
533 .db
534 .prepare(MOVE_ENTRIES)
535 .bind(&[pull.id.as_str().into(), base.into(), session_id.into()])?
536 .run()
537 .await;
538 // An entry recorded on the pull request at the same moment took a
539 // number these needed: they stay the session's alone.
540 if let Err(error) = moved {
541 worker::console_error!("session {session_id}: entries not moved onto #{number}: {error}");
542 }
543 Ok(())
544 }
545
546 /// The caller's own session, as stored.
547 async fn own_session(&self, repo: &Repo, actor: &User, id: &str) -> Result<Outcome<SessionRow>> {
548 Ok(match self.session_row(&repo.id, id.trim()).await? {
549 Some(row) if row.actor_id == actor.id => Outcome::Ok(row),
550 _ => not_found(),
551 })
552 }
553
554 pub(crate) async fn update_session(&self, a: UpdateSessionArgs) -> Result<Outcome<Session>> {
555 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
556 let row = unwrap_or_fail!(self.own_session(&repo, &a.actor, &a.id).await?);
557 if row.state == "ended" && !revives(row.end_reason.as_deref()) && !finished_subagent(&row) {
558 return Ok(Outcome::fail(FailureCode::Conflict, "This session has ended. Start a new one."));
559 }
560 let (item, pull) = unwrap_or_fail!(self.session_subjects(&repo, a.item, a.pull).await?);
561 let fields = Fields {
562 item,
563 pull,
564 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
565 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
566 title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
567 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
568 task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
569 prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
570 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
571 result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS),
572 };
573 self.resume(&repo, row, &a.actor, fields).await
574 }
575
576 /// A session `actor` may end: their own, or anyone's when they have
577 /// write access to the repository (one left behind by a harness that
578 /// was killed or never said it ended).
579 async fn endable_session(&self, repo: &Repo, actor: &User, id: &str) -> Result<Outcome<SessionRow>> {
580 let Some(row) = self.session_row(&repo.id, id.trim()).await? else {
581 return Ok(not_found());
582 };
583 let can_push = matches!(crate::allowed(Some(actor), repo, g1t_contracts::access::Capability::Push), Outcome::Ok(()));
584 Ok(if may_end_session(&row.actor_id, &actor.id, can_push) {
585 Outcome::Ok(row)
586 } else {
587 Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can end it.")
588 })
589 }
590
591 pub(crate) async fn end_session(&self, a: EndSessionArgs) -> Result<Outcome<Session>> {
592 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
593 let row = unwrap_or_fail!(self.endable_session(&repo, &a.actor, &a.id).await?);
594 let own = row.actor_id == a.actor.id;
595 let now = now_ms();
596 let at = rfc3339(now);
597 // What it reported is its own to say: someone else ending it says nothing for it.
598 let result = if own { clip(a.result.as_deref(), MAX_RESULT_CHARS) } else { None };
599 if row.state == "ended" {
600 // Ended already: it keeps what it reported, or takes it now.
601 if row.result.is_none()
602 && let Some(result) = &result
603 {
604 let updated = self
605 .db
606 .prepare(format!("UPDATE sessions SET result = ? WHERE id = ? RETURNING {SESSION_COLUMNS}"))
607 .bind(&[result.as_str().into(), row.id.as_str().into()])?
608 .first::<SessionRow>(None)
609 .await?;
610 if let Some(updated) = updated {
611 return Ok(Outcome::Ok(updated.at(now)));
612 }
613 }
614 return Ok(Outcome::Ok(row.at(now)));
615 }
616 let reason = session_end_reason(own, a.by_hand, &a.actor.username, tidy(a.reason.as_deref(), MAX_NOTE_CHARS).as_deref());
617 let ended_event = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
618 // Its subagents still going end with it, all in one change with
619 // their updates. (One started meanwhile ends with it too, and is
620 // announced on its own below.)
621 let going: Vec<SessionRow> = self
622 .db
623 .prepare(tree_sql())
624 .bind(&[row.id.as_str().into()])?
625 .all()
626 .await?
627 .results::<SessionRow>()?
628 .into_iter()
629 .filter(|child| child.state == "active")
630 .collect();
631 let end_one = |session: SessionRow, reason: &str, result: Option<String>| {
632 SessionRow {
633 state: "ended".to_owned(),
634 ended_at: Some(at.clone()),
635 end_reason: Some(reason.to_owned()),
636 last_seen_at: at.clone(),
637 result: result.or(session.result.clone()),
638 ..session
639 }
640 .at(now)
641 };
642 let guard = "EXISTS (SELECT 1 FROM sessions WHERE id = ?7 AND state = 'ended' AND ended_at = ?8)";
643 let mut statements = vec![self
644 .db
645 .prepare(format!(
646 "UPDATE sessions SET state = 'ended', ended_at = ?1, end_reason = ?2, last_seen_at = ?1, result = COALESCE(?3, result),
647 {final_due}
648 WHERE id = ?4 AND state = 'active'
649 RETURNING {SESSION_COLUMNS}",
650 final_due = titles::final_due("?1"),
651 ))
652 .bind(&[at.as_str().into(), reason.as_str().into(), text_or_null(&result), row.id.as_str().into()])?];
653 let expected = end_one(row.clone(), &reason, result.clone());
654 let sent = push_delivery(
655 &mut statements,
656 self.delivery(
657 "session.ended",
658 &repo.id,
659 ended_event(&expected),
660 &format!("{}:ended:{at}", row.id),
661 guard,
662 vec![row.id.as_str().into(), at.as_str().into()],
663 )?,
664 );
665 // Only once it ended here, so a call that lost the race ends nothing.
666 statements.push(self.db.prepare(end_tree_sql()).bind(&[row.id.as_str().into(), at.as_str().into(), "parent ended".into()])?);
667 let tree_index = statements.len() - 1;
668 let mut orphans_sent = Vec::new();
669 for child in going {
670 let child_id = child.id.clone();
671 let expected = end_one(child, "parent ended", None);
672 let delivery = self.delivery(
673 "session.ended",
674 &repo.id,
675 ended_event(&expected),
676 &format!("{child_id}:ended:{at}"),
677 guard,
678 vec![child_id.as_str().into(), at.as_str().into()],
679 )?;
680 orphans_sent.push((child_id, push_delivery(&mut statements, delivery)));
681 }
682 let mut results = self.db.batch(statements).await?;
683 let orphans: Vec<SessionRow> = match results.get(tree_index) {
684 Some(result) => result.results()?,
685 None => Vec::new(),
686 };
687 let ended = match results.drain(..).next() {
688 Some(result) => result.results::<SessionRow>()?.into_iter().next(),
689 None => None,
690 };
691 let Some(ended) = ended else {
692 // Ended by another call meanwhile.
693 return Ok(match self.session_row(&repo.id, &row.id).await? {
694 Some(row) => Outcome::Ok(row.at(now)),
695 None => not_found(),
696 });
697 };
698 let session = ended.at(now);
699 if let Some(result) = &result {
700 self.link_refs(&session.id, &session.repo_id, &result.chars().take(MAX_REF_TEXT).collect::<String>()).await?;
701 }
702 self.delivered(sent, "session.ended", &repo.id, Some(a.actor.id.clone()), ended_event(&session)).await?;
703 for orphan in orphans {
704 let orphan = orphan.at(now);
705 match orphans_sent.iter().position(|(id, _)| *id == orphan.id) {
706 Some(index) => {
707 let (_, sent) = orphans_sent.swap_remove(index);
708 self.delivered(sent, "session.ended", &repo.id, Some(a.actor.id.clone()), ended_event(&orphan)).await?;
709 }
710 None => self.publish("session.ended", &repo.id, &a.actor, SessionEvent::of(&orphan)).await?,
711 }
712 }
713 Ok(Outcome::Ok(session))
714 }
715
716 /// A person names a session from the site (`TitleSessionArgs`): pinned,
717 /// or with no title, unpinned for g1t to name again.
718 pub(crate) async fn title_session(&self, a: TitleSessionArgs) -> Result<Outcome<Session>> {
719 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
720 let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else {
721 return Ok(not_found());
722 };
723 let can_push = matches!(crate::allowed(Some(&a.actor), &repo, g1t_contracts::access::Capability::Push), Outcome::Ok(()));
724 if !may_end_session(&row.actor_id, &a.actor.id, can_push) {
725 return Ok(Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can name it."));
726 }
727 let at = rfc3339(now_ms());
728 let title = tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS);
729 let statement = match &title {
730 Some(title) => self.db.prepare(titles::PIN_TITLE).bind(&[title.as_str().into(), at.as_str().into(), row.id.as_str().into()])?,
731 None => self.db.prepare(titles::UNPIN_TITLE).bind(&[at.as_str().into(), row.id.as_str().into()])?,
732 };
733 statement.run().await?;
734 let Some(row) = self.session_row(&repo.id, &row.id).await? else {
735 return Ok(not_found());
736 };
737 let session = row.at(now_ms());
738 self.publish_as(
739 "session.progressed",
740 &repo.id,
741 Some(a.actor.id.clone()),
742 SessionProgressed { repo_id: repo.id.clone(), number: session.pull.or(session.item), session_id: session.id.clone(), root_id: None },
743 )
744 .await?;
745 let mut sessions = [session];
746 shown::sessions(&mut sessions, &Some(a.actor.clone()), &repo.namespace);
747 let [session] = sessions;
748 Ok(Outcome::Ok(session))
749 }
750
751 pub(crate) async fn get_work_session(&self, a: WorkSessionArgs) -> Result<Outcome<SessionDetail>> {
752 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
753 let Some(row) = self.session_row(&repo.id, a.id.trim()).await? else {
754 return Ok(not_found());
755 };
756 let now = now_ms();
757 let entries = self
758 .db
759 .prepare(
760 "SELECT session_seq AS seq, kind, text, tool, \"commit\", at FROM session_entries
761 WHERE session_id = ? AND session_seq > ? ORDER BY session_seq LIMIT ?",
762 )
763 .bind(&[row.id.as_str().into(), a.after_seq.into(), ENTRY_PAGE.into()])?
764 .all()
765 .await?
766 .results::<crate::rows::SessionRow>()?
767 .into_iter()
768 .map(SessionEntry::from)
769 .collect();
770 let mut session = row.at(now);
771 let children = self.subagents_of(&session.id, now).await?;
772 session.subagents = children.iter().filter(|child| child.parent_id.as_deref() == Some(session.id.as_str())).count() as u32;
773 // A subagent's page names the session at the top of its tree.
774 let root = match &session.parent_id {
775 Some(_) => {
776 let mut top = None;
777 let mut at = session.parent_id.clone();
778 for _ in 0..8 {
779 let Some(id) = at else { break };
780 let Some(found) = self.session_row(&repo.id, &id).await? else { break };
781 at = found.parent_id.clone();
782 top = Some(found.at(now));
783 }
784 top
785 }
786 None => None,
787 };
788 let mut ids: Vec<String> = vec![session.id.clone()];
789 ids.extend(children.iter().map(|child| child.id.clone()));
790 let (touched, links, skills) = self.tree_facts(&repo.id, &ids).await?;
791 let updates = self.pending_updates(&session.id).await?;
792 let detail = SessionDetail { session, entries, children, touched, links, skills, root, updates, limited: false };
793 Ok(Outcome::Ok(shown::detail(detail, &a.viewer, &repo.namespace)))
794 }
795
796 /// The sessions tied to some pull requests, for Home (`PullSessionsArgs`).
797 pub(crate) async fn pull_sessions(&self, a: PullSessionsArgs) -> Result<Outcome<Vec<PullSessions>>> {
798 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
799 let mut numbers = a.numbers;
800 numbers.sort_unstable();
801 numbers.dedup();
802 numbers.truncate(MAX_PULL_SESSIONS);
803 if numbers.is_empty() {
804 return Ok(Outcome::Ok(Vec::new()));
805 }
806 let rows = self
807 .db
808 .prepare(PULL_SESSIONS)
809 .bind(&[repo.id.as_str().into(), serde_json::to_string(&numbers)?.into()])?
810 .all()
811 .await?
812 .results::<PullSessionRow>()?;
813 Ok(Outcome::Ok(pull_sessions_of(rows)))
814 }
815
816 pub(crate) async fn list_work_sessions(&self, a: ListWorkSessionsArgs) -> Result<Outcome<Vec<Session>>> {
817 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
818 let now = now_ms();
819 let idle_since = rfc3339(now.saturating_sub(SESSION_IDLE_MS));
820 // Every statement names each bind it is given.
821 let state = match a.state {
822 None => "AND ?2 IS NOT NULL",
823 Some(SessionState::Active) => "AND state = 'active' AND last_seen_at > ?2",
824 Some(SessionState::Idle) => "AND state = 'active' AND last_seen_at <= ?2",
825 Some(SessionState::Ended) => "AND state = 'ended' AND ?2 IS NOT NULL",
826 };
827 // On a work item or pull request: attached, or tied to it any way.
828 let mut linked: Vec<String> = Vec::new();
829 let mut item_id = None;
830 if let Some(number) = a.item {
831 let Some(held) = items::by_number(self, &repo.namespace, number).await? else {
832 return Ok(Outcome::Ok(Vec::new()));
833 };
834 linked.extend(self.item_sessions(&repo.id, &held.item.id).await?);
835 // And those on its pull requests here.
836 let pulls: Vec<u32> = self
837 .db
838 .prepare("SELECT number AS n FROM pulls WHERE repo_id = ? AND item_id = ?")
839 .bind(&[repo.id.as_str().into(), held.item.id.as_str().into()])?
840 .all()
841 .await?
842 .results::<NumberRow>()?
843 .into_iter()
844 .map(|row| row.n)
845 .collect();
846 for pull in &pulls {
847 linked.extend(self.linked_sessions(&repo.id, *pull).await?);
848 }
849 linked.extend(self.branch_sessions(&repo.id, &pulls).await?);
850 item_id = Some(held.item.id);
851 }
852 if let Some(number) = a.pull {
853 linked.extend(self.linked_sessions(&repo.id, number).await?);
854 linked.extend(self.branch_sessions(&repo.id, &[number]).await?);
855 }
856 let rows = self
857 .db
858 .prepare(format!(
859 "SELECT {SESSION_COLUMNS} FROM sessions
860 WHERE repo_id = ?1 {state}
861 AND (?6 = 1 OR parent_id IS NULL OR id IN (SELECT value FROM json_each(?7)))
862 AND (?3 IS NULL OR item_id = ?3 OR id IN (SELECT value FROM json_each(?7)))
863 AND (?4 IS NULL OR pull = ?4 OR id IN (SELECT value FROM json_each(?7)))
864 AND (?8 IS NULL OR id IN (SELECT session_id FROM session_touches WHERE repo_id = ?1 AND kind = 'file'
865 AND (value = ?8 OR (substr(?8, -1) = '/' AND substr(value, 1, length(?8)) = ?8))))
866 ORDER BY last_seen_at DESC LIMIT ?5"
867 ))
868 .bind(&[
869 repo.id.as_str().into(),
870 idle_since.as_str().into(),
871 text_or_null(&item_id),
872 number_or_null(a.pull),
873 LIST_LIMIT.into(),
874 u32::from(a.subagents).into(),
875 serde_json::to_string(&linked)?.into(),
876 text_or_null(&tidy(a.path.as_deref().map(|path| path.trim_start_matches("./")), MAX_TOUCH_CHARS)),
877 ])?
878 .all()
879 .await?
880 .results::<SessionRow>()?;
881 let mut sessions: Vec<Session> = rows.into_iter().map(|row| row.at(now)).collect();
882 // With subagents, each tree whole: its subagents, and its top.
883 if a.subagents {
884 let mut more: Vec<Session> = Vec::new();
885 for session in sessions.iter().filter(|session| session.parent_id.is_none()).take(20) {
886 more.extend(self.subagents_of(&session.id, now).await?);
887 }
888 let parents: Vec<String> = sessions
889 .iter()
890 .filter_map(|session| session.parent_id.clone())
891 .filter(|parent| !sessions.iter().any(|session| &session.id == parent))
892 .collect();
893 more.extend(self.sessions_by_id(&repo.id, &parents).await?);
894 for session in more {
895 if !sessions.iter().any(|known| known.id == session.id) {
896 sessions.push(session);
897 }
898 }
899 count_subagents(&mut sessions);
900 } else {
901 self.fill_subagents(&mut sessions).await?;
902 }
903 self.fill_first_prompts(&mut sessions).await?;
904 shown::sessions(&mut sessions, &a.viewer, &repo.namespace);
905 Ok(Outcome::Ok(sessions))
906 }
907
908 /// Notes `text` (from g1t) in the live harness sessions on `pull` or
909 /// its work item, so an agent reading its session hears of it. Never
910 /// fails what it follows: a problem is logged.
911 pub(crate) async fn note_sessions(&self, pull: &Pull, text: &str) {
912 let noted = async {
913 let sessions: Vec<SessionRow> = self
914 .db
915 .prepare(format!(
916 "SELECT {SESSION_COLUMNS} FROM sessions
917 WHERE state = 'active' AND ((repo_id = ?1 AND pull = ?2)
918 OR item_id = (SELECT item_id FROM pulls WHERE repo_id = ?1 AND number = ?2))"
919 ))
920 .bind(&[pull.repo_id.as_str().into(), pull.number.into()])?
921 .all()
922 .await?
923 .results()?;
924 let at = rfc3339(now_ms());
925 let mut statements = Vec::new();
926 for session in sessions {
927 // Kept by the session alone: the pull request's own session
928 // has the note already.
929 statements.push(self.db.prepare(INSERT_ENTRY).bind(&[
930 JsValue::NULL,
931 "note".into(),
932 text.into(),
933 JsValue::NULL,
934 text_or_null(&pull.head_commit),
935 at.as_str().into(),
936 session.id.as_str().into(),
937 JsValue::NULL,
938 ])?);
939 }
940 if !statements.is_empty() {
941 self.db.batch(statements).await?;
942 }
943 Ok::<(), worker::Error>(())
944 };
945 if let Err(error) = noted.await {
946 worker::console_error!("#{}: sessions were not told: {error}", pull.number);
947 }
948 }
949
950 pub(crate) async fn record_session_entries(&self, a: RecordEntriesArgs) -> Result<Outcome<Recorded>> {
951 if a.acknowledge.len() > 100 || a.acknowledge.iter().any(|id| id.len() > 100) {
952 return Ok(Outcome::fail(FailureCode::Invalid, "Acknowledge at most 100 update IDs."));
953 }
954 if a.entries.len() > crate::MAX_ENTRY_BATCH {
955 return Ok(Outcome::fail(
956 FailureCode::Invalid,
957 format!("Send at most {} entries at a time.", crate::MAX_ENTRY_BATCH),
958 ));
959 }
960 if a.touched.len() > MAX_TOUCHES {
961 return Ok(Outcome::fail(FailureCode::Invalid, format!("Say at most {MAX_TOUCHES} things touched at a time.")));
962 }
963 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
964 let row = unwrap_or_fail!(self.own_session(&repo, &a.actor, &a.id).await?);
965 if row.state == "ended" && !revives(row.end_reason.as_deref()) {
966 return Ok(Outcome::fail(FailureCode::Conflict, "This session has ended. Start a new one."));
967 }
968 // Recording is a heartbeat too.
969 let session = unwrap_or_fail!(self.resume(&repo, row, &a.actor, Fields::default()).await?);
970 let touched = self.record_touches(&session, &a.touched).await?;
971 if a.entries.is_empty() {
972 if touched > 0 {
973 self.announce_progress(&session).await?;
974 }
975 let acknowledged = self.acknowledge_updates(&session.id, &a.acknowledge).await?;
976 return Ok(Outcome::Ok(Recorded { acknowledged, ..Recorded::default() }));
977 }
978 let pull = match session.pull {
979 Some(number) => self.pull(&repo.id, number).await?,
980 None => None,
981 };
982 let pull_id: JsValue = pull.as_ref().map_or(JsValue::NULL, |pull| pull.id.as_str().into());
983 let at = rfc3339(now_ms());
984 let total = a.entries.len() as u32;
985 let mut statements = Vec::with_capacity(a.entries.len());
986 let mut prompts = String::new();
987 // Each prompt's place among the statements, and what the first one with one is for.
988 let mut prompt_at: Vec<usize> = Vec::new();
989 let mut intent: Option<String> = None;
990 for keyed in a.entries {
991 let entry = keyed.entry;
992 let kind = serde_json::to_value(entry.kind)?;
993 // Kept without credentials: a session's page may show it to a
994 // whole team, and its prompts to its author on any device.
995 let mut text: String = shown::clean(&entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect::<String>());
996 if entry.kind == SessionEntryKind::Skill {
997 // A skill is its name, nothing more.
998 text = text.trim().chars().take(MAX_AGENT_CHARS).collect();
999 }
1000 if entry.kind == SessionEntryKind::Prompt {
1001 if prompts.len() < MAX_REF_TEXT {
1002 prompts.push_str(&text);
1003 prompts.push('\n');
1004 }
1005 prompt_at.push(statements.len());
1006 if intent.is_none() {
1007 intent = titles::intent_title(&text);
1008 }
1009 }
1010 let key = tidy(keyed.key.as_deref(), MAX_KEY_CHARS);
1011 // Each takes the next number of the session, and of its pull
1012 // request when it has one, itself, so two batches at once
1013 // cannot collide; a key already recorded is skipped.
1014 statements.push(
1015 self.db
1016 .prepare(INSERT_ENTRY)
1017 .bind(&[
1018 pull_id.clone(),
1019 kind.as_str().unwrap_or("note").into(),
1020 text.into(),
1021 text_or_null(&entry.tool),
1022 text_or_null(&entry.commit.or_else(|| session.head_commit.clone())),
1023 at.as_str().into(),
1024 session.id.as_str().into(),
1025 text_or_null(&key),
1026 ])?,
1027 );
1028 }
1029 let mut recorded = 0;
1030 let mut prompted = 0;
1031 for (index, result) in self.db.batch(statements).await?.into_iter().enumerate() {
1032 let new = result.results::<NumberRow>()?.len() as u32;
1033 recorded += new;
1034 if prompt_at.contains(&index) {
1035 prompted += new;
1036 }
1037 }
1038 if prompted > 0 {
1039 // The first prompt names the session for now; enough of them settle its title (titles.rs).
1040 let now = now_ms();
1041 self.db
1042 .prepare(titles::prompted_sql())
1043 .bind(&[
1044 session.id.as_str().into(),
1045 prompted.into(),
1046 text_or_null(&intent),
1047 rfc3339(now).into(),
1048 after_minutes(now, titles::SETTLE_AFTER_MINUTES).into(),
1049 ])?
1050 .run()
1051 .await?;
1052 }
1053 if !prompts.is_empty() && recorded > 0 {
1054 self.link_refs(&session.id, &repo.id, &prompts).await?;
1055 }
1056 if let Some(pull) = pull
1057 && recorded > 0
1058 {
1059 self.publish(
1060 "session.appended",
1061 &repo.id,
1062 &a.actor,
1063 SessionAppended { pull_id: pull.id, repo_id: repo.id.clone(), number: pull.number, count: recorded },
1064 )
1065 .await?;
1066 }
1067 if recorded > 0 || touched > 0 {
1068 self.announce_progress(&session).await?;
1069 }
1070 let acknowledged = self.acknowledge_updates(&session.id, &a.acknowledge).await?;
1071 Ok(Outcome::Ok(Recorded { recorded, duplicates: total - recorded, acknowledged }))
1072 }
1073
1074 /// `session.progressed`, at most every [`PROGRESS_EVERY_MS`] for a
1075 /// session, so the pages showing it follow along.
1076 async fn announce_progress(&self, session: &Session) -> Result<()> {
1077 let now = now_ms();
1078 let due = self
1079 .db
1080 .prepare(MARK_PROGRESS)
1081 .bind(&[rfc3339(now).into(), session.id.as_str().into(), rfc3339(now.saturating_sub(PROGRESS_EVERY_MS)).into()])?
1082 .first::<serde_json::Value>(None)
1083 .await?;
1084 if due.is_none() {
1085 return Ok(());
1086 }
1087 let mut root_id = None;
1088 let mut up = session.parent_id.clone();
1089 for _ in 0..8 {
1090 let Some(id) = up else { break };
1091 up = self.session_row(&session.repo_id, &id).await?.and_then(|row| row.parent_id);
1092 root_id = Some(id);
1093 }
1094 self.publish_as(
1095 "session.progressed",
1096 &session.repo_id,
1097 Some(session.actor.id.clone()),
1098 SessionProgressed {
1099 repo_id: session.repo_id.clone(),
1100 number: session.pull.or(session.item),
1101 session_id: session.id.clone(),
1102 root_id,
1103 },
1104 )
1105 .await
1106 }
1107}