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.

773 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. 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 => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]),
210 CoordinationOp::EndSession => (
211 json!({
212 "repo": repo,
213 "id": id,
214 "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." },
215 "result": result,
216 }),
217 &["repo", "id"],
218 ),
219 CoordinationOp::GetSession => (
220 json!({
221 "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace." },
222 "workspace": { "type": "string", "description": "A workspace's slug, instead of repo: the session is found in whichever repository of it it runs in." },
223 "id": id,
224 "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page. To read only pending `updates`, give 4294967295: no entries come back." },
225 }),
226 &["id"],
227 ),
228 CoordinationOp::ListSessions => (
229 json!({
230 "repo": { "type": "string", "description": "Repository as \"owner/name\". Left out, give workspace for every repository of it you may read." },
231 "workspace": { "type": "string", "description": "A workspace's slug, instead of repo: its sessions across every repository of it you may read (see list_workspace_sessions for what else it takes)." },
232 "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." },
233 "item": { "type": "integer", "description": "Only sessions on this work item: attached, or tied to it any other way." },
234 "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." },
235 "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." },
236 "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." },
237 }),
238 &[],
239 ),
240 CoordinationOp::ListWorkspaceSessions => (
241 json!({
242 "workspace": { "type": "string", "description": "The workspace's slug." },
243 "state": { "type": "string", "enum": ["live", "recent", "ended"], "description": "live: going now (active or idle, a run queued or running); recent: every one (the default); ended." },
244 "kind": { "type": "string", "enum": SessionKind::ALL.map(SessionKind::as_str), "description": "Only sessions of this kind." },
245 "harness": { "type": "string", "enum": Harness::ALL.map(Harness::as_str), "description": "Only sessions in this harness." },
246 "person": { "type": "string", "description": "Only sessions this person runs, by username." },
247 "repo": { "type": "string", "description": "Only sessions in this repository of the workspace, by its name (or \"owner/name\")." },
248 "item": { "type": "integer", "description": "Only sessions on this work item of the workspace." },
249 "subagents": { "type": "boolean", "description": "Give each session's subagents too, in its `children`." },
250 "limit": { "type": "integer", "description": "At most this many, up to 200; 60 if left out." },
251 }),
252 &["workspace"],
253 ),
254 CoordinationOp::GetWorkspaceSession => (
255 json!({
256 "workspace": { "type": "string", "description": "The workspace's slug." },
257 "id": id,
258 "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." },
259 }),
260 &["workspace", "id"],
261 ),
262 CoordinationOp::RecordSessionEntries => (
263 json!({
264 "acknowledge": { "type": "array", "maxItems": 100, "items": { "type": "string" }, "description": "Exact pending update IDs this session has considered; never acknowledge merely because fetched." },
265 "repo": repo,
266 "id": id,
267 "entries": {
268 "type": "array",
269 "description": "Up to 200 entries, oldest first.",
270 "items": {
271 "type": "object",
272 "properties": {
273 "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note", "skill"] },
274 "text": { "type": "string", "description": "For a skill, its name only." },
275 "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." },
276 "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." },
277 "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." },
278 },
279 "required": ["kind", "text"],
280 },
281 },
282 "touched": {
283 "type": "array",
284 "description": "What it touched, up to 200, each kept once.",
285 "items": {
286 "type": "object",
287 "properties": {
288 "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "item"] },
289 "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." },
290 },
291 "required": ["kind", "value"],
292 },
293 },
294 }),
295 &["repo", "id"],
296 ),
297 CoordinationOp::ClaimWorkItem => (
298 json!({
299 "workspace": workspace,
300 "repo": checkout,
301 "number": { "type": "integer", "description": "The work item's number. Left out, give title (and body) to open one and claim it." },
302 "title": { "type": "string", "description": "Without number: the title of a new work item to open and claim, filed from repo when given." },
303 "body": { "type": "string", "description": "Without number: the new work item's description." },
304 "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." },
305 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
306 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
307 }),
308 &[],
309 ),
310 CoordinationOp::ReleaseClaim => (
311 json!({
312 "workspace": workspace,
313 "repo": checkout,
314 "number": number,
315 "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
316 "session_id": session_id,
317 }),
318 &["number"],
319 ),
320 CoordinationOp::HandOffWorkItem => (
321 json!({
322 "workspace": workspace,
323 "repo": checkout,
324 "number": number,
325 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
326 "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
327 "session_id": session_id,
328 }),
329 &["number", "note"],
330 ),
331 CoordinationOp::ListClaims => (
332 json!({
333 "workspace": workspace,
334 "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." },
335 "state": {
336 "type": "string",
337 "enum": ["active", "released", "expired", "handed_off"],
338 "description": "Claims in this state; active if left out.",
339 },
340 "item": { "type": "integer", "description": "Only claims on this work item." },
341 }),
342 &[],
343 ),
344 CoordinationOp::GetWork => (json!({ "workspace": workspace, "repo": checkout, "number": number }), &["number"]),
345 CoordinationOp::StartAgentSession => (
346 json!({
347 "repo": repo,
348 "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." },
349 "title": { "type": "string", "description": "Without item: the title of a new work item to open and work on, filed from the repository." },
350 "body": { "type": "string", "description": "Without item: the new work item's description." },
351 "instruction": { "type": "string", "description": "What to do beyond what the work item says. Up to 4,000 characters." },
352 "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)." },
353 "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." },
354 }),
355 &["repo"],
356 ),
357 };
358 json!({ "type": "object", "properties": properties, "required": required })
359 }
360}
361
362fn text(input: &Value, key: &str) -> Option<String> {
363 input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
364}
365
366/// A whole number given as a number, as digits, or as `#12`.
367fn number(input: &Value, key: &str) -> Option<u32> {
368 match &input[key] {
369 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
370 Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
371 _ => None,
372 }
373}
374
375/// An optional whole number: none when left out, refused when not one.
376fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
377 match &input[key] {
378 Value::Null => Ok(None),
379 Value::String(text) if text.trim().is_empty() => Ok(None),
380 _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
381 }
382}
383
384/// `blocked_by` for `update_work_item`: work item numbers, as numbers,
385/// digits or `#12`. None when left out; refused when it holds anything
386/// else.
387pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
388 let items = match &input["blocked_by"] {
389 Value::Null => return Ok(None),
390 Value::Array(items) => items,
391 _ => return Err("blocked_by is a list of work item numbers.".to_owned()),
392 };
393 items
394 .iter()
395 .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not a work item number.")))
396 .collect::<std::result::Result<Vec<u32>, String>>()
397 .map(Some)
398}
399
400pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
401 let given = repo_path(input);
402 let repo = match given.clone() {
403 Some(repo) => repo,
404 None if op.needs_repo() => return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")),
405 None => RepoPath { namespace: String::new(), name: String::new() },
406 };
407 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
408 // A claim or a work context names its work item's workspace, or a
409 // repository of it.
410 let workspace = item_workspace(input);
411 // Sessions across a workspace: listing or getting them without a repository.
412 if given.is_none() && !workspace.is_empty() {
413 match op {
414 CoordinationOp::ListSessions => return list_workspace_sessions(services, viewer, input, workspace).await,
415 CoordinationOp::GetSession => return get_workspace_session(services, viewer, input, workspace).await,
416 _ => {}
417 }
418 }
419 if matches!(op, CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession) {
420 if workspace.is_empty() {
421 return invalid("Give the workspace by its slug.".to_owned());
422 }
423 return match op {
424 CoordinationOp::ListWorkspaceSessions => list_workspace_sessions(services, viewer, input, workspace).await,
425 _ => get_workspace_session(services, viewer, input, workspace).await,
426 };
427 }
428 if matches!(op, CoordinationOp::ListSessions | CoordinationOp::GetSession) && given.is_none() {
429 return invalid("Give the repository as \"owner/name\", or the workspace by its slug.".to_owned());
430 }
431 if !op.needs_repo() && workspace.is_empty() {
432 return invalid("Give the workspace by its slug, or a repository of it as \"owner/name\".".to_owned());
433 }
434 let (item, pull) = match (optional_number(input, "item"), optional_number(input, "pull")) {
435 (Ok(item), Ok(pull)) => (item, pull),
436 (Err(message), _) | (_, Err(message)) => return invalid(message),
437 };
438 let actor = || viewer.clone().unwrap_or_default();
439 let id = || text(input, "id").unwrap_or_default();
440 let item_number = number(input, "number").unwrap_or_default();
441 let work = &services.work;
442 if matches!(
443 op,
444 CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
445 ) && id().is_empty()
446 {
447 return invalid("Name the session by its id (ses_…).".to_owned());
448 }
449 match op {
450 CoordinationOp::StartSession => {
451 let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
452 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
453 return invalid(format!("harness is one of {}.", names.join(", ")));
454 };
455 let args = StartSessionArgs {
456 actor: actor(),
457 repo,
458 harness,
459 harness_session_id: text(input, "harness_session_id"),
460 item,
461 pull,
462 branch: text(input, "branch"),
463 title: text(input, "title"),
464 head_commit: text(input, "head_commit"),
465 parent_id: text(input, "parent_id"),
466 agent_type: text(input, "agent_type"),
467 task: text(input, "task"),
468 prompt: text(input, "prompt"),
469 model: text(input, "model"),
470 };
471 g1t_kit::call(work, "start_session", &args).await
472 }
473 CoordinationOp::UpdateSession => {
474 let args = UpdateSessionArgs {
475 actor: actor(),
476 repo,
477 id: id(),
478 item,
479 pull,
480 branch: text(input, "branch"),
481 head_commit: text(input, "head_commit"),
482 title: text(input, "title"),
483 agent_type: text(input, "agent_type"),
484 task: text(input, "task"),
485 prompt: text(input, "prompt"),
486 model: text(input, "model"),
487 result: text(input, "result"),
488 };
489 g1t_kit::call(work, "update_session", &args).await
490 }
491 CoordinationOp::EndSession => {
492 let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result"), by_hand: false };
493 g1t_kit::call(work, "end_session", &args).await
494 }
495 CoordinationOp::GetSession => {
496 let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
497 g1t_kit::call(work, "get_work_session", &args).await
498 }
499 CoordinationOp::ListSessions => {
500 let state = match text(input, "state") {
501 None => None,
502 Some(state) => match SessionState::parse(&state) {
503 Some(state) => Some(state),
504 None => return invalid(format!("state is active, idle or ended, not {state}.")),
505 },
506 };
507 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
508 let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, item, pull, subagents, path: text(input, "path") };
509 g1t_kit::call(work, "list_work_sessions", &args).await
510 }
511 CoordinationOp::RecordSessionEntries => {
512 let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] {
513 Value::Null => json!([]),
514 entries => entries.clone(),
515 }) {
516 Ok(entries) => entries,
517 Err(_) => {
518 return invalid(
519 "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(),
520 );
521 }
522 };
523 let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] {
524 Value::Null => json!([]),
525 touched => touched.clone(),
526 }) {
527 Ok(touched) => touched,
528 Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or item.".to_owned()),
529 };
530 let acknowledge: Vec<String> = match serde_json::from_value(input.get("acknowledge").cloned().unwrap_or(json!([]))) {
531 Ok(ids) => ids,
532 Err(_) => return invalid("acknowledge must be an array of update IDs.".to_owned()),
533 };
534 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched, acknowledge };
535 g1t_kit::call(work, "record_session_entries", &args).await
536 }
537 CoordinationOp::ClaimWorkItem => {
538 let ttl_minutes = match optional_number(input, "ttl_minutes") {
539 Ok(minutes) => minutes,
540 Err(message) => return invalid(message),
541 };
542 let title = text(input, "title");
543 if item_number == 0 && title.is_none() {
544 return invalid("Name the work item by its number, or give a title (and body) to open one.".to_owned());
545 }
546 if item_number != 0 && title.is_some() {
547 return invalid("Give the work item's number or a title for a new one, not both.".to_owned());
548 }
549 let args = ClaimArgs {
550 actor: actor(),
551 workspace,
552 repo: given,
553 number: item_number,
554 title,
555 body: text(input, "body"),
556 session_id: text(input, "session_id"),
557 note: text(input, "note"),
558 ttl_minutes,
559 };
560 g1t_kit::call(work, "claim_work_item", &args).await
561 }
562 CoordinationOp::ReleaseClaim => {
563 let args = ReleaseArgs { actor: actor(), workspace, repo: given, number: item_number, reason: text(input, "reason"), session_id: text(input, "session_id") };
564 g1t_kit::call(work, "release_claim", &args).await
565 }
566 CoordinationOp::HandOffWorkItem => {
567 let Some(note) = text(input, "note") else {
568 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
569 };
570 let args = HandOffArgs { actor: actor(), workspace, repo: given, number: item_number, to: text(input, "to"), note, session_id: text(input, "session_id") };
571 g1t_kit::call(work, "hand_off_work_item", &args).await
572 }
573 CoordinationOp::ListClaims => {
574 let state = match text(input, "state") {
575 None => None,
576 Some(state) => match ClaimState::parse(&state) {
577 Some(state) => Some(state),
578 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
579 },
580 };
581 let args = ListClaimsArgs { viewer: viewer.clone(), workspace, repo: given, state, item };
582 g1t_kit::call(work, "list_claims", &args).await
583 }
584 CoordinationOp::GetWork => {
585 let args = WorkItemViewArgs { workspace, number: item_number, viewer: viewer.clone() };
586 g1t_kit::call(work, "get_work", &args).await
587 }
588 CoordinationOp::StartAgentSession => {
589 let mode = match text(input, "mode") {
590 None => None,
591 Some(mode) => match AgentMode::parse(&mode) {
592 Some(mode) => Some(mode),
593 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
594 },
595 };
596 if item.is_none() && text(input, "title").is_none() {
597 return invalid("Name the work item, or give a title (and body) for a new one.".to_owned());
598 }
599 let handoff = match &input["handoff"] {
600 Value::Bool(handoff) => Some(*handoff),
601 Value::String(text) if text == "true" => Some(true),
602 Value::String(text) if text == "false" => Some(false),
603 _ => None,
604 };
605 let args = StartAgentSessionArgs {
606 actor: actor(),
607 repo,
608 item,
609 pull: None,
610 asked: None,
611 title: text(input, "title"),
612 body: text(input, "body"),
613 instruction: text(input, "instruction"),
614 mode,
615 handoff,
616 };
617 g1t_kit::call(&services.integrations, "start_agent_session", &args).await
618 }
619 // Answered above, before a repository is asked for.
620 CoordinationOp::ListWorkspaceSessions | CoordinationOp::GetWorkspaceSession => invalid("Give the workspace by its slug.".to_owned()),
621 }
622}
623
624/// `list_workspace_sessions`, from a `workspace` and its filters.
625async fn list_workspace_sessions(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> {
626 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
627 let shown = match text(input, "state") {
628 None => SessionsShown::Recent,
629 Some(state) => match SessionsShown::parse(&state) {
630 Some(shown) => shown,
631 None => return invalid(format!("state is live, recent (or all) or ended, not {state}.")),
632 },
633 };
634 let kind = match text(input, "kind") {
635 None => None,
636 Some(kind) => match SessionKind::parse(&kind) {
637 Some(kind) => Some(kind),
638 None => return invalid(format!("kind is harness, hosted, agent or recorded, not {kind}.")),
639 },
640 };
641 let harness = match text(input, "harness") {
642 None => None,
643 Some(name) => match Harness::parse(&name) {
644 Some(harness) => Some(harness),
645 None => {
646 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
647 return invalid(format!("harness is one of {}.", names.join(", ")));
648 }
649 },
650 };
651 let item = match optional_number(input, "item") {
652 Ok(item) => item,
653 Err(message) => return invalid(message),
654 };
655 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
656 let args = ListWorkspaceSessionsArgs {
657 viewer: viewer.clone(),
658 workspace,
659 shown,
660 kind,
661 harness,
662 person: text(input, "person"),
663 repo: text(input, "repo").or_else(|| text(input, "repository")),
664 item,
665 subagents,
666 sessions_only: false,
667 limit: number(input, "limit"),
668 };
669 g1t_kit::call(&services.work, "list_workspace_sessions", &args).await
670}
671
672/// `get_workspace_session`, from a `workspace` and the session's `id`.
673async fn get_workspace_session(services: &Services, viewer: &Viewer, input: &Value, workspace: String) -> Result<Outcome<Value>> {
674 let Some(id) = text(input, "id") else {
675 return Ok(Outcome::fail(FailureCode::Invalid, "Name the session by its id (ses_…)."));
676 };
677 let args = GetWorkspaceSessionArgs { viewer: viewer.clone(), workspace, id, after_seq: number(input, "after").unwrap_or_default() };
678 g1t_kit::call(&services.work, "get_workspace_session", &args).await
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use crate::operations::Op;
685 use g1t_contracts::scopes::{Level, Scope, scope_for};
686
687 #[test]
688 fn each_operation_is_described_scoped_and_listed() {
689 for op in CoordinationOp::ALL {
690 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
691 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
692 assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
693 assert_eq!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), op.needs_repo(), "{}", op.name());
694 let scope = scope_for(op.name()).unwrap();
695 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
696 let sessions = op.name().contains("session");
697 let expected = match (sessions, op.reads()) {
698 (true, true) => Scope::PullRequestsRead,
699 (true, false) => Scope::PullRequestsWrite,
700 (false, true) => Scope::WorkRead,
701 (false, false) => Scope::WorkWrite,
702 };
703 assert_eq!(scope, expected, "{}", op.name());
704 }
705 }
706
707 #[test]
708 fn routes_reach_each_operation_with_its_input() {
709 let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
710 let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
711 let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
712 (route.op, input)
713 };
714 let coordination = |op| Op::Coordination(op);
715 let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
716 assert_eq!(op, coordination(CoordinationOp::StartSession));
717 assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
718 let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
719 assert_eq!(op, coordination(CoordinationOp::UpdateSession));
720 assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
721 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
722 assert_eq!(op, coordination(CoordinationOp::EndSession));
723 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
724 assert_eq!(op, coordination(CoordinationOp::GetSession));
725 assert_eq!(input["after"], "40");
726 let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("item", "4")], Value::Null);
727 assert_eq!(op, coordination(CoordinationOp::ListSessions));
728 assert_eq!(input, json!({ "state": "idle", "item": "4", "repo": "acme/web" }));
729 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
730 assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
731 let (op, input) = resolve("POST", "/workspaces/acme/work/4/claim", &[], json!({ "note": "on it", "repo": "acme/web" }));
732 assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem));
733 assert_eq!(input, json!({ "note": "on it", "number": 4, "workspace": "acme", "repo": "acme/web" }));
734 // Work with no work item yet: one is opened and claimed.
735 let (op, input) = resolve("POST", "/workspaces/acme/claims", &[], json!({ "title": "Fix previews" }));
736 assert_eq!(op, coordination(CoordinationOp::ClaimWorkItem));
737 assert_eq!(input, json!({ "title": "Fix previews", "workspace": "acme" }));
738 let (op, _) = resolve("POST", "/workspaces/acme/work/4/release", &[], json!({}));
739 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
740 let (op, _) = resolve("POST", "/workspaces/acme/work/4/handoff", &[], json!({ "note": "half done" }));
741 assert_eq!(op, coordination(CoordinationOp::HandOffWorkItem));
742 let (op, input) = resolve("GET", "/workspaces/acme/claims", &[("state", "active"), ("repo", "acme/web")], Value::Null);
743 assert_eq!(op, coordination(CoordinationOp::ListClaims));
744 assert_eq!(input, json!({ "state": "active", "repo": "acme/web", "workspace": "acme" }));
745 let (op, input) = resolve("GET", "/workspaces/acme/work/4/work", &[], Value::Null);
746 assert_eq!(op, coordination(CoordinationOp::GetWork));
747 assert_eq!(input, json!({ "number": 4, "workspace": "acme" }));
748 }
749
750 #[test]
751 fn mcp_actions_reach_each_operation() {
752 use crate::tools::{Tool, resolve};
753 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" }));
754 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
755 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
756 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
757 assert_eq!(call("work_item", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimWorkItem)));
758 let claim_new = resolve(Tool::by_name("work_item").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" }));
759 assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimWorkItem)));
760 assert_eq!(call("work_item", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
761 }
762
763 #[test]
764 fn numbers_come_as_numbers_digits_or_with_a_hash() {
765 assert_eq!(blocked_by(&json!({})), Ok(None));
766 assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
767 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
768 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
769 assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
770 assert_eq!(optional_number(&json!({ "item": "" }), "item"), Ok(None));
771 assert!(optional_number(&json!({ "item": "soon" }), "item").is_err());
772 }
773}