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.

323 linesCodeBlameRaw
1//! Sessions across a workspace: what is cooking in every repository of it
2//! the viewer may read, every kind of session in one shape, with the merge
3//! queues and what just landed. Sessions' own page in the web, `GET
4//! /workspaces/{workspace}/sessions`, the `session` tool's `list` with a
5//! `workspace`, and `g1t session list --workspace`.
6//!
7//! Kinds of session:
8//! - **harness**: someone's own harness (Claude Code, Codex, Cursor,
9//! Copilot, another) recording itself with `start_session`.
10//! - **hosted**: a session g1t started for someone, from the web or a work
11//! item, on the workspace's Anthropic provider (`claude_managed_agents`).
12//! - **agent**: a run of g1t's own agent on a pull request (`agent_runs`).
13//! - **recorded**: what a coding agent recorded on a pull request with
14//! `record_session`, with no session of its own behind it.
15
16use serde::{Deserialize, Serialize};
17
18use super::{Harness, Pull, PullStatus, QueueView, Session, SessionDetail, SessionState, SESSION_IDLE_MS, SESSION_TIMEOUT_MS};
19use crate::Viewer;
20
21/// What kind of session one in a workspace's list is.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SessionKind {
25 Harness,
26 Hosted,
27 Agent,
28 Recorded,
29}
30
31impl SessionKind {
32 pub const ALL: [SessionKind; 4] = [SessionKind::Harness, SessionKind::Hosted, SessionKind::Agent, SessionKind::Recorded];
33
34 pub fn as_str(self) -> &'static str {
35 match self {
36 SessionKind::Harness => "harness",
37 SessionKind::Hosted => "hosted",
38 SessionKind::Agent => "agent",
39 SessionKind::Recorded => "recorded",
40 }
41 }
42
43 pub fn parse(name: &str) -> Option<SessionKind> {
44 let name = name.trim().to_lowercase();
45 SessionKind::ALL.into_iter().find(|kind| kind.as_str() == name)
46 }
47
48 /// The kind of a session recorded with `start_session`: g1t started
49 /// those in Claude Managed Agents, the rest are someone's own harness.
50 pub fn of(harness: Harness) -> SessionKind {
51 match harness {
52 Harness::ClaudeManagedAgents => SessionKind::Hosted,
53 _ => SessionKind::Harness,
54 }
55 }
56}
57
58/// Which of a workspace's sessions to list.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum SessionsShown {
62 /// Those going now: active or idle, a run queued or running.
63 Live,
64 /// Every one, most recently heard from first.
65 #[default]
66 Recent,
67 /// Those that ended, or went quiet long enough to have.
68 Ended,
69}
70
71impl SessionsShown {
72 pub fn parse(name: &str) -> Option<SessionsShown> {
73 match name.trim().to_lowercase().as_str() {
74 "live" => Some(SessionsShown::Live),
75 "recent" | "all" => Some(SessionsShown::Recent),
76 "ended" => Some(SessionsShown::Ended),
77 _ => None,
78 }
79 }
80
81 pub fn admits(self, state: SessionState) -> bool {
82 match self {
83 SessionsShown::Live => state != SessionState::Ended,
84 SessionsShown::Recent => true,
85 SessionsShown::Ended => state == SessionState::Ended,
86 }
87 }
88}
89
90/// The state of a session as it is now: one that went quiet past the
91/// timeout reads as ended, as the next sweep of its repository will make
92/// it, so a list across a workspace never shows one as live.
93pub fn settled_state(state: SessionState, last_seen_ms: u64, now_ms: u64) -> SessionState {
94 if state != SessionState::Ended && now_ms.saturating_sub(last_seen_ms) >= SESSION_TIMEOUT_MS {
95 SessionState::Ended
96 } else {
97 state
98 }
99}
100
101/// The state of something heard from at `last_ms` with no state of its own
102/// (a pull request's recorded session): active, idle or ended by how long
103/// it has been quiet, unless something of it is still running.
104pub fn quiet_state(running: bool, last_ms: u64, now_ms: u64) -> SessionState {
105 let quiet = now_ms.saturating_sub(last_ms);
106 if running || quiet < SESSION_IDLE_MS {
107 SessionState::Active
108 } else if quiet < SESSION_TIMEOUT_MS {
109 SessionState::Idle
110 } else {
111 SessionState::Ended
112 }
113}
114
115/// One session of a workspace, whatever its kind.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct WorkspaceSession {
119 pub kind: SessionKind,
120 /// `ses_…` for a harness or hosted session, `run_…` for g1t's agent,
121 /// the pull request's id for one recorded on it.
122 pub id: String,
123 /// The repository it is in, `owner/name`.
124 pub repo: String,
125 pub repo_id: String,
126 pub title: Option<String>,
127 /// Who runs it: whose token started it, who put g1t's agent to work, or
128 /// who opened the pull request. `g1t` for g1t's own.
129 pub actor: Option<String>,
130 /// For a harness or hosted session.
131 pub harness: Option<Harness>,
132 /// For g1t's agent and a recorded one: the agent label (`g1t`, `claude-code`).
133 pub agent: Option<String>,
134 pub branch: Option<String>,
135 /// The work item it is on, by number in the workspace.
136 pub item: Option<u32>,
137 pub item_title: Option<String>,
138 pub pull: Option<u32>,
139 pub pull_title: Option<String>,
140 pub pull_status: Option<PullStatus>,
141 pub state: SessionState,
142 /// RFC 3339.
143 pub started_at: String,
144 /// RFC 3339: its last heartbeat, step or entry.
145 pub last_activity_at: String,
146 /// How many subagents it started.
147 pub subagents: u32,
148 /// For g1t's agent: what it is doing now, in one line.
149 pub step: Option<String>,
150 /// For a harness or hosted session: the session itself.
151 pub session: Option<Session>,
152 /// With `subagents`: its subagents and theirs, each naming its parent.
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub children: Vec<Session>,
155}
156
157/// A repository of the workspace the viewer may read.
158#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct SessionsRepo {
161 pub id: String,
162 /// `owner/name`.
163 pub path: String,
164 pub default_branch: String,
165}
166
167/// One repository's merge queue, when something is waiting in it.
168#[derive(Clone, Debug, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct RepoQueue {
171 /// `owner/name`.
172 pub repo: String,
173 pub default_branch: String,
174 #[serde(flatten)]
175 pub queue: QueueView,
176}
177
178/// A pull request with the repository it is in.
179#[derive(Clone, Debug, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct RepoPull {
182 /// `owner/name`.
183 pub repo: String,
184 #[serde(flatten)]
185 pub pull: Pull,
186}
187
188/// `list_workspace_sessions`'s answer.
189#[derive(Clone, Debug, Default, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct WorkspaceSessions {
192 /// Most recently heard from first; live ones first with `Live`.
193 pub sessions: Vec<WorkspaceSession>,
194 /// The merge queues with something waiting, busiest first.
195 #[serde(default)]
196 pub queue: Vec<RepoQueue>,
197 /// The pull requests merged most recently, newest first.
198 #[serde(default)]
199 pub landed: Vec<RepoPull>,
200 /// The repositories looked in: every one of the workspace the viewer
201 /// may read, or the one asked for.
202 #[serde(default)]
203 pub repos: Vec<SessionsRepo>,
204}
205
206/// The most sessions one list gives.
207pub const MAX_WORKSPACE_SESSIONS: u32 = 200;
208/// The sessions a list gives when it does not say.
209pub const DEFAULT_WORKSPACE_SESSIONS: u32 = 60;
210
211/// `list_workspace_sessions`: the sessions of every repository of a
212/// workspace the viewer may read. Returns `Outcome<WorkspaceSessions>`.
213#[derive(Debug, Default, Serialize, Deserialize)]
214pub struct ListWorkspaceSessionsArgs {
215 pub viewer: Viewer,
216 pub workspace: String,
217 #[serde(default)]
218 pub shown: SessionsShown,
219 #[serde(default)]
220 pub kind: Option<SessionKind>,
221 #[serde(default)]
222 pub harness: Option<Harness>,
223 /// Only those this person runs, by username.
224 #[serde(default)]
225 pub person: Option<String>,
226 /// Only those in this repository of the workspace, by name.
227 #[serde(default)]
228 pub repo: Option<String>,
229 /// Only those on this work item, by number.
230 #[serde(default)]
231 pub item: Option<u32>,
232 /// Each session's subagents too, in its `children`.
233 #[serde(default)]
234 pub subagents: bool,
235 /// Leave out the merge queues and what landed.
236 #[serde(default)]
237 pub sessions_only: bool,
238 #[serde(default)]
239 pub limit: Option<u32>,
240}
241
242/// `get_workspace_session`: one harness or hosted session of a workspace by
243/// its id, in whichever of its repositories it runs. Returns
244/// `Outcome<WorkspaceSessionDetail>`.
245#[derive(Debug, Serialize, Deserialize)]
246pub struct GetWorkspaceSessionArgs {
247 pub viewer: Viewer,
248 pub workspace: String,
249 pub id: String,
250 #[serde(default)]
251 pub after_seq: u32,
252}
253
254/// A session and the repository it runs in.
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct WorkspaceSessionDetail {
257 /// `owner/name`.
258 pub repo: String,
259 #[serde(flatten)]
260 pub detail: SessionDetail,
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 const NOW: u64 = 1_791_800_000_000;
268
269 #[test]
270 fn a_session_started_by_g1t_is_hosted_and_the_rest_are_harnesses() {
271 assert_eq!(SessionKind::of(Harness::ClaudeManagedAgents), SessionKind::Hosted);
272 for harness in [Harness::ClaudeCode, Harness::Codex, Harness::Cursor, Harness::Copilot, Harness::Other] {
273 assert_eq!(SessionKind::of(harness), SessionKind::Harness);
274 }
275 for kind in SessionKind::ALL {
276 assert_eq!(SessionKind::parse(kind.as_str()), Some(kind));
277 }
278 assert_eq!(SessionKind::parse("Agent"), Some(SessionKind::Agent));
279 assert_eq!(SessionKind::parse("bot"), None);
280 }
281
282 #[test]
283 fn a_quiet_session_settles_as_ended_and_live_shows_only_those_going() {
284 assert_eq!(settled_state(SessionState::Active, NOW - 60_000, NOW), SessionState::Active);
285 assert_eq!(settled_state(SessionState::Idle, NOW - SESSION_IDLE_MS, NOW), SessionState::Idle);
286 assert_eq!(settled_state(SessionState::Idle, NOW - SESSION_TIMEOUT_MS, NOW), SessionState::Ended);
287 assert!(SessionsShown::Live.admits(SessionState::Idle));
288 assert!(!SessionsShown::Live.admits(SessionState::Ended));
289 assert!(SessionsShown::Ended.admits(SessionState::Ended));
290 assert!(!SessionsShown::Ended.admits(SessionState::Active));
291 assert!(SessionsShown::Recent.admits(SessionState::Ended));
292 assert_eq!(SessionsShown::parse("live"), Some(SessionsShown::Live));
293 assert_eq!(SessionsShown::parse("all"), Some(SessionsShown::Recent));
294 assert_eq!(SessionsShown::default(), SessionsShown::Recent);
295 }
296
297 #[test]
298 fn a_recorded_session_reads_its_state_from_how_long_it_has_been_quiet() {
299 assert_eq!(quiet_state(false, NOW - 1_000, NOW), SessionState::Active);
300 assert_eq!(quiet_state(false, NOW - SESSION_IDLE_MS, NOW), SessionState::Idle);
301 assert_eq!(quiet_state(false, NOW - SESSION_TIMEOUT_MS, NOW), SessionState::Ended);
302 assert_eq!(quiet_state(true, NOW - SESSION_TIMEOUT_MS * 4, NOW), SessionState::Active);
303 }
304
305 #[test]
306 fn a_detail_carries_its_repository_beside_the_session() {
307 let detail: WorkspaceSessionDetail = serde_json::from_value(serde_json::json!({
308 "repo": "acme/web",
309 "session": {
310 "id": "ses_1", "repoId": "rep_1", "item": 4, "pull": null, "branch": "main",
311 "actor": { "id": "usr_1", "username": "ana" }, "harness": "codex", "harnessSessionId": null,
312 "title": "Greet", "state": "active", "headCommit": null, "startedAt": "t", "lastSeenAt": "t",
313 "endedAt": null, "endReason": null
314 },
315 "entries": []
316 }))
317 .unwrap();
318 assert_eq!(detail.repo, "acme/web");
319 let back = serde_json::to_value(&detail).unwrap();
320 assert_eq!(back["repo"], "acme/web");
321 assert_eq!(back["session"]["id"], "ses_1");
322 }
323}