Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

807 linesCodeBlameRaw
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
13use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs};
14use g1t_contracts::work::*;
15use g1t_contracts::{FailureCode, Outcome, Viewer};
16use serde_json::{Value, json};
17use worker::Result;
18
19use g1t_contracts::repos::RepoPath;
20
21use 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)]
25pub 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
42impl 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
388fn 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`.
393fn 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.
402fn 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.
413pub 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
426pub 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.
659async 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`.
707async 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)]
716mod 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}