Skip to content
606 linesCodeBlameRaw
1//! Coordinating work over REST and MCP: sessions of people's own harnesses
2//! (the MCP `session` tool), and claims and an issue's whole context
3//! (actions of the `issue` tool).
4//!
5//! The work service keeps them and decides who may see and change them
6//! (`g1t_contracts::work::coordination`): anyone who can read the
7//! repository reads them; taking part needs a confirmed email address and a
8//! role that may open issues and pull requests; only the person whose
9//! token started a session, or made a claim, changes it.
10
11use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs};
12use g1t_contracts::work::*;
13use g1t_contracts::{FailureCode, Outcome, Viewer};
14use serde_json::{Value, json};
15use worker::Result;
16
17use crate::operations::{Services, repo_path};
18
19/// One operation on sessions, claims or an issue's context.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum CoordinationOp {
22 StartSession,
23 UpdateSession,
24 EndSession,
25 GetSession,
26 ListSessions,
27 RecordSessionEntries,
28 ClaimIssue,
29 ReleaseClaim,
30 HandOffIssue,
31 ListClaims,
32 GetWork,
33 StartAgentSession,
34}
35
36impl CoordinationOp {
37 /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a
38 /// test checks against this.
39 pub const ALL: [CoordinationOp; 12] = [
40 CoordinationOp::StartSession,
41 CoordinationOp::UpdateSession,
42 CoordinationOp::EndSession,
43 CoordinationOp::GetSession,
44 CoordinationOp::ListSessions,
45 CoordinationOp::RecordSessionEntries,
46 CoordinationOp::ClaimIssue,
47 CoordinationOp::ReleaseClaim,
48 CoordinationOp::HandOffIssue,
49 CoordinationOp::ListClaims,
50 CoordinationOp::GetWork,
51 CoordinationOp::StartAgentSession,
52 ];
53
54 pub fn name(self) -> &'static str {
55 match self {
56 CoordinationOp::StartSession => "start_session",
57 CoordinationOp::UpdateSession => "update_session",
58 CoordinationOp::EndSession => "end_session",
59 CoordinationOp::GetSession => "get_session",
60 CoordinationOp::ListSessions => "list_sessions",
61 CoordinationOp::RecordSessionEntries => "record_session_entries",
62 CoordinationOp::ClaimIssue => "claim_issue",
63 CoordinationOp::ReleaseClaim => "release_claim",
64 CoordinationOp::HandOffIssue => "hand_off_issue",
65 CoordinationOp::ListClaims => "list_claims",
66 CoordinationOp::GetWork => "get_work",
67 CoordinationOp::StartAgentSession => "start_agent_session",
68 }
69 }
70
71 /// For the API reference.
72 pub fn title(self) -> &'static str {
73 match self {
74 CoordinationOp::StartSession => "Start a session",
75 CoordinationOp::UpdateSession => "Update a session",
76 CoordinationOp::EndSession => "End a session",
77 CoordinationOp::GetSession => "Get a session",
78 CoordinationOp::ListSessions => "List sessions",
79 CoordinationOp::RecordSessionEntries => "Record entries in a session",
80 CoordinationOp::ClaimIssue => "Claim an issue",
81 CoordinationOp::ReleaseClaim => "Release a claim",
82 CoordinationOp::HandOffIssue => "Hand off an issue",
83 CoordinationOp::ListClaims => "List claims",
84 CoordinationOp::GetWork => "Get an issue's work context",
85 CoordinationOp::StartAgentSession => "Start an agent session on an issue",
86 }
87 }
88
89 /// Whether it only reads.
90 pub fn reads(self) -> bool {
91 matches!(
92 self,
93 CoordinationOp::GetSession | CoordinationOp::ListSessions | CoordinationOp::ListClaims | CoordinationOp::GetWork
94 )
95 }
96
97 pub fn description(self) -> &'static str {
98 match self {
99 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 issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue 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 issue, 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 issues.",
100 CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue 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 2 hours; 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`.",
101 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 issue off, or let them expire.",
102 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 issues it and they touched; `links`, the issues 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. Anyone who can read the repository may.",
103 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; `issue` 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.",
104 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 issue (one it named); each is kept once, ties the session to the pull requests and issues 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. 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. Recording is a heartbeat. Returns how many were `recorded` and how many were `duplicates`.",
105 CoordinationOp::ClaimIssue => "Claim an open issue: say you are working on it. A claim is advisory, never a lock: anyone may claim an issue 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. Returns your `claim`, the `others`, and `blocked_by`: each issue 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 issue yet, leave out `number` and give `title` (and `body`): the issue is opened first (an issue.opened event), then claimed, the session named by `session_id` is attached to it, and `issue` is the issue opened. The session is checked first, so a claim it would refuse opens nothing.",
106 CoordinationOp::ReleaseClaim => "Release your claim on an issue, with an optional `reason`: you stopped working on it. A work.released event. Claims also end when the issue closes, and expire when not renewed. Refused with `404` when you hold no active claim on it.",
107 CoordinationOp::HandOffIssue => "Hand an issue 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 issue hears; the next claim on it says it took the hand-off over. Refused with `404` when you hold no active claim on it.",
108 CoordinationOp::ListClaims => "A repository's claims, newest first, up to 100: active ones unless `state` says released, expired or handed_off, and `issue` for one issue's. Anyone who can read the repository may.",
109 CoordinationOp::GetWork => "Everything about an issue an agent needs before working on it, in one call: the `issue`, its `definition_of_done` (the items under its \"Definition of done\" heading) and the `required_checks` every pull request into the default branch must pass; active `claims`; `sessions` on it or its pull requests; its `pulls`, 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; `blocked_by` (each issue it waits on, with claims and pull requests) and `blocks` (the issues waiting on it); `decisions` recorded about it; `last_integration_failure`; and `recent`, its latest coordination events, newest first.",
110 CoordinationOp::StartAgentSession => "Start work on an issue in a session of its own, on the person's own Claude: g1t runs no model and bills nothing for it. Give `issue`, or `title` and `body` to open a new issue first; `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 connection (an owner adds it under Integrations), 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-issue, 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 connection 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 issue as it starts.",
111 }
112 }
113
114 pub fn input(self) -> Value {
115 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
116 let id = json!({ "type": "string", "description": "The session's id (ses_…), from start_session or list_sessions." });
117 let number = json!({ "type": "integer", "description": "The issue's number." });
118 let issue = json!({ "type": "integer", "description": "The issue it works on." });
119 let pull = json!({ "type": "integer", "description": "The pull request it works on." });
120 let fields = |more: Value| -> Value {
121 let mut properties = json!({
122 "repo": repo,
123 "issue": issue,
124 "pull": pull,
125 "branch": { "type": "string", "description": "The branch it works on." },
126 "title": { "type": "string", "description": "What it is doing, in a few words. Up to 200 characters." },
127 "head_commit": { "type": "string", "description": "The commit it is at, such as after a push." },
128 });
129 if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
130 all.extend(more);
131 }
132 properties
133 };
134 let session_id = json!({ "type": "string", "description": "The session (ses_…) whose claim it is, when you have several on the issue, such as sibling subagents." });
135 let subagent = json!({
136 "agent_type": { "type": "string", "description": "For a subagent: its kind, as the harness names it (Explore, security-reviewer)." },
137 "task": { "type": "string", "description": "For a subagent: the task it was given, in a few words. Up to 200 characters." },
138 "prompt": { "type": "string", "description": "For a subagent: the prompt its parent gave it. Kept up to 4,000 characters." },
139 "model": { "type": "string", "description": "The model it runs on, when known." },
140 });
141 let with = |base: Value, more: Value| -> Value {
142 let mut base = base;
143 if let (Some(all), Value::Object(more)) = (base.as_object_mut(), more) {
144 all.extend(more);
145 }
146 base
147 };
148 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." });
149 let (properties, required): (Value, &[&str]) = match self {
150 CoordinationOp::StartSession => (
151 with(
152 fields(json!({
153 "harness": {
154 "type": "string",
155 "enum": Harness::ALL.map(Harness::as_str),
156 "description": "The harness the session runs in.",
157 },
158 "harness_session_id": {
159 "type": "string",
160 "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.",
161 },
162 "parent_id": { "type": "string", "description": "For a subagent: the session that started it (ses_…), one of yours on the repository." },
163 })),
164 subagent.clone(),
165 ),
166 &["repo", "harness"],
167 ),
168 CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]),
169 CoordinationOp::EndSession => (
170 json!({
171 "repo": repo,
172 "id": id,
173 "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." },
174 "result": result,
175 }),
176 &["repo", "id"],
177 ),
178 CoordinationOp::GetSession => (
179 json!({
180 "repo": repo,
181 "id": id,
182 "after": { "type": "integer", "description": "Only entries after this sequence number, for the next page." },
183 }),
184 &["repo", "id"],
185 ),
186 CoordinationOp::ListSessions => (
187 json!({
188 "repo": repo,
189 "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." },
190 "issue": { "type": "integer", "description": "Only sessions on this issue: attached, or tied to it any other way." },
191 "pull": { "type": "integer", "description": "Only sessions on this pull request: attached, or tied to it any other way." },
192 "path": { "type": "string", "description": "Only sessions that touched this file, or any under it when it ends in `/`." },
193 "subagents": { "type": "boolean", "description": "List subagents too, each tree whole. Left out, only the sessions at the top." },
194 }),
195 &["repo"],
196 ),
197 CoordinationOp::RecordSessionEntries => (
198 json!({
199 "repo": repo,
200 "id": id,
201 "entries": {
202 "type": "array",
203 "description": "Up to 200 entries, oldest first.",
204 "items": {
205 "type": "object",
206 "properties": {
207 "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note", "skill"] },
208 "text": { "type": "string", "description": "For a skill, its name only." },
209 "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." },
210 "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." },
211 "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." },
212 },
213 "required": ["kind", "text"],
214 },
215 },
216 "touched": {
217 "type": "array",
218 "description": "What it touched, up to 200, each kept once.",
219 "items": {
220 "type": "object",
221 "properties": {
222 "kind": { "type": "string", "enum": ["file", "commit", "branch", "pull", "issue"] },
223 "value": { "type": "string", "description": "A path from the repository's root, a commit, a branch, or a number." },
224 },
225 "required": ["kind", "value"],
226 },
227 },
228 }),
229 &["repo", "id"],
230 ),
231 CoordinationOp::ClaimIssue => (
232 json!({
233 "repo": repo,
234 "number": { "type": "integer", "description": "The issue's number. Left out, give title (and body) to open one and claim it." },
235 "title": { "type": "string", "description": "Without number: the title of a new issue to open and claim." },
236 "body": { "type": "string", "description": "Without number: the new issue's description." },
237 "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim. With title, it is attached to the new issue." },
238 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
239 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
240 }),
241 &["repo"],
242 ),
243 CoordinationOp::ReleaseClaim => (
244 json!({
245 "repo": repo,
246 "number": number,
247 "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
248 "session_id": session_id,
249 }),
250 &["repo", "number"],
251 ),
252 CoordinationOp::HandOffIssue => (
253 json!({
254 "repo": repo,
255 "number": number,
256 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
257 "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
258 "session_id": session_id,
259 }),
260 &["repo", "number", "note"],
261 ),
262 CoordinationOp::ListClaims => (
263 json!({
264 "repo": repo,
265 "state": {
266 "type": "string",
267 "enum": ["active", "released", "expired", "handed_off"],
268 "description": "Claims in this state; active if left out.",
269 },
270 "issue": { "type": "integer", "description": "Only claims on this issue." },
271 }),
272 &["repo"],
273 ),
274 CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]),
275 CoordinationOp::StartAgentSession => (
276 json!({
277 "repo": repo,
278 "issue": { "type": "integer", "description": "The issue to work on. Left out, give title (and body) to open one." },
279 "title": { "type": "string", "description": "Without issue: the title of a new issue to open and work on." },
280 "body": { "type": "string", "description": "Without issue: the new issue's description." },
281 "instruction": { "type": "string", "description": "What to do beyond what the issue says. Up to 4,000 characters." },
282 "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)." },
283 "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 there is no Anthropic connection. Left out, a session when the workspace can, else the hand-off." },
284 }),
285 &["repo"],
286 ),
287 };
288 json!({ "type": "object", "properties": properties, "required": required })
289 }
290}
291
292fn text(input: &Value, key: &str) -> Option<String> {
293 input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
294}
295
296/// A whole number given as a number, as digits, or as `#12`.
297fn number(input: &Value, key: &str) -> Option<u32> {
298 match &input[key] {
299 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
300 Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
301 _ => None,
302 }
303}
304
305/// An optional whole number: none when left out, refused when not one.
306fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
307 match &input[key] {
308 Value::Null => Ok(None),
309 Value::String(text) if text.trim().is_empty() => Ok(None),
310 _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
311 }
312}
313
314/// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or
315/// `#12`. None when left out; refused when it holds anything else.
316pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
317 let items = match &input["blocked_by"] {
318 Value::Null => return Ok(None),
319 Value::Array(items) => items,
320 _ => return Err("blocked_by is a list of issue numbers.".to_owned()),
321 };
322 items
323 .iter()
324 .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number.")))
325 .collect::<std::result::Result<Vec<u32>, String>>()
326 .map(Some)
327}
328
329pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
330 let Some(repo) = repo_path(input) else {
331 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
332 };
333 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
334 let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) {
335 (Ok(issue), Ok(pull)) => (issue, pull),
336 (Err(message), _) | (_, Err(message)) => return invalid(message),
337 };
338 let actor = || viewer.clone().unwrap_or_default();
339 let id = || text(input, "id").unwrap_or_default();
340 let issue_number = number(input, "number").unwrap_or_default();
341 let work = &services.work;
342 if matches!(
343 op,
344 CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
345 ) && id().is_empty()
346 {
347 return invalid("Name the session by its id (ses_…).".to_owned());
348 }
349 match op {
350 CoordinationOp::StartSession => {
351 let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
352 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
353 return invalid(format!("harness is one of {}.", names.join(", ")));
354 };
355 let args = StartSessionArgs {
356 actor: actor(),
357 repo,
358 harness,
359 harness_session_id: text(input, "harness_session_id"),
360 issue,
361 pull,
362 branch: text(input, "branch"),
363 title: text(input, "title"),
364 head_commit: text(input, "head_commit"),
365 parent_id: text(input, "parent_id"),
366 agent_type: text(input, "agent_type"),
367 task: text(input, "task"),
368 prompt: text(input, "prompt"),
369 model: text(input, "model"),
370 };
371 g1t_kit::call(work, "start_session", &args).await
372 }
373 CoordinationOp::UpdateSession => {
374 let args = UpdateSessionArgs {
375 actor: actor(),
376 repo,
377 id: id(),
378 issue,
379 pull,
380 branch: text(input, "branch"),
381 head_commit: text(input, "head_commit"),
382 title: text(input, "title"),
383 agent_type: text(input, "agent_type"),
384 task: text(input, "task"),
385 prompt: text(input, "prompt"),
386 model: text(input, "model"),
387 result: text(input, "result"),
388 };
389 g1t_kit::call(work, "update_session", &args).await
390 }
391 CoordinationOp::EndSession => {
392 let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") };
393 g1t_kit::call(work, "end_session", &args).await
394 }
395 CoordinationOp::GetSession => {
396 let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
397 g1t_kit::call(work, "get_work_session", &args).await
398 }
399 CoordinationOp::ListSessions => {
400 let state = match text(input, "state") {
401 None => None,
402 Some(state) => match SessionState::parse(&state) {
403 Some(state) => Some(state),
404 None => return invalid(format!("state is active, idle or ended, not {state}.")),
405 },
406 };
407 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
408 let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull, subagents, path: text(input, "path") };
409 g1t_kit::call(work, "list_work_sessions", &args).await
410 }
411 CoordinationOp::RecordSessionEntries => {
412 let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] {
413 Value::Null => json!([]),
414 entries => entries.clone(),
415 }) {
416 Ok(entries) => entries,
417 Err(_) => {
418 return invalid(
419 "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(),
420 );
421 }
422 };
423 let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] {
424 Value::Null => json!([]),
425 touched => touched.clone(),
426 }) {
427 Ok(touched) => touched,
428 Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or issue.".to_owned()),
429 };
430 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched };
431 g1t_kit::call(work, "record_session_entries", &args).await
432 }
433 CoordinationOp::ClaimIssue => {
434 let ttl_minutes = match optional_number(input, "ttl_minutes") {
435 Ok(minutes) => minutes,
436 Err(message) => return invalid(message),
437 };
438 let title = text(input, "title");
439 if issue_number == 0 && title.is_none() {
440 return invalid("Name the issue by its number, or give a title (and body) to open one.".to_owned());
441 }
442 if issue_number != 0 && title.is_some() {
443 return invalid("Give the issue's number or a title for a new one, not both.".to_owned());
444 }
445 let args = ClaimArgs {
446 actor: actor(),
447 repo,
448 number: issue_number,
449 title,
450 body: text(input, "body"),
451 session_id: text(input, "session_id"),
452 note: text(input, "note"),
453 ttl_minutes,
454 };
455 g1t_kit::call(work, "claim_issue", &args).await
456 }
457 CoordinationOp::ReleaseClaim => {
458 let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") };
459 g1t_kit::call(work, "release_claim", &args).await
460 }
461 CoordinationOp::HandOffIssue => {
462 let Some(note) = text(input, "note") else {
463 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
464 };
465 let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note, session_id: text(input, "session_id") };
466 g1t_kit::call(work, "hand_off_issue", &args).await
467 }
468 CoordinationOp::ListClaims => {
469 let state = match text(input, "state") {
470 None => None,
471 Some(state) => match ClaimState::parse(&state) {
472 Some(state) => Some(state),
473 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
474 },
475 };
476 let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
477 g1t_kit::call(work, "list_claims", &args).await
478 }
479 CoordinationOp::GetWork => {
480 let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
481 g1t_kit::call(work, "get_work", &args).await
482 }
483 CoordinationOp::StartAgentSession => {
484 let mode = match text(input, "mode") {
485 None => None,
486 Some(mode) => match AgentMode::parse(&mode) {
487 Some(mode) => Some(mode),
488 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
489 },
490 };
491 if issue.is_none() && text(input, "title").is_none() {
492 return invalid("Name the issue, or give a title (and body) for a new one.".to_owned());
493 }
494 let handoff = match &input["handoff"] {
495 Value::Bool(handoff) => Some(*handoff),
496 Value::String(text) if text == "true" => Some(true),
497 Value::String(text) if text == "false" => Some(false),
498 _ => None,
499 };
500 let args = StartAgentSessionArgs {
501 actor: actor(),
502 repo,
503 issue,
504 title: text(input, "title"),
505 body: text(input, "body"),
506 instruction: text(input, "instruction"),
507 mode,
508 handoff,
509 };
510 g1t_kit::call(&services.integrations, "start_agent_session", &args).await
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::operations::Op;
519 use g1t_contracts::scopes::{Level, Scope, scope_for};
520
521 #[test]
522 fn each_operation_is_described_scoped_and_listed() {
523 for op in CoordinationOp::ALL {
524 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
525 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
526 assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
527 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
528 let scope = scope_for(op.name()).unwrap();
529 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
530 let sessions = op.name().contains("session");
531 let expected = match (sessions, op.reads()) {
532 (true, true) => Scope::PullRequestsRead,
533 (true, false) => Scope::PullRequestsWrite,
534 (false, true) => Scope::IssuesRead,
535 (false, false) => Scope::IssuesWrite,
536 };
537 assert_eq!(scope, expected, "{}", op.name());
538 }
539 }
540
541 #[test]
542 fn routes_reach_each_operation_with_its_input() {
543 let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
544 let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
545 let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
546 (route.op, input)
547 };
548 let coordination = |op| Op::Coordination(op);
549 let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
550 assert_eq!(op, coordination(CoordinationOp::StartSession));
551 assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
552 let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
553 assert_eq!(op, coordination(CoordinationOp::UpdateSession));
554 assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
555 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
556 assert_eq!(op, coordination(CoordinationOp::EndSession));
557 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
558 assert_eq!(op, coordination(CoordinationOp::GetSession));
559 assert_eq!(input["after"], "40");
560 let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null);
561 assert_eq!(op, coordination(CoordinationOp::ListSessions));
562 assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" }));
563 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
564 assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
565 let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
566 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
567 assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
568 // Work with no issue yet: one is opened and claimed.
569 let (op, input) = resolve("POST", "/repos/acme/web/claims", &[], json!({ "title": "Fix previews" }));
570 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
571 assert_eq!(input, json!({ "title": "Fix previews", "repo": "acme/web" }));
572 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
573 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
574 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
575 assert_eq!(op, coordination(CoordinationOp::HandOffIssue));
576 let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null);
577 assert_eq!(op, coordination(CoordinationOp::ListClaims));
578 let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null);
579 assert_eq!(op, coordination(CoordinationOp::GetWork));
580 assert_eq!(input, json!({ "number": 4, "repo": "acme/web" }));
581 }
582
583 #[test]
584 fn mcp_actions_reach_each_operation() {
585 use crate::tools::{Tool, resolve};
586 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" }));
587 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
588 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
589 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
590 assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
591 let claim_new = resolve(Tool::by_name("issue").unwrap(), &json!({ "action": "claim", "repo": "a/b", "title": "Fix previews" }));
592 assert_eq!(claim_new, Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
593 assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
594 }
595
596 #[test]
597 fn numbers_come_as_numbers_digits_or_with_a_hash() {
598 assert_eq!(blocked_by(&json!({})), Ok(None));
599 assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
600 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
601 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
602 assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
603 assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None));
604 assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err());
605 }
606}