| 1 | //! Coordinating work over REST and MCP: sessions of people's own harnesses |
| 2 | //! (the MCP `session` tool), and claims and a work item's whole context |
| 3 | //! (actions of the `work_item` tool). |
| 4 | //! |
| 5 | //! The work service keeps them and decides who may see and change them |
| 6 | //! (`g1t_contracts::work::coordination`): anyone who may see the work item |
| 7 | //! or read the repository reads them; taking part needs a confirmed email |
| 8 | //! address and a role that may open work items and pull requests; only the |
| 9 | //! person whose token started a session, or made a claim, changes it. |
| 10 | //! Sessions are a repository's, where they are checked out; claims and |
| 11 | //! work contexts are of a workspace's work items. |
| 12 | |
| 13 | use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs}; |
| 14 | use g1t_contracts::work::*; |
| 15 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 16 | use serde_json::{Value, json}; |
| 17 | use worker::Result; |
| 18 | |
| 19 | use g1t_contracts::repos::RepoPath; |
| 20 | |
| 21 | use crate::operations::{Services, item_workspace, repo_path}; |
| 22 | |
| 23 | /// One operation on sessions, claims or a work item's context. |
| 24 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 25 | pub enum CoordinationOp { |
| 26 | StartSession, |
| 27 | UpdateSession, |
| 28 | EndSession, |
| 29 | GetSession, |
| 30 | ListSessions, |
| 31 | RecordSessionEntries, |
| 32 | ClaimWorkItem, |
| 33 | ReleaseClaim, |
| 34 | HandOffWorkItem, |
| 35 | ListClaims, |
| 36 | GetWork, |
| 37 | StartAgentSession, |
| 38 | ListWorkspaceSessions, |
| 39 | GetWorkspaceSession, |
| 40 | } |
| 41 | |
| 42 | impl CoordinationOp { |
| 43 | /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a |
| 44 | /// test checks against this. |
| 45 | pub const ALL: [CoordinationOp; 14] = [ |
| 46 | CoordinationOp::StartSession, |
| 47 | CoordinationOp::UpdateSession, |
| 48 | CoordinationOp::EndSession, |
| 49 | CoordinationOp::GetSession, |
| 50 | CoordinationOp::ListSessions, |
| 51 | CoordinationOp::RecordSessionEntries, |
| 52 | CoordinationOp::ClaimWorkItem, |
| 53 | CoordinationOp::ReleaseClaim, |
| 54 | CoordinationOp::HandOffWorkItem, |
| 55 | CoordinationOp::ListClaims, |
| 56 | CoordinationOp::GetWork, |
| 57 | CoordinationOp::StartAgentSession, |
| 58 | CoordinationOp::ListWorkspaceSessions, |
| 59 | CoordinationOp::GetWorkspaceSession, |
| 60 | ]; |
| 61 | |
| 62 | pub fn name(self) -> &'static str { |
| 63 | match self { |
| 64 | CoordinationOp::StartSession => "start_session", |
| 65 | CoordinationOp::UpdateSession => "update_session", |
| 66 | CoordinationOp::EndSession => "end_session", |
| 67 | CoordinationOp::GetSession => "get_session", |
| 68 | CoordinationOp::ListSessions => "list_sessions", |
| 69 | CoordinationOp::RecordSessionEntries => "record_session_entries", |
| 70 | CoordinationOp::ClaimWorkItem => "claim_work_item", |
| 71 | CoordinationOp::ReleaseClaim => "release_claim", |
| 72 | CoordinationOp::HandOffWorkItem => "hand_off_work_item", |
| 73 | CoordinationOp::ListClaims => "list_claims", |
| 74 | CoordinationOp::GetWork => "get_work", |
| 75 | CoordinationOp::StartAgentSession => "start_agent_session", |
| 76 | CoordinationOp::ListWorkspaceSessions => "list_workspace_sessions", |
| 77 | CoordinationOp::GetWorkspaceSession => "get_workspace_session", |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// For the API reference. |
| 82 | pub fn title(self) -> &'static str { |
| 83 | match self { |
| 84 | CoordinationOp::StartSession => "Start a session", |
| 85 | CoordinationOp::UpdateSession => "Update a session", |
| 86 | CoordinationOp::EndSession => "End a session", |
| 87 | CoordinationOp::GetSession => "Get a session", |
| 88 | CoordinationOp::ListSessions => "List sessions", |
| 89 | CoordinationOp::RecordSessionEntries => "Record entries in a session", |
| 90 | CoordinationOp::ClaimWorkItem => "Claim a work item", |
| 91 | CoordinationOp::ReleaseClaim => "Release a claim", |
| 92 | CoordinationOp::HandOffWorkItem => "Hand off a work item", |
| 93 | CoordinationOp::ListClaims => "List claims", |
| 94 | CoordinationOp::GetWork => "Get a work item's work context", |
| 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", |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /// Whether it is about one repository: sessions are where they run; |
| 102 | /// claims and work contexts are of a workspace's work items, and take a |
| 103 | /// repository only to name its workspace (or, for a claim, where it is |
| 104 | /// made from). Sessions are read across a workspace too: listing or |
| 105 | /// getting them takes a repository or a workspace. |
| 106 | pub fn needs_repo(self) -> bool { |
| 107 | !matches!( |
| 108 | self, |
| 109 | CoordinationOp::GetSession |
| 110 | | CoordinationOp::ListSessions |
| 111 | | CoordinationOp::ListWorkspaceSessions |
| 112 | | CoordinationOp::GetWorkspaceSession |
| 113 | | CoordinationOp::ClaimWorkItem |
| 114 | | CoordinationOp::ReleaseClaim |
| 115 | | CoordinationOp::HandOffWorkItem |
| 116 | | CoordinationOp::ListClaims |
| 117 | | CoordinationOp::GetWork |
| 118 | ) |
| 119 | } |
| 120 | |
| 121 | /// Whether it only reads. |
| 122 | pub fn reads(self) -> bool { |
| 123 | matches!( |
| 124 | self, |
| 125 | CoordinationOp::GetSession |
| 126 | | CoordinationOp::ListSessions |
| 127 | | CoordinationOp::ListClaims |
| 128 | | CoordinationOp::GetWork |
| 129 | | CoordinationOp::ListWorkspaceSessions |
| 130 | | CoordinationOp::GetWorkspaceSession |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | pub fn description(self) -> &'static str { |
| 135 | match self { |
| 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.", |
| 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. `usage` reports the session's token usage so far, per model, replacing what was reported before: g1t estimates `cost_usd` from it at Anthropic's list price (none when no model in it has a known price), with `cost_partial` true when some of it was for a model it has no price for. Returns the session; one ended with end_session is refused with `409`.", |
| 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`.", |
| 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.", |
| 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).", |
| 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.", |
| 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.", |
| 146 | CoordinationOp::HandOffWorkItem => "Hand a work item you claimed off: your claim ends as handed_off, with `note` saying where the work stands, for `to` (a username) or, without one, for anyone. A work.handoff_requested event, which whoever follows the work item hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.", |
| 147 | CoordinationOp::ListClaims => "A workspace's claims on the work items you may see, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `item` for one work item's. With `repo`, those made from its checkouts or on work items linked to it.", |
| 148 | CoordinationOp::GetWork => "Everything about a work item an agent needs before working on it, in one call: the `item`, its `definition_of_done` (the items under its \"Definition of done\" heading); `repos`, each repository it is linked to (origin, targets, touched) with its `role`, whether the work `landed` there, the `required_checks` every pull request into its default branch must pass, and its `pulls` for the work item, each with its status, required checks as they stand on its head, whether it merges cleanly, the other pull requests it overlaps and its place in the merge queue; `missing`, the targets it has not landed in yet; `suggested_targets`, repositories of the workspace it may belong in, likeliest first, each with `why` (link_work_item with role target confirms one); active `claims`; `sessions` on it or its pull requests; `blocked_by` (each work item it waits on, with claims and pull requests) and `blocks` (the work items waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.", |
| 149 | CoordinationOp::StartAgentSession => "Start work on a work item in a session of its own, on the person's own Claude: g1t runs no model and bills nothing for it. Give `item` (a work item of the repository's workspace), or `title` and `body` to open a new work item first, filed from the repository; `instruction` adds to what the session is asked; `mode` (plan, implement or review) says what kind of work it is. With the workspace's Anthropic AI provider (an owner adds it in the workspace's AI settings), g1t starts a Claude Managed Agents session with that key: g1t's MCP server attached with a credential for you and this repository only that lasts the session and is kept in a vault on the Anthropic account (never in the prompt), g1t's skills (work-on-item, coordinate, fix-integration) next to the account's own, and the session, its subagents (threads), what they touched and what they reported recorded here as a g1t session (`harness` claude_managed_agents). Returns `session`, its `session_url` on g1t, `provider_session_url`, and `prompt`. With `handoff` true, or without an Anthropic AI provider when `handoff` is not false, nothing is started: `command` is a Claude Code command line, and `prompt` what to paste, for someone with g1t's Claude Code plugin, whose session then attaches to the work item as it starts.", |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | pub fn input(self) -> Value { |
| 154 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); |
| 155 | let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." }); |
| 156 | let number = json!({ "type": "integer", "description": "The work item's number in its workspace." }); |
| 157 | let workspace = json!({ "type": "string", "description": "The workspace's slug. Left out, repo's workspace." }); |
| 158 | let checkout = json!({ "type": "string", "description": "The repository of your checkout, as \"owner/name\": names the workspace when workspace is left out." }); |
| 159 | let item = json!({ "type": "integer", "description": "The work item it works on, by its number in the repository's workspace." }); |
| 160 | let pull = json!({ "type": "integer", "description": "The pull request it works on." }); |
| 161 | let fields = |more: Value| -> Value { |
| 162 | let mut properties = json!({ |
| 163 | "repo": repo, |
| 164 | "item": item, |
| 165 | "pull": pull, |
| 166 | "branch": { "type": "string", "description": "The branch it works on." }, |
| 167 | "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." }, |
| 168 | "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." }, |
| 169 | }); |
| 170 | if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) { |
| 171 | all.extend(more); |
| 172 | } |
| 173 | properties |
| 174 | }; |
| 175 | let session_id = json!({ "type": "string", "description": "The session (ses_…) whose claim it is, when you have several on the work item, such as sibling subagents." }); |
| 176 | let subagent = json!({ |
| 177 | "agent_type": { "type": "string", "description": "For a subagent: its kind, as the harness names it (Explore, security-reviewer)." }, |
| 178 | "task": { "type": "string", "description": "For a subagent: the task it was given, in a few words. Up to 200 characters." }, |
| 179 | "prompt": { "type": "string", "description": "For a subagent: the prompt its parent gave it. Kept up to 4,000 characters." }, |
| 180 | "model": { "type": "string", "description": "The model it runs on, when known." }, |
| 181 | }); |
| 182 | let with = |base: Value, more: Value| -> Value { |
| 183 | let mut base = base; |
| 184 | if let (Some(all), Value::Object(more)) = (base.as_object_mut(), more) { |
| 185 | all.extend(more); |
| 186 | } |
| 187 | base |
| 188 | }; |
| 189 | let result = json!({ "type": "string", "description": "What it reported when it finished: a subagent's final message. Kept up to 8,000 characters. Never a tool's output." }); |
| 190 | let (properties, required): (Value, &[&str]) = match self { |
| 191 | CoordinationOp::StartSession => ( |
| 192 | with( |
| 193 | fields(json!({ |
| 194 | "harness": { |
| 195 | "type": "string", |
| 196 | "enum": Harness::ALL.map(Harness::as_str), |
| 197 | "description": "The harness the session runs in.", |
| 198 | }, |
| 199 | "harness_session_id": { |
| 200 | "type": "string", |
| 201 | "description": "The harness's own id for the session, such as Claude Code's session_id; for a subagent, the parent's, `/agent/` and the subagent's id. Starting again with it returns the same session.", |
| 202 | }, |
| 203 | "parent_id": { "type": "string", "description": "For a subagent: the session that started it (ses_…), one of yours on the repository." }, |
| 204 | })), |
| 205 | subagent.clone(), |
| 206 | ), |
| 207 | &["repo", "harness"], |
| 208 | ), |
| 209 | CoordinationOp::UpdateSession => ( |
| 210 | with( |
| 211 | fields(json!({ |
| 212 | "id": id, |
| 213 | "result": result, |
| 214 | "usage": { |
| 215 | "type": "array", |
| 216 | "description": "The session's token usage so far, one entry per model: `model`, `tier` (`long` for a claude-haiku-5-5 prompt over 100,000 tokens), `input_tokens`, `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and `cache_read_tokens`, each a whole number. Cumulative: it replaces the usage given before. Up to 50 models. g1t estimates the session's `cost_usd` from it at Anthropic's list price.", |
| 217 | "items": { |
| 218 | "type": "object", |
| 219 | "properties": { |
| 220 | "model": { "type": "string" }, |
| 221 | "tier": { "type": "string" }, |
| 222 | "input_tokens": { "type": "integer" }, |
| 223 | "output_tokens": { "type": "integer" }, |
| 224 | "cache_write_5m_tokens": { "type": "integer" }, |
| 225 | "cache_write_1h_tokens": { "type": "integer" }, |
| 226 | "cache_read_tokens": { "type": "integer" }, |
| 227 | }, |
| 228 | "required": ["model"], |
| 229 | }, |
| 230 | }, |
| 231 | })), |
| 232 | subagent.clone(), |
| 233 | ), |
| 234 | &["repo", "id"], |
| 235 | ), |
| 236 | CoordinationOp::EndSession => ( |
| 237 | json!({ |
| 238 | "repo": repo, |
| 239 | "id": id, |
| 240 | "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." }, |
| 241 | "result": result, |
| 242 | }), |
| 243 | &["repo", "id"], |
| 244 | ), |
| 245 | CoordinationOp::GetSession => ( |
| 246 | json!({ |
| 247 | "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace." }, |
| 248 | "workspace": { "type": "string", "description": "A workspace's slug, instead of repo: the session is found in whichever repository of it it runs in." }, |
| 249 | "id": id, |
| 250 | "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." }, |
| 251 | }), |
| 252 | &["id"], |
| 253 | ), |
| 254 | CoordinationOp::ListSessions => ( |
| 255 | json!({ |
| 256 | "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace for every repository of it you may read." }, |
| 257 | "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)." }, |
| 258 | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, |
| 259 | "item": { "type": "integer", "description": "Only sessions on this work item: attached, or tied to it any other way." }, |
| 260 | "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." }, |
| 261 | "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." }, |
| 262 | "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." }, |
| 263 | }), |
| 264 | &[], |
| 265 | ), |
| 266 | CoordinationOp::ListWorkspaceSessions => ( |
| 267 | json!({ |
| 268 | "workspace": { "type": "string", "description": "The workspace's slug." }, |
| 269 | "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." }, |
| 270 | "kind": { "type": "string", "enum": SessionKind::ALL.map(SessionKind::as_str), "description": "Only sessions of this kind." }, |
| 271 | "harness": { "type": "string", "enum": Harness::ALL.map(Harness::as_str), "description": "Only sessions in this harness." }, |
| 272 | "person": { "type": "string", "description": "Only sessions this person runs, by username." }, |
| 273 | "repo": { "type": "string", "description": "Only sessions in this repository of the workspace, by its name (or \"owner/name\")." }, |
| 274 | "item": { "type": "integer", "description": "Only sessions on this work item of the workspace." }, |
| 275 | "subagents": { "type": "boolean", "description": "Give each session's subagents too, in its `children`." }, |
| 276 | "limit": { "type": "integer", "description": "At most this many, up to 200; 60 if left out." }, |
| 277 | }), |
| 278 | &["workspace"], |
| 279 | ), |
| 280 | CoordinationOp::GetWorkspaceSession => ( |
| 281 | json!({ |
| 282 | "workspace": { "type": "string", "description": "The workspace's slug." }, |
| 283 | "id": id, |
| 284 | "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." }, |
| 285 | }), |
| 286 | &["workspace", "id"], |
| 287 | ), |
| 288 | CoordinationOp::RecordSessionEntries => ( |
| 289 | json!({ |
| 290 | "acknowledge": { "type": "array", "maxItems": 100, "items": { "type": "string" }, "description": "Exact pending update IDs this session has considered; never acknowledge merely because fetched." }, |
| 291 | "repo": repo, |
| 292 | "id": id, |
| 293 | "entries": { |
| 294 | "type": "array", |
| 295 | "description": "Up to 200 entries, oldest first.", |
| 296 | "items": { |
| 297 | "type": "object", |
| 298 | "properties": { |
| 299 | "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note", "skill"] }, |
| 300 | "text": { "type": "string", "description": "For a skill, its name only." }, |
| 301 | "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." }, |
| 302 | "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." }, |
| 303 | "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." }, |
| 304 | }, |
| 305 | "required": ["kind", "text"], |
| 306 | }, |
| 307 | }, |
| 308 | "touched": { |
| 309 | "type": "array", |
| 310 | "description": "What it touched, up to 200, each kept once.", |
| 311 | "items": { |
| 312 | "type": "object", |
| 313 | "properties": { |
| 314 | "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "item"] }, |
| 315 | "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." }, |
| 316 | }, |
| 317 | "required": ["kind", "value"], |
| 318 | }, |
| 319 | }, |
| 320 | }), |
| 321 | &["repo", "id"], |
| 322 | ), |
| 323 | CoordinationOp::ClaimWorkItem => ( |
| 324 | json!({ |
| 325 | "workspace": workspace, |
| 326 | "repo": checkout, |
| 327 | "number": { "type": "integer", "description": "The work item's number. Left out, give title (and body) to open one and claim it." }, |
| 328 | "title": { "type": "string", "description": "Without number: the title of a new work item to open and claim, filed from repo when given." }, |
| 329 | "body": { "type": "string", "description": "Without number: the new work item's description." }, |
| 330 | "session_id": { "type": "string", "description": "One of your sessions (ses_…): its heartbeats renew the claim. With title, it is attached to the new work item." }, |
| 331 | "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." }, |
| 332 | "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." }, |
| 333 | }), |
| 334 | &[], |
| 335 | ), |
| 336 | CoordinationOp::ReleaseClaim => ( |
| 337 | json!({ |
| 338 | "workspace": workspace, |
| 339 | "repo": checkout, |
| 340 | "number": number, |
| 341 | "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." }, |
| 342 | "session_id": session_id, |
| 343 | }), |
| 344 | &["number"], |
| 345 | ), |
| 346 | CoordinationOp::HandOffWorkItem => ( |
| 347 | json!({ |
| 348 | "workspace": workspace, |
| 349 | "repo": checkout, |
| 350 | "number": number, |
| 351 | "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." }, |
| 352 | "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." }, |
| 353 | "session_id": session_id, |
| 354 | }), |
| 355 | &["number", "note"], |
| 356 | ), |
| 357 | CoordinationOp::ListClaims => ( |
| 358 | json!({ |
| 359 | "workspace": workspace, |
| 360 | "repo": { "type": "string", "description": "Only claims made from this repository's checkouts or on work items linked to it, \"owner/name\". Names the workspace when workspace is left out." }, |
| 361 | "state": { |
| 362 | "type": "string", |
| 363 | "enum": ["active", "released", "expired", "handed_off"], |
| 364 | "description": "Claims in this state; active if left out.", |
| 365 | }, |
| 366 | "item": { "type": "integer", "description": "Only claims on this work item." }, |
| 367 | }), |
| 368 | &[], |
| 369 | ), |
| 370 | CoordinationOp::GetWork => (json!({ "workspace": workspace, "repo": checkout, "number": number }), &["number"]), |
| 371 | CoordinationOp::StartAgentSession => ( |
| 372 | json!({ |
| 373 | "repo": repo, |
| 374 | "item": { "type": "integer", "description": "The work item to work on, by its number in the repository's workspace. Left out, give title (and body) to open one." }, |
| 375 | "title": { "type": "string", "description": "Without item: the title of a new work item to open and work on, filed from the repository." }, |
| 376 | "body": { "type": "string", "description": "Without item: the new work item's description." }, |
| 377 | "instruction": { "type": "string", "description": "What to do beyond what the work item says. Up to 4,000 characters." }, |
| 378 | "mode": { "type": "string", "enum": ["plan", "implement", "review"], "description": "What kind of work: plan (say how, change nothing), implement (the default) or review (review its pull request)." }, |
| 379 | "handoff": { "type": "boolean", "description": "true for a Claude Code command and prompt to run yourself instead of a session on the workspace's Anthropic account; false to refuse when the workspace has no Anthropic AI provider. Left out, a session when the workspace can, else the hand-off." }, |
| 380 | }), |
| 381 | &["repo"], |
| 382 | ), |
| 383 | }; |
| 384 | json!({ "type": "object", "properties": properties, "required": required }) |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | fn text(input: &Value, key: &str) -> Option<String> { |
| 389 | input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned) |
| 390 | } |
| 391 | |
| 392 | /// A whole number given as a number, as digits, or as `#12`. |
| 393 | fn number(input: &Value, key: &str) -> Option<u32> { |
| 394 | match &input[key] { |
| 395 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), |
| 396 | Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(), |
| 397 | _ => None, |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | /// An optional whole number: none when left out, refused when not one. |
| 402 | fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> { |
| 403 | match &input[key] { |
| 404 | Value::Null => Ok(None), |
| 405 | Value::String(text) if text.trim().is_empty() => Ok(None), |
| 406 | _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")), |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | /// `blocked_by` for `update_work_item`: work item numbers, as numbers, |
| 411 | /// digits or `#12`. None when left out; refused when it holds anything |
| 412 | /// else. |
| 413 | pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> { |
| 414 | let items = match &input["blocked_by"] { |
| 415 | Value::Null => return Ok(None), |
| 416 | Value::Array(items) => items, |
| 417 | _ => return Err("blocked_by is a list of work item numbers.".to_owned()), |
| 418 | }; |
| 419 | items |
| 420 | .iter() |
| 421 | .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not a work item number."))) |
| 422 | .collect::<std::result::Result<Vec<u32>, String>>() |
| 423 | .map(Some) |
| 424 | } |
| 425 | |
| 426 | pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { |
| 427 | let given = repo_path(input); |
| 428 | let repo = match given.clone() { |
| 429 | Some(repo) => repo, |
| 430 | None if op.needs_repo() => return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")), |
| 431 | None => RepoPath { namespace: String::new(), name: String::new() }, |
| 432 | }; |
| 433 | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); |
| 434 | // A claim or a work context names its work item's workspace, or a |
| 435 | // repository of it. |
| 436 | let workspace = item_workspace(input); |
| 437 | // Sessions across a workspace: listing or getting them without a repository. |
| 438 | if given.is_none() && !workspace.is_empty() { |
| 439 | match op { |
| 440 | CoordinationOp::ListSessions => return list_workspace_sessions(services, viewer, input, workspace).await, |
| 441 | CoordinationOp::GetSession => return get_workspace_session(services, viewer, input, workspace).await, |
| 442 | _ => {} |
| 443 | } |
| 444 | } |
| 445 | if matches!(op, CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession) { |
| 446 | if workspace.is_empty() { |
| 447 | return invalid("Give the workspace by its slug.".to_owned()); |
| 448 | } |
| 449 | return match op { |
| 450 | CoordinationOp::ListWorkspaceSessions => list_workspace_sessions(services, viewer, input, workspace).await, |
| 451 | _ => get_workspace_session(services, viewer, input, workspace).await, |
| 452 | }; |
| 453 | } |
| 454 | if matches!(op, CoordinationOp::ListSessions | CoordinationOp::GetSession) && given.is_none() { |
| 455 | return invalid("Give the repository as \"owner/name\", or the workspace by its slug.".to_owned()); |
| 456 | } |
| 457 | if !op.needs_repo() && workspace.is_empty() { |
| 458 | return invalid("Give the workspace by its slug, or a repository of it as \"owner/name\".".to_owned()); |
| 459 | } |
| 460 | let (item, pull) = match (optional_number(input, "item"), optional_number(input, "pull")) { |
| 461 | (Ok(item), Ok(pull)) => (item, pull), |
| 462 | (Err(message), _) | (_, Err(message)) => return invalid(message), |
| 463 | }; |
| 464 | let actor = || viewer.clone().unwrap_or_default(); |
| 465 | let id = || text(input, "id").unwrap_or_default(); |
| 466 | let item_number = number(input, "number").unwrap_or_default(); |
| 467 | let work = &services.work; |
| 468 | if matches!( |
| 469 | op, |
| 470 | CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries |
| 471 | ) && id().is_empty() |
| 472 | { |
| 473 | return invalid("Name the session by its id (ses_…).".to_owned()); |
| 474 | } |
| 475 | match op { |
| 476 | CoordinationOp::StartSession => { |
| 477 | let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else { |
| 478 | let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect(); |
| 479 | return invalid(format!("harness is one of {}.", names.join(", "))); |
| 480 | }; |
| 481 | let args = StartSessionArgs { |
| 482 | actor: actor(), |
| 483 | repo, |
| 484 | harness, |
| 485 | harness_session_id: text(input, "harness_session_id"), |
| 486 | item, |
| 487 | pull, |
| 488 | branch: text(input, "branch"), |
| 489 | title: text(input, "title"), |
| 490 | head_commit: text(input, "head_commit"), |
| 491 | parent_id: text(input, "parent_id"), |
| 492 | agent_type: text(input, "agent_type"), |
| 493 | task: text(input, "task"), |
| 494 | prompt: text(input, "prompt"), |
| 495 | model: text(input, "model"), |
| 496 | }; |
| 497 | g1t_kit::call(work, "start_session", &args).await |
| 498 | } |
| 499 | CoordinationOp::UpdateSession => { |
| 500 | let usage = match &input["usage"] { |
| 501 | Value::Null => None, |
| 502 | given => match g1t_contracts::model_prices::parse_usage(given) { |
| 503 | Ok(usage) => Some(usage), |
| 504 | Err(message) => return invalid(message), |
| 505 | }, |
| 506 | }; |
| 507 | let args = UpdateSessionArgs { |
| 508 | actor: actor(), |
| 509 | repo, |
| 510 | id: id(), |
| 511 | item, |
| 512 | pull, |
| 513 | branch: text(input, "branch"), |
| 514 | head_commit: text(input, "head_commit"), |
| 515 | title: text(input, "title"), |
| 516 | agent_type: text(input, "agent_type"), |
| 517 | task: text(input, "task"), |
| 518 | prompt: text(input, "prompt"), |
| 519 | model: text(input, "model"), |
| 520 | result: text(input, "result"), |
| 521 | usage, |
| 522 | }; |
| 523 | g1t_kit::call(work, "update_session", &args).await |
| 524 | } |
| 525 | CoordinationOp::EndSession => { |
| 526 | let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result"), by_hand: false }; |
| 527 | g1t_kit::call(work, "end_session", &args).await |
| 528 | } |
| 529 | CoordinationOp::GetSession => { |
| 530 | let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() }; |
| 531 | g1t_kit::call(work, "get_work_session", &args).await |
| 532 | } |
| 533 | CoordinationOp::ListSessions => { |
| 534 | let state = match text(input, "state") { |
| 535 | None => None, |
| 536 | Some(state) => match SessionState::parse(&state) { |
| 537 | Some(state) => Some(state), |
| 538 | None => return invalid(format!("state is active, idle or ended, not {state}.")), |
| 539 | }, |
| 540 | }; |
| 541 | let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true"); |
| 542 | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, item, pull, subagents, path: text(input, "path") }; |
| 543 | g1t_kit::call(work, "list_work_sessions", &args).await |
| 544 | } |
| 545 | CoordinationOp::RecordSessionEntries => { |
| 546 | let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] { |
| 547 | Value::Null => json!([]), |
| 548 | entries => entries.clone(), |
| 549 | }) { |
| 550 | Ok(entries) => entries, |
| 551 | Err(_) => { |
| 552 | return invalid( |
| 553 | "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result, note or skill, with tool, commit and key as needed.".to_owned(), |
| 554 | ); |
| 555 | } |
| 556 | }; |
| 557 | let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] { |
| 558 | Value::Null => json!([]), |
| 559 | touched => touched.clone(), |
| 560 | }) { |
| 561 | Ok(touched) => touched, |
| 562 | Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or item.".to_owned()), |
| 563 | }; |
| 564 | let acknowledge: Vec<String> = match serde_json::from_value(input.get("acknowledge").cloned().unwrap_or(json!([]))) { |
| 565 | Ok(ids) => ids, |
| 566 | Err(_) => return invalid("acknowledge must be an array of update IDs.".to_owned()), |
| 567 | }; |
| 568 | let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched, acknowledge }; |
| 569 | g1t_kit::call(work, "record_session_entries", &args).await |
| 570 | } |
| 571 | CoordinationOp::ClaimWorkItem => { |
| 572 | let ttl_minutes = match optional_number(input, "ttl_minutes") { |
| 573 | Ok(minutes) => minutes, |
| 574 | Err(message) => return invalid(message), |
| 575 | }; |
| 576 | let title = text(input, "title"); |
| 577 | if item_number == 0 && title.is_none() { |
| 578 | return invalid("Name the work item by its number, or give a title (and body) to open one.".to_owned()); |
| 579 | } |
| 580 | if item_number != 0 && title.is_some() { |
| 581 | return invalid("Give the work item's number or a title for a new one, not both.".to_owned()); |
| 582 | } |
| 583 | let args = ClaimArgs { |
| 584 | actor: actor(), |
| 585 | workspace, |
| 586 | repo: given, |
| 587 | number: item_number, |
| 588 | title, |
| 589 | body: text(input, "body"), |
| 590 | session_id: text(input, "session_id"), |
| 591 | note: text(input, "note"), |
| 592 | ttl_minutes, |
| 593 | }; |
| 594 | g1t_kit::call(work, "claim_work_item", &args).await |
| 595 | } |
| 596 | CoordinationOp::ReleaseClaim => { |
| 597 | let args = ReleaseArgs { actor: actor(), workspace, repo: given, number: item_number, reason: text(input, "reason"), session_id: text(input, "session_id") }; |
| 598 | g1t_kit::call(work, "release_claim", &args).await |
| 599 | } |
| 600 | CoordinationOp::HandOffWorkItem => { |
| 601 | let Some(note) = text(input, "note") else { |
| 602 | return invalid("Say where the work stands in note, for whoever picks it up.".to_owned()); |
| 603 | }; |
| 604 | let args = HandOffArgs { actor: actor(), workspace, repo: given, number: item_number, to: text(input, "to"), note, session_id: text(input, "session_id") }; |
| 605 | g1t_kit::call(work, "hand_off_work_item", &args).await |
| 606 | } |
| 607 | CoordinationOp::ListClaims => { |
| 608 | let state = match text(input, "state") { |
| 609 | None => None, |
| 610 | Some(state) => match ClaimState::parse(&state) { |
| 611 | Some(state) => Some(state), |
| 612 | None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")), |
| 613 | }, |
| 614 | }; |
| 615 | let args = ListClaimsArgs { viewer: viewer.clone(), workspace, repo: given, state, item }; |
| 616 | g1t_kit::call(work, "list_claims", &args).await |
| 617 | } |
| 618 | CoordinationOp::GetWork => { |
| 619 | let args = WorkItemViewArgs { workspace, number: item_number, viewer: viewer.clone() }; |
| 620 | g1t_kit::call(work, "get_work", &args).await |
| 621 | } |
| 622 | CoordinationOp::StartAgentSession => { |
| 623 | let mode = match text(input, "mode") { |
| 624 | None => None, |
| 625 | Some(mode) => match AgentMode::parse(&mode) { |
| 626 | Some(mode) => Some(mode), |
| 627 | None => return invalid(format!("mode is plan, implement or review, not {mode}.")), |
| 628 | }, |
| 629 | }; |
| 630 | if item.is_none() && text(input, "title").is_none() { |
| 631 | return invalid("Name the work item, or give a title (and body) for a new one.".to_owned()); |
| 632 | } |
| 633 | let handoff = match &input["handoff"] { |
| 634 | Value::Bool(handoff) => Some(*handoff), |
| 635 | Value::String(text) if text == "true" => Some(true), |
| 636 | Value::String(text) if text == "false" => Some(false), |
| 637 | _ => None, |
| 638 | }; |
| 639 | let args = StartAgentSessionArgs { |
| 640 | actor: actor(), |
| 641 | repo, |
| 642 | item, |
| 643 | pull: None, |
| 644 | asked: None, |
| 645 | title: text(input, "title"), |
| 646 | body: text(input, "body"), |
| 647 | instruction: text(input, "instruction"), |
| 648 | mode, |
| 649 | handoff, |
| 650 | }; |
| 651 | g1t_kit::call(&services.integrations, "start_agent_session", &args).await |
| 652 | } |
| 653 | // Answered above, before a repository is asked for. |
| 654 | CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession => invalid("Give the workspace by its slug.".to_owned()), |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | /// `list_workspace_sessions`, from a `workspace` and its filters. |
| 659 | async fn list_workspace_sessions(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> { |
| 660 | let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message)); |
| 661 | let shown = match text(input, "state") { |
| 662 | None => SessionsShown::Recent, |
| 663 | Some(state) => match SessionsShown::parse(&state) { |
| 664 | Some(shown) => shown, |
| 665 | None => return invalid(format!("state is live, recent (or all) or ended, not {state}.")), |
| 666 | }, |
| 667 | }; |
| 668 | let kind = match text(input, "kind") { |
| 669 | None => None, |
| 670 | Some(kind) => match SessionKind::parse(&kind) { |
| 671 | Some(kind) => Some(kind), |
| 672 | None => return invalid(format!("kind is harness, hosted, agent or recorded, not {kind}.")), |
| 673 | }, |
| 674 | }; |
| 675 | let harness = match text(input, "harness") { |
| 676 | None => None, |
| 677 | Some(name) => match Harness::parse(&name) { |
| 678 | Some(harness) => Some(harness), |
| 679 | None => { |
| 680 | let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect(); |
| 681 | return invalid(format!("harness is one of {}.", names.join(", "))); |
| 682 | } |
| 683 | }, |
| 684 | }; |
| 685 | let item = match optional_number(input, "item") { |
| 686 | Ok(item) => item, |
| 687 | Err(message) => return invalid(message), |
| 688 | }; |
| 689 | let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true"); |
| 690 | let args = ListWorkspaceSessionsArgs { |
| 691 | viewer: viewer.clone(), |
| 692 | workspace, |
| 693 | shown, |
| 694 | kind, |
| 695 | harness, |
| 696 | person: text(input, "person"), |
| 697 | repo: text(input, "repo").or_else(|| text(input, "repository")), |
| 698 | item, |
| 699 | subagents, |
| 700 | sessions_only: false, |
| 701 | limit: number(input, "limit"), |
| 702 | }; |
| 703 | g1t_kit::call(&services.work, "list_workspace_sessions", &args).await |
| 704 | } |
| 705 | |
| 706 | /// `get_workspace_session`, from a `workspace` and the session's `id`. |
| 707 | async fn get_workspace_session(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> { |
| 708 | let Some(id) = text(input, "id") else { |
| 709 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the session by its id (ses_…).")); |
| 710 | }; |
| 711 | let args = GetWorkspaceSessionArgs { viewer: viewer.clone(), workspace, id, after_seq: number(input, "after").unwrap_or_default() }; |
| 712 | g1t_kit::call(&services.work, "get_workspace_session", &args).await |
| 713 | } |
| 714 | |
| 715 | #[cfg(test)] |
| 716 | mod tests { |
| 717 | use super::*; |
| 718 | use crate::operations::Op; |
| 719 | use g1t_contracts::scopes::{Level, Scope, scope_for}; |
| 720 | |
| 721 | #[test] |
| 722 | fn each_operation_is_described_scoped_and_listed() { |
| 723 | for op in CoordinationOp::ALL { |
| 724 | assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name()); |
| 725 | assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op))); |
| 726 | assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name()); |
| 727 | assert_eq!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), op.needs_repo(), "{}", op.name()); |
| 728 | let scope = scope_for(op.name()).unwrap(); |
| 729 | assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name()); |
| 730 | let sessions = op.name().contains("session"); |
| 731 | let expected = match (sessions, op.reads()) { |
| 732 | (true, true) => Scope::PullRequestsRead, |
| 733 | (true, false) => Scope::PullRequestsWrite, |
| 734 | (false, true) => Scope::WorkRead, |
| 735 | (false, false) => Scope::WorkWrite, |
| 736 | }; |
| 737 | assert_eq!(scope, expected, "{}", op.name()); |
| 738 | } |
| 739 | } |
| 740 | |
| 741 | #[test] |
| 742 | fn routes_reach_each_operation_with_its_input() { |
| 743 | let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| { |
| 744 | let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); |
| 745 | let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap(); |
| 746 | (route.op, input) |
| 747 | }; |
| 748 | let coordination = |op| Op::Coordination(op); |
| 749 | let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" })); |
| 750 | assert_eq!(op, coordination(CoordinationOp::StartSession)); |
| 751 | assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" })); |
| 752 | let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({})); |
| 753 | assert_eq!(op, coordination(CoordinationOp::UpdateSession)); |
| 754 | assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" })); |
| 755 | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({})); |
| 756 | assert_eq!(op, coordination(CoordinationOp::EndSession)); |
| 757 | let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null); |
| 758 | assert_eq!(op, coordination(CoordinationOp::GetSession)); |
| 759 | assert_eq!(input["after"], "40"); |
| 760 | let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("item", "4")], Value::Null); |
| 761 | assert_eq!(op, coordination(CoordinationOp::ListSessions)); |
| 762 | assert_eq!(input, json!({ "state": "idle", "item": "4", "repo": "acme/web" })); |
| 763 | let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] })); |
| 764 | assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries)); |
| 765 | let (op, input) = resolve("POST", "/workspaces/acme/work/4/claim", &[], json!({ "note": "on it", "repo": "acme/web" })); |
| 766 | assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem)); |
| 767 | assert_eq!(input, json!({ "note": "on it", "number": 4, "workspace": "acme", "repo": "acme/web" })); |
| 768 | // Work with no work item yet: one is opened and claimed. |
| 769 | let (op, input) = resolve("POST", "/workspaces/acme/claims", &[], json!({ "title": "Fix previews" })); |
| 770 | assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem)); |
| 771 | assert_eq!(input, json!({ "title": "Fix previews", "workspace": "acme" })); |
| 772 | let (op, _) = resolve("POST", "/workspaces/acme/work/4/release", &[], json!({})); |
| 773 | assert_eq!(op, coordination(CoordinationOp::ReleaseClaim)); |
| 774 | let (op, _) = resolve("POST", "/workspaces/acme/work/4/handoff", &[], json!({ "note": "half done" })); |
| 775 | assert_eq!(op, coordination(CoordinationOp::HandOffWorkItem)); |
| 776 | let (op, input) = resolve("GET", "/workspaces/acme/claims", &[("state", "active"), ("repo", "acme/web")], Value::Null); |
| 777 | assert_eq!(op, coordination(CoordinationOp::ListClaims)); |
| 778 | assert_eq!(input, json!({ "state": "active", "repo": "acme/web", "workspace": "acme" })); |
| 779 | let (op, input) = resolve("GET", "/workspaces/acme/work/4/work", &[], Value::Null); |
| 780 | assert_eq!(op, coordination(CoordinationOp::GetWork)); |
| 781 | assert_eq!(input, json!({ "number": 4, "workspace": "acme" })); |
| 782 | } |
| 783 | |
| 784 | #[test] |
| 785 | fn mcp_actions_reach_each_operation() { |
| 786 | use crate::tools::{Tool, resolve}; |
| 787 | let call = |tool: &str, action: &str| resolve(Tool::by_name(tool).unwrap(), &json!({ "action": action, "repo": "a/b", "id": "ses_1", "number": 4, "harness": "codex", "entries": [], "note": "x" })); |
| 788 | assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession))); |
| 789 | assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession))); |
| 790 | assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries))); |
| 791 | assert_eq!(call("work_item", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimWorkItem))); |
| 792 | let claim_new = resolve(Tool::by_name("work_item").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" })); |
| 793 | assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimWorkItem))); |
| 794 | assert_eq!(call("work_item", "work"), Ok(Op::Coordination(CoordinationOp::GetWork))); |
| 795 | } |
| 796 | |
| 797 | #[test] |
| 798 | fn numbers_come_as_numbers_digits_or_with_a_hash() { |
| 799 | assert_eq!(blocked_by(&json!({})), Ok(None)); |
| 800 | assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5]))); |
| 801 | assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![]))); |
| 802 | assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err()); |
| 803 | assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err()); |
| 804 | assert_eq!(optional_number(&json!({ "item": "" }), "item"), Ok(None)); |
| 805 | assert!(optional_number(&json!({ "item": "soon" }), "item").is_err()); |
| 806 | } |
| 807 | } |