Sessions across a workspace: one list of every kind
list_workspace_sessions and get_workspace_session read every repository of a workspace the viewer can read and answer one shape for each kind of session: someone's own harness (with its subagents), one g1t started on the workspace's provider, g1t's own agent, and what an agent recorded on a pull request with no session of its own. They come with the merge queues that have something waiting and what landed last. GET /workspaces/{workspace}/sessions[/{id}], the session tool's list and get with workspace, and g1t session list --workspace.
- G1T-Session:
- ses_01m560bz6eeqc8v1hrrha03pap
24 files+622−158 files not counted0/24 viewed
| 35 | 35 | ListClaims, | |
| 36 | 36 | GetWork, | |
| 37 | 37 | StartAgentSession, | |
| 38 | + | ListWorkspaceSessions, | |
| 39 | + | GetWorkspaceSession, | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 40 | 42 | impl CoordinationOp { | |
| 41 | 43 | /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a | |
| 42 | 44 | /// test checks against this. | |
| 43 | − | pub const ALL: [CoordinationOp; 12] = [ | |
| 45 | + | pub const ALL: [CoordinationOp; 14] = [ | |
| 44 | 46 | CoordinationOp::StartSession, | |
| 45 | 47 | CoordinationOp::UpdateSession, | |
| 46 | 48 | CoordinationOp::EndSession, | |
| ⋯ | |||
| 53 | 55 | CoordinationOp::ListClaims, | |
| 54 | 56 | CoordinationOp::GetWork, | |
| 55 | 57 | CoordinationOp::StartAgentSession, | |
| 58 | + | CoordinationOp::ListWorkspaceSessions, | |
| 59 | + | CoordinationOp::GetWorkspaceSession, | |
| 56 | 60 | ]; | |
| 57 | 61 | ||
| 58 | 62 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 69 | 73 | CoordinationOp::ListClaims => "list_claims", | |
| 70 | 74 | CoordinationOp::GetWork => "get_work", | |
| 71 | 75 | CoordinationOp::StartAgentSession => "start_agent_session", | |
| 76 | + | CoordinationOp::ListWorkspaceSessions => "list_workspace_sessions", | |
| 77 | + | CoordinationOp::GetWorkspaceSession => "get_workspace_session", | |
| 72 | 78 | } | |
| 73 | 79 | } | |
| 74 | 80 | ||
| ⋯ | |||
| 87 | 93 | CoordinationOp::ListClaims => "List claims", | |
| 88 | 94 | CoordinationOp::GetWork => "Get a work item's work context", | |
| 89 | 95 | CoordinationOp::StartAgentSession => "Start an agent session on a work item", | |
| 96 | + | CoordinationOp::ListWorkspaceSessions => "List a workspace's sessions", | |
| 97 | + | CoordinationOp::GetWorkspaceSession => "Get a session of a workspace", | |
| 90 | 98 | } | |
| 91 | 99 | } | |
| 92 | 100 | ||
| 93 | 101 | /// Whether it is about one repository: sessions are where they run; | |
| 94 | 102 | /// claims and work contexts are of a workspace's work items, and take a | |
| 95 | 103 | /// repository only to name its workspace (or, for a claim, where it is | |
| 96 | − | /// made from). | |
| 104 | + | /// made from). Sessions are read across a workspace too: listing or | |
| 105 | + | /// getting them takes a repository or a workspace. | |
| 97 | 106 | pub fn needs_repo(self) -> bool { | |
| 98 | 107 | !matches!( | |
| 99 | 108 | self, | |
| 100 | − | CoordinationOp::ClaimWorkItem | |
| 109 | + | CoordinationOp::GetSession | |
| 110 | + | | CoordinationOp::ListSessions | |
| 111 | + | | CoordinationOp::ListWorkspaceSessions | |
| 112 | + | | CoordinationOp::GetWorkspaceSession | |
| 113 | + | | CoordinationOp::ClaimWorkItem | |
| 101 | 114 | | CoordinationOp::ReleaseClaim | |
| 102 | 115 | | CoordinationOp::HandOffWorkItem | |
| 103 | 116 | | CoordinationOp::ListClaims | |
| ⋯ | |||
| 109 | 122 | pub fn reads(self) -> bool { | |
| 110 | 123 | matches!( | |
| 111 | 124 | self, | |
| 112 | − | CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork | |
| 125 | + | CoordinationOp::GetSession | |
| 126 | + | | CoordinationOp::ListSessions | |
| 127 | + | | CoordinationOp::ListClaims | |
| 128 | + | | CoordinationOp::GetWork | |
| 129 | + | | CoordinationOp::ListWorkspaceSessions | |
| 130 | + | | CoordinationOp::GetWorkspaceSession | |
| 113 | 131 | ) | |
| 114 | 132 | } | |
| 115 | 133 | ||
| ⋯ | |||
| 118 | 136 | CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the work item, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `item` and `pull` attach it to a work item of the repository's workspace and a pull request of the repository; a pull request for a work item attaches that work item too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's work item, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open work items.", | |
| 119 | 137 | CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.", | |
| 120 | 138 | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the work item off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.", | |
| 121 | − | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may.", | |
| 122 | − | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.", | |
| 139 | + | CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may. With `workspace` instead of `repo`, the session is found in whichever repository of the workspace it runs in, and `repo` says which.", | |
| 140 | + | CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may. With `workspace` instead of `repo`, every repository of the workspace you may read: see list_workspace_sessions.", | |
| 141 | + | CoordinationOp::ListWorkspaceSessions => "What is cooking across a workspace: the sessions of every repository of it you may read, every kind in one shape, most recently heard from first (up to 200, 60 if `limit` is left out). `kind` is harness (someone's own harness: Claude Code, Codex, Cursor, Copilot, another), hosted (started on g1t on the workspace's Anthropic provider), agent (g1t's own agent on a pull request; members only) or recorded (what a coding agent recorded on a pull request with no session behind it). Each gives `repo`, `title`, `actor`, `harness` or `agent`, `branch`, `item` and its title, `pull` and its title and status, `state` (active, idle or ended: one quiet for 30 minutes reads as ended), `last_activity_at`, `subagents`, and for harness and hosted ones the `session` itself. `state` live (going now), recent (the default) or ended; `harness`, `person` (a username), `repo` (a repository's name in the workspace) and `item` narrow it; `subagents` true gives each tree's subagents in `children`. With them: `queue`, the merge queues with something waiting, `landed`, the pull requests merged most recently, and `repos`, the repositories looked in. Only what you may read is listed; outside the workspace, sessions show who runs them and what they are tied to, not what they recorded.", | |
| 142 | + | CoordinationOp::GetWorkspaceSession => "One session of a workspace by its id (ses_…), in whichever of its repositories it runs: as get_session, with `repo` naming the repository. Anyone who can read that repository may.", | |
| 123 | 143 | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or item (a work item it named); each is kept once, ties the session to the pull requests and work items it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair and again when a file it was not announced for joins. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. `acknowledge` accepts up to 100 exact pending update IDs this session has considered; reading alone never acknowledges. Recording is a heartbeat. Returns how many were `recorded`, how many were `duplicates`, and how many of the `acknowledge` IDs were `acknowledged` (fewer than given means some were not this session's updates).", | |
| 124 | 144 | CoordinationOp::ClaimWorkItem => "Claim an open work item: say you are working on it. A claim is advisory, never a lock: anyone may claim a work item someone else has, and `others` lists the claims already on it, so you can coordinate or pick something else. It lasts `ttl_minutes` (30 unless you say, up to 240) and is renewed by claiming again or, when you give the `session_id` of one of your sessions, by that session's heartbeats. Name the work item by `workspace` and `number`; `repo`, the repository of your checkout, names the workspace when `workspace` is left out and says where the claim is made from. Returns your `claim`, the `others`, and `blocked_by`: each work item this one waits on, with its claims and pull requests. A new claim is a work.claimed event; one taking over a hand-off says which in `took_over`. For work that has no work item yet, leave out `number` and give `title` (and `body`): the work item is opened first (filed from `repo` when given; a work_item.opened event), then claimed, the session named by `session_id` is attached to it, and `item` is the work item opened. The session is checked first, so a claim it would refuse opens nothing.", | |
| 125 | 145 | CoordinationOp::ReleaseClaim => "Release your claim on a work item, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the work item closes (reason item_closed), and expire when not renewed. Refused with `404` when you hold no active claim on it.", | |
| ⋯ | |||
| 198 | 218 | ), | |
| 199 | 219 | CoordinationOp::GetSession => ( | |
| 200 | 220 | json!({ | |
| 201 | − | "repo": repo, | |
| 221 | + | "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace." }, | |
| 222 | + | "workspace": { "type": "string", "description": "A workspace's slug, instead of repo: the session is found in whichever repository of it it runs in." }, | |
| 202 | 223 | "id": id, | |
| 203 | 224 | "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page. To read only pending `updates`, give 4294967295: no entries come back." }, | |
| 204 | 225 | }), | |
| 205 | − | &["repo", "id"], | |
| 226 | + | &["id"], | |
| 206 | 227 | ), | |
| 207 | 228 | CoordinationOp::ListSessions => ( | |
| 208 | 229 | json!({ | |
| 209 | − | "repo": repo, | |
| 230 | + | "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace for every repository of it you may read." }, | |
| 231 | + | "workspace": { "type": "string", "description": "A workspace's slug, instead of repo: its sessions across every repository of it you may read (see list_workspace_sessions for what else it takes)." }, | |
| 210 | 232 | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, | |
| 211 | 233 | "item": { "type": "integer", "description": "Only sessions on this work item: attached, or tied to it any other way." }, | |
| 212 | 234 | "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." }, | |
| 213 | 235 | "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." }, | |
| 214 | 236 | "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." }, | |
| 215 | 237 | }), | |
| 216 | − | &["repo"], | |
| 238 | + | &[], | |
| 239 | + | ), | |
| 240 | + | CoordinationOp::ListWorkspaceSessions => ( | |
| 241 | + | json!({ | |
| 242 | + | "workspace": { "type": "string", "description": "The workspace's slug." }, | |
| 243 | + | "state": { "type": "string", "enum": ["live", "recent", "ended"], "description": "live: going now (active or idle, a run queued or running); recent: every one (the default); ended." }, | |
| 244 | + | "kind": { "type": "string", "enum": SessionKind::ALL.map(SessionKind::as_str), "description": "Only sessions of this kind." }, | |
| 245 | + | "harness": { "type": "string", "enum": Harness::ALL.map(Harness::as_str), "description": "Only sessions in this harness." }, | |
| 246 | + | "person": { "type": "string", "description": "Only sessions this person runs, by username." }, | |
| 247 | + | "repo": { "type": "string", "description": "Only sessions in this repository of the workspace, by its name (or \"owner/name\")." }, | |
| 248 | + | "item": { "type": "integer", "description": "Only sessions on this work item of the workspace." }, | |
| 249 | + | "subagents": { "type": "boolean", "description": "Give each session's subagents too, in its `children`." }, | |
| 250 | + | "limit": { "type": "integer", "description": "At most this many, up to 200; 60 if left out." }, | |
| 251 | + | }), | |
| 252 | + | &["workspace"], | |
| 253 | + | ), | |
| 254 | + | CoordinationOp::GetWorkspaceSession => ( | |
| 255 | + | json!({ | |
| 256 | + | "workspace": { "type": "string", "description": "The workspace's slug." }, | |
| 257 | + | "id": id, | |
| 258 | + | "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." }, | |
| 259 | + | }), | |
| 260 | + | &["workspace", "id"], | |
| 217 | 261 | ), | |
| 218 | 262 | CoordinationOp::RecordSessionEntries => ( | |
| 219 | 263 | json!({ | |
| ⋯ | |||
| 364 | 408 | // A claim or a work context names its work item's workspace, or a | |
| 365 | 409 | // repository of it. | |
| 366 | 410 | let workspace = item_workspace(input); | |
| 411 | + | // Sessions across a workspace: listing or getting them without a repository. | |
| 412 | + | if given.is_none() && !workspace.is_empty() { | |
| 413 | + | match op { | |
| 414 | + | CoordinationOp::ListSessions => return list_workspace_sessions(services, viewer, input, workspace).await, | |
| 415 | + | CoordinationOp::GetSession => return get_workspace_session(services, viewer, input, workspace).await, | |
| 416 | + | _ => {} | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | if matches!(op, CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession) { | |
| 420 | + | if workspace.is_empty() { | |
| 421 | + | return invalid("Give the workspace by its slug.".to_owned()); | |
| 422 | + | } | |
| 423 | + | return match op { | |
| 424 | + | CoordinationOp::ListWorkspaceSessions => list_workspace_sessions(services, viewer, input, workspace).await, | |
| 425 | + | _ => get_workspace_session(services, viewer, input, workspace).await, | |
| 426 | + | }; | |
| 427 | + | } | |
| 428 | + | if matches!(op, CoordinationOp::ListSessions | CoordinationOp::GetSession) && given.is_none() { | |
| 429 | + | return invalid("Give the repository as \"owner/name\", or the workspace by its slug.".to_owned()); | |
| 430 | + | } | |
| 367 | 431 | if !op.needs_repo() && workspace.is_empty() { | |
| 368 | 432 | return invalid("Give the workspace by its slug, or a repository of it as \"owner/name\".".to_owned()); | |
| 369 | 433 | } | |
| ⋯ | |||
| 552 | 616 | }; | |
| 553 | 617 | g1t_kit::call(&services.integrations, "start_agent_session", &args).await | |
| 554 | 618 | } | |
| 619 | + | // Answered above, before a repository is asked for. | |
| 620 | + | CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession => invalid("Give the workspace by its slug.".to_owned()), | |
| 555 | 621 | } | |
| 556 | 622 | } | |
| 557 | 623 | ||
| 624 | + | /// `list_workspace_sessions`, from a `workspace` and its filters. | |
| 625 | + | async fn list_workspace_sessions(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> { | |
| 626 | + | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); | |
| 627 | + | let shown = match text(input, "state") { | |
| 628 | + | None => SessionsShown::Recent, | |
| 629 | + | Some(state) => match SessionsShown::parse(&state) { | |
| 630 | + | Some(shown) => shown, | |
| 631 | + | None => return invalid(format!("state is live, recent or ended, not {state}.")), | |
| 632 | + | }, | |
| 633 | + | }; | |
| 634 | + | let kind = match text(input, "kind") { | |
| 635 | + | None => None, | |
| 636 | + | Some(kind) => match SessionKind::parse(&kind) { | |
| 637 | + | Some(kind) => Some(kind), | |
| 638 | + | None => return invalid(format!("kind is harness, hosted, agent or recorded, not {kind}.")), | |
| 639 | + | }, | |
| 640 | + | }; | |
| 641 | + | let harness = match text(input, "harness") { | |
| 642 | + | None => None, | |
| 643 | + | Some(name) => match Harness::parse(&name) { | |
| 644 | + | Some(harness) => Some(harness), | |
| 645 | + | None => { | |
| 646 | + | let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect(); | |
| 647 | + | return invalid(format!("harness is one of {}.", names.join(", "))); | |
| 648 | + | } | |
| 649 | + | }, | |
| 650 | + | }; | |
| 651 | + | let item = match optional_number(input, "item") { | |
| 652 | + | Ok(item) => item, | |
| 653 | + | Err(message) => return invalid(message), | |
| 654 | + | }; | |
| 655 | + | let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true"); | |
| 656 | + | let args = ListWorkspaceSessionsArgs { | |
| 657 | + | viewer: viewer.clone(), | |
| 658 | + | workspace, | |
| 659 | + | shown, | |
| 660 | + | kind, | |
| 661 | + | harness, | |
| 662 | + | person: text(input, "person"), | |
| 663 | + | repo: text(input, "repo").or_else(|| text(input, "repository")), | |
| 664 | + | item, | |
| 665 | + | subagents, | |
| 666 | + | sessions_only: false, | |
| 667 | + | limit: number(input, "limit"), | |
| 668 | + | }; | |
| 669 | + | g1t_kit::call(&services.work, "list_workspace_sessions", &args).await | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | /// `get_workspace_session`, from a `workspace` and the session's `id`. | |
| 673 | + | async fn get_workspace_session(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> { | |
| 674 | + | let Some(id) = text(input, "id") else { | |
| 675 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the session by its id (ses_…).")); | |
| 676 | + | }; | |
| 677 | + | let args = GetWorkspaceSessionArgs { viewer: viewer.clone(), workspace, id, after_seq: number(input, "after").unwrap_or_default() }; | |
| 678 | + | g1t_kit::call(&services.work, "get_workspace_session", &args).await | |
| 679 | + | } | |
| 680 | + | ||
| 558 | 681 | #[cfg(test)] | |
| 559 | 682 | mod tests { | |
| 560 | 683 | use super::*; | |
| 450 | 450 | Op::Coordination(CoordinationOp::EndSession), | |
| 451 | 451 | Op::Coordination(CoordinationOp::GetSession), | |
| 452 | 452 | Op::Coordination(CoordinationOp::ListSessions), | |
| 453 | + | Op::Coordination(CoordinationOp::ListWorkspaceSessions), | |
| 454 | + | Op::Coordination(CoordinationOp::GetWorkspaceSession), | |
| 453 | 455 | Op::Coordination(CoordinationOp::RecordSessionEntries), | |
| 454 | 456 | Op::Coordination(CoordinationOp::StartAgentSession), | |
| 455 | 457 | Op::ReadSession, |
| 868 | 868 | } | |
| 869 | 869 | ||
| 870 | 870 | impl Op { | |
| 871 | − | pub const ALL: [Op; 428] = [ | |
| 871 | + | pub const ALL: [Op; 430] = [ | |
| 872 | 872 | Op::Whoami, | |
| 873 | 873 | Op::GetWorkspace, | |
| 874 | 874 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1297 | 1297 | Op::Coordination(CoordinationOp::ListClaims), | |
| 1298 | 1298 | Op::Coordination(CoordinationOp::GetWork), | |
| 1299 | 1299 | Op::Coordination(CoordinationOp::StartAgentSession), | |
| 1300 | + | Op::Coordination(CoordinationOp::ListWorkspaceSessions), | |
| 1301 | + | Op::Coordination(CoordinationOp::GetWorkspaceSession), | |
| 1300 | 1302 | ]; | |
| 1301 | 1303 | ||
| 1302 | 1304 | pub fn by_name(name: &str) -> Option<Op> { | |
Binary or large file; its contents are not shown.
| 268 | 268 | } | |
| 269 | 269 | Op::Coordination(CoordinationOp::GetSession) => through::<work::SessionDetail>(op, sent), | |
| 270 | 270 | Op::Coordination(CoordinationOp::ListSessions) => through::<Vec<work::Session>>(op, sent), | |
| 271 | + | Op::Coordination(CoordinationOp::ListWorkspaceSessions) => through::<work::WorkspaceSessions>(op, sent), | |
| 272 | + | Op::Coordination(CoordinationOp::GetWorkspaceSession) => through::<work::WorkspaceSessionDetail>(op, sent), | |
| 271 | 273 | Op::Coordination(CoordinationOp::RecordSessionEntries) => through::<work::Recorded>(op, sent), | |
| 272 | 274 | Op::Coordination(CoordinationOp::ClaimWorkItem) => through::<work::Claimed>(op, sent), | |
| 273 | 275 | Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffWorkItem) => through::<work::Claim>(op, sent), |
| 645 | 645 | Op::Coordination(CoordinationOp::ListClaims), | |
| 646 | 646 | &[("state", "state"), ("item", "item"), ("repo", "repo")], | |
| 647 | 647 | ), | |
| 648 | + | route( | |
| 649 | + | "GET", | |
| 650 | + | "/workspaces/:workspace/sessions", | |
| 651 | + | Op::Coordination(CoordinationOp::ListWorkspaceSessions), | |
| 652 | + | &[ | |
| 653 | + | ("state", "state"), | |
| 654 | + | ("kind", "kind"), | |
| 655 | + | ("harness", "harness"), | |
| 656 | + | ("person", "person"), | |
| 657 | + | ("repo", "repo"), | |
| 658 | + | ("item", "item"), | |
| 659 | + | ("subagents", "subagents"), | |
| 660 | + | ("limit", "limit"), | |
| 661 | + | ], | |
| 662 | + | ), | |
| 663 | + | route("GET", "/workspaces/:workspace/sessions/:id", Op::Coordination(CoordinationOp::GetWorkspaceSession), &[("after", "after")]), | |
| 648 | 664 | route("POST", "/repos/:owner/:name/sessions", Op::Coordination(CoordinationOp::StartSession), &[]), | |
| 649 | 665 | route( | |
| 650 | 666 | "GET", |
| 212 | 212 | Tool { | |
| 213 | 213 | name: "session", | |
| 214 | 214 | title: "Sessions", | |
| 215 | − | description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id; a subagent starts its own with parent_id), `update` as a heartbeat or to attach a work item, pull request, branch or head commit, `record` user-facing decisions, tool calls, skills used and what it touched (idempotent on key), `end` when done with what it reported. `list` with an item, or a path, shows who else (sibling subagents too) is on the same work. `dispatch` starts work on a work item in a session on the person's own Claude. A session goes idle after 15 minutes without a heartbeat and ends after 30 minutes; a heartbeat brings it back.", | |
| 215 | + | description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id; a subagent starts its own with parent_id), `update` as a heartbeat or to attach a work item, pull request, branch or head commit, `record` user-facing decisions, tool calls, skills used and what it touched (idempotent on key), `end` when done with what it reported. `list` with an item, or a path, shows who else (sibling subagents too) is on the same work; `list` with a `workspace` shows everything cooking across it: people's harnesses, sessions started on g1t, g1t's agent and what was recorded on pull requests. `dispatch` starts work on a work item in a session on the person's own Claude. A session goes idle after 15 minutes without a heartbeat and ends after 30 minutes; a heartbeat brings it back.", | |
| 216 | 216 | default_action: None, | |
| 217 | 217 | actions: &[ | |
| 218 | 218 | a("start", Op::Coordination(CoordinationOp::StartSession), "Start one, or get the one started with this harness_session_id"), | |
| ⋯ | |||
| 220 | 220 | a("record", Op::Coordination(CoordinationOp::RecordSessionEntries), "Record entries and acknowledge exact coordination update IDs after considering them; entry keys make retries idempotent"), | |
| 221 | 221 | a("end", Op::Coordination(CoordinationOp::EndSession), "End it"), | |
| 222 | 222 | a("get", Op::Coordination(CoordinationOp::GetSession), "One session, entries and pending coordination updates (reading never acknowledges)"), | |
| 223 | − | a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by work item, pull request or file touched; with subagents, each tree whole"), | |
| 223 | + | a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by work item, pull request or file touched; with subagents, each tree whole. With workspace instead: what is cooking across it, every kind of session, with its merge queues and what landed"), | |
| 224 | 224 | a("dispatch", Op::Coordination(CoordinationOp::StartAgentSession), "Start work on a work item in a session on the person's own Claude (Managed Agents with the workspace's Anthropic key), or get a Claude Code hand-off"), | |
| 225 | 225 | ], | |
| 226 | 226 | }, | |
| ⋯ | |||
| 646 | 646 | // A person's own Getting started is about them at a page, not for agents. | |
| 647 | 647 | Op::Onboarding(OnboardingOp::GetOnboarding), | |
| 648 | 648 | Op::Onboarding(OnboardingOp::UpdateOnboarding), | |
| 649 | + | // Sessions across a workspace are the `session` tool's `list` and | |
| 650 | + | // `get` with a `workspace`. | |
| 651 | + | Op::Coordination(CoordinationOp::ListWorkspaceSessions), | |
| 652 | + | Op::Coordination(CoordinationOp::GetWorkspaceSession), | |
| 649 | 653 | ]; | |
| 650 | 654 | ||
| 651 | 655 | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| 1007 | 1007 | | `-L, --limit <LIMIT>` | Default: `20`. | | |
| 1008 | 1008 | | `--path <PATH>` | Only sessions that touched this file, or any under it when it ends in /. | | |
| 1009 | 1009 | | `--subagents` | List subagents too, under the sessions that started them. | | |
| 1010 | + | | `--workspace [SLUG]` | Every repository of a workspace you may read, every kind of session: this repository's workspace, or the one named. | | |
| 1011 | + | | `--harness <HARNESS>` | With --workspace: only sessions in this harness (claude_code, codex, …). | | |
| 1012 | + | | `--person <PERSON>` | With --workspace: only sessions this person runs, by username. | | |
| 1013 | + | | `--in <NAME>` | With --workspace: only sessions in this repository of it, by name. | | |
| 1010 | 1014 | ||
| 1011 | − | Calls [`list_sessions`](/reference/api/sessions/list-sessions/). | |
| 1015 | + | Calls [`list_sessions`](/reference/api/sessions/list-sessions/), [`list_workspace_sessions`](/reference/api/sessions/list-workspace-sessions/). | |
| 1012 | 1016 | ||
| 1013 | 1017 | ### g1t session view | |
| 1014 | 1018 |
Binary or large file; its contents are not shown.
| 1222 | 1222 | // Harness sessions, which record how pull requests are made. | |
| 1223 | 1223 | ("get_session", Scope::PullRequestsRead), | |
| 1224 | 1224 | ("list_sessions", Scope::PullRequestsRead), | |
| 1225 | + | ("list_workspace_sessions", Scope::PullRequestsRead), | |
| 1226 | + | ("get_workspace_session", Scope::PullRequestsRead), | |
| 1225 | 1227 | ("start_session", Scope::PullRequestsWrite), | |
| 1226 | 1228 | ("update_session", Scope::PullRequestsWrite), | |
| 1227 | 1229 | ("end_session", Scope::PullRequestsWrite), |
| 25 | 25 | mod items; | |
| 26 | 26 | pub use items::*; | |
| 27 | 27 | ||
| 28 | + | mod workspace_sessions; | |
| 29 | + | pub use workspace_sessions::*; | |
| 30 | + | ||
| 28 | 31 | pub mod refs; | |
| 29 | 32 | ||
| 30 | 33 | /// The labels a new repository starts with, and that "Add the default |
| 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 | + | ||
| 16 | + | use serde::{Deserialize, Serialize}; | |
| 17 | + | ||
| 18 | + | use super::{Harness, Pull, PullStatus, QueueView, Session, SessionDetail, SessionState, SESSION_IDLE_MS, SESSION_TIMEOUT_MS}; | |
| 19 | + | use 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")] | |
| 24 | + | pub enum SessionKind { | |
| 25 | + | Harness, | |
| 26 | + | Hosted, | |
| 27 | + | Agent, | |
| 28 | + | Recorded, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl 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")] | |
| 61 | + | pub 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 | + | ||
| 71 | + | impl 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. | |
| 93 | + | pub 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. | |
| 104 | + | pub 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")] | |
| 118 | + | pub 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")] | |
| 160 | + | pub 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")] | |
| 170 | + | pub 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")] | |
| 181 | + | pub 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")] | |
| 191 | + | pub 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. | |
| 207 | + | pub const MAX_WORKSPACE_SESSIONS: u32 = 200; | |
| 208 | + | /// The sessions a list gives when it does not say. | |
| 209 | + | pub 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)] | |
| 214 | + | pub 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)] | |
| 246 | + | pub 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)] | |
| 256 | + | pub struct WorkspaceSessionDetail { | |
| 257 | + | /// `owner/name`. | |
| 258 | + | pub repo: String, | |
| 259 | + | #[serde(flatten)] | |
| 260 | + | pub detail: SessionDetail, | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[cfg(test)] | |
| 264 | + | mod 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 | + | } |
| 1186 | 1186 | /// List subagents too, under the sessions that started them. | |
| 1187 | 1187 | #[arg(long)] | |
| 1188 | 1188 | pub subagents: bool, | |
| 1189 | + | /// Every repository of a workspace you may read, every kind of session: | |
| 1190 | + | /// this repository's workspace, or the one named. | |
| 1191 | + | #[arg(long, value_name = "SLUG", num_args = 0..=1, default_missing_value = "")] | |
| 1192 | + | pub workspace: Option<String>, | |
| 1193 | + | /// With --workspace: only sessions in this harness (claude_code, codex, …). | |
| 1194 | + | #[arg(long, requires = "workspace")] | |
| 1195 | + | pub harness: Option<String>, | |
| 1196 | + | /// With --workspace: only sessions this person runs, by username. | |
| 1197 | + | #[arg(long, requires = "workspace")] | |
| 1198 | + | pub person: Option<String>, | |
| 1199 | + | /// With --workspace: only sessions in this repository of it, by name. | |
| 1200 | + | #[arg(long = "in", value_name = "NAME", requires = "workspace")] | |
| 1201 | + | pub in_repo: Option<String>, | |
| 1189 | 1202 | } | |
| 1190 | 1203 | ||
| 1191 | 1204 | #[derive(Debug, Args)] |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | 315 | fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> { | |
| 316 | + | if args.workspace.is_some() { | |
| 317 | + | return list_workspace(ctx, args); | |
| 318 | + | } | |
| 316 | 319 | let (repo, client) = super::coordination_client(ctx, super::QUICK)?; | |
| 317 | 320 | let state = args.state.map(|state| match state { | |
| 318 | 321 | SessionState::Active => "active", | |
| ⋯ | |||
| 331 | 334 | print_list(&ctx.out, &sessions, current().as_deref()) | |
| 332 | 335 | } | |
| 333 | 336 | ||
| 337 | + | /// `g1t session list --workspace`: what is cooking across a workspace. | |
| 338 | + | fn list_workspace(ctx: &Ctx, args: SessionListArgs) -> Result<()> { | |
| 339 | + | let place = super::work::place(ctx, args.workspace.as_deref())?; | |
| 340 | + | let state = match args.state { | |
| 341 | + | None => "recent", | |
| 342 | + | Some(SessionState::Active | SessionState::Idle) => "live", | |
| 343 | + | Some(SessionState::Ended) => "ended", | |
| 344 | + | }; | |
| 345 | + | let mut call = place.client.op("list_workspace_sessions").arg("workspace", &place.workspace).query("state", state).query("limit", args.limit.clamp(1, 200)); | |
| 346 | + | if let Some(item) = args.item { | |
| 347 | + | call = call.query("item", item); | |
| 348 | + | } | |
| 349 | + | if let Some(harness) = &args.harness { | |
| 350 | + | call = call.query("harness", harness); | |
| 351 | + | } | |
| 352 | + | if let Some(person) = &args.person { | |
| 353 | + | call = call.query("person", person.trim_start_matches('@')); | |
| 354 | + | } | |
| 355 | + | if let Some(name) = &args.in_repo { | |
| 356 | + | call = call.query("repo", name); | |
| 357 | + | } | |
| 358 | + | if args.subagents { | |
| 359 | + | call = call.query("subagents", true); | |
| 360 | + | } | |
| 361 | + | let found = call.send()?; | |
| 362 | + | if ctx.out.structured() { | |
| 363 | + | return ctx.out.emit(&found); | |
| 364 | + | } | |
| 365 | + | let sessions = found["sessions"].as_array().cloned().unwrap_or_default(); | |
| 366 | + | if sessions.is_empty() { | |
| 367 | + | eprintln!("No {} sessions in the {} workspace.", if state == "recent" { "matching" } else { state }, place.workspace); | |
| 368 | + | return Ok(()); | |
| 369 | + | } | |
| 370 | + | let mine = current(); | |
| 371 | + | let mut rows: Vec<Vec<Cell>> = Vec::new(); | |
| 372 | + | for session in &sessions { | |
| 373 | + | let id = s(session, "id"); | |
| 374 | + | let marked = if Some(id) == mine.as_deref() { format!("{id} *") } else { id.to_owned() }; | |
| 375 | + | let by = match session["harness"].as_str() { | |
| 376 | + | Some(harness) => harness.to_owned(), | |
| 377 | + | None => format!("{} {}", s(session, "kind"), s(session, "agent")).trim().to_owned(), | |
| 378 | + | }; | |
| 379 | + | let mut on = s(session, "repo").to_owned(); | |
| 380 | + | if let Some(item) = session["item"].as_u64() { | |
| 381 | + | on.push_str(&format!(" #{item}")); | |
| 382 | + | } | |
| 383 | + | if let Some(pull) = session["pull"].as_u64() { | |
| 384 | + | on.push_str(&format!(" PR #{pull}")); | |
| 385 | + | } | |
| 386 | + | rows.push(vec![ | |
| 387 | + | Cell::new(marked), | |
| 388 | + | Cell::styled(s(session, "state"), ctx.out.state(s(session, "state"))), | |
| 389 | + | Cell::new(s(session, "actor")), | |
| 390 | + | Cell::new(by), | |
| 391 | + | Cell::new(on), | |
| 392 | + | Cell::styled(ago(s(session, "last_activity_at")), ctx.out.dim(&ago(s(session, "last_activity_at")))), | |
| 393 | + | ]); | |
| 394 | + | for child in session["children"].as_array().into_iter().flatten() { | |
| 395 | + | rows.push(vec![ | |
| 396 | + | Cell::new(format!("↳ {} {}", s(child, "id"), s(child, "agent_type")).trim_end().to_owned()), | |
| 397 | + | Cell::styled(s(child, "state"), ctx.out.state(s(child, "state"))), | |
| 398 | + | Cell::new(super::login_of(child)), | |
| 399 | + | Cell::new(s(child, "harness")), | |
| 400 | + | Cell::new(String::new()), | |
| 401 | + | Cell::styled(ago(s(child, "last_seen_at")), ctx.out.dim(&ago(s(child, "last_seen_at")))), | |
| 402 | + | ]); | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ctx.out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)?; | |
| 406 | + | let waiting: u64 = found["queue"].as_array().into_iter().flatten().map(|queue| queue["active"].as_array().map_or(0, |active| active.len() as u64)).sum(); | |
| 407 | + | if waiting > 0 { | |
| 408 | + | eprintln!("{waiting} waiting in merge queues."); | |
| 409 | + | } | |
| 410 | + | Ok(()) | |
| 411 | + | } | |
| 412 | + | ||
| 334 | 413 | pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> { | |
| 335 | 414 | let rows: Vec<Vec<Cell>> = sessions | |
| 336 | 415 | .iter() | |
| 255 | 255 | Mapping { command: "session log", ops: &["record_session_entries"] }, | |
| 256 | 256 | Mapping { command: "session attach", ops: &["update_session"] }, | |
| 257 | 257 | Mapping { command: "session end", ops: &["end_session"] }, | |
| 258 | − | Mapping { command: "session list", ops: &["list_sessions"] }, | |
| 258 | + | Mapping { command: "session list", ops: &["list_sessions", "list_workspace_sessions"] }, | |
| 259 | 259 | Mapping { command: "session view", ops: &["get_session"] }, | |
| 260 | 260 | Mapping { command: "session updates", ops: &["get_session"] }, | |
| 261 | 261 | Mapping { command: "session acknowledge", ops: &["record_session_entries"] }, |
| 5969 | 5969 | body: &[], | |
| 5970 | 5970 | }, | |
| 5971 | 5971 | Operation { | |
| 5972 | + | id: "get_workspace_session", | |
| 5973 | + | method: "GET", | |
| 5974 | + | path: "/workspaces/{workspace}/sessions/{id}", | |
| 5975 | + | tag: "Sessions", | |
| 5976 | + | summary: "Get a session of a workspace", | |
| 5977 | + | scope: Some("pull_requests:read"), | |
| 5978 | + | params: &[ | |
| 5979 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 5980 | + | Param { name: "id", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 5981 | + | Param { name: "after", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 5982 | + | ], | |
| 5983 | + | body: &[], | |
| 5984 | + | }, | |
| 5985 | + | Operation { | |
| 5972 | 5986 | id: "list_sessions", | |
| 5973 | 5987 | method: "GET", | |
| 5974 | 5988 | path: "/repos/{owner}/{name}/sessions", | |
| ⋯ | |||
| 5987 | 6001 | body: &[], | |
| 5988 | 6002 | }, | |
| 5989 | 6003 | Operation { | |
| 6004 | + | id: "list_workspace_sessions", | |
| 6005 | + | method: "GET", | |
| 6006 | + | path: "/workspaces/{workspace}/sessions", | |
| 6007 | + | tag: "Sessions", | |
| 6008 | + | summary: "List a workspace's sessions", | |
| 6009 | + | scope: Some("pull_requests:read"), | |
| 6010 | + | params: &[ | |
| 6011 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 6012 | + | Param { name: "state", location: In::Query, kind: "string", required: false, values: &["live", "recent", "ended"] }, | |
| 6013 | + | Param { name: "kind", location: In::Query, kind: "string", required: false, values: &["harness", "hosted", "agent", "recorded"] }, | |
| 6014 | + | Param { name: "harness", location: In::Query, kind: "string", required: false, values: &["claude_code", "claude_managed_agents", "codex", "cursor", "copilot", "other"] }, | |
| 6015 | + | Param { name: "person", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 6016 | + | Param { name: "repo", location: In::Query, kind: "string", required: false, values: &[] }, | |
| 6017 | + | Param { name: "item", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 6018 | + | Param { name: "subagents", location: In::Query, kind: "boolean", required: false, values: &[] }, | |
| 6019 | + | Param { name: "limit", location: In::Query, kind: "integer", required: false, values: &[] }, | |
| 6020 | + | ], | |
| 6021 | + | body: &[], | |
| 6022 | + | }, | |
| 6023 | + | Operation { | |
| 5990 | 6024 | id: "read_session", | |
| 5991 | 6025 | method: "GET", | |
| 5992 | 6026 | path: "/repos/{owner}/{name}/pulls/{number}/session", | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.