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.

g1t.sh
589 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`.",
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": number,
235 "session_id": { "type": "string", "description": "One of your sessions (ses_…) on the repository: its heartbeats renew the claim." },
236 "note": { "type": "string", "description": "What you are doing, for whoever else looks. Up to 500 characters." },
237 "ttl_minutes": { "type": "integer", "description": "How long it lasts unless renewed: 30 unless you say, at most 240." },
238 }),
239 &["repo", "number"],
240 ),
241 CoordinationOp::ReleaseClaim => (
242 json!({
243 "repo": repo,
244 "number": number,
245 "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." },
246 "session_id": session_id,
247 }),
248 &["repo", "number"],
249 ),
250 CoordinationOp::HandOffIssue => (
251 json!({
252 "repo": repo,
253 "number": number,
254 "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." },
255 "note": { "type": "string", "description": "Where the work stands: what is done, what is left, what to watch for. Up to 500 characters." },
256 "session_id": session_id,
257 }),
258 &["repo", "number", "note"],
259 ),
260 CoordinationOp::ListClaims => (
261 json!({
262 "repo": repo,
263 "state": {
264 "type": "string",
265 "enum": ["active", "released", "expired", "handed_off"],
266 "description": "Claims in this state; active if left out.",
267 },
268 "issue": { "type": "integer", "description": "Only claims on this issue." },
269 }),
270 &["repo"],
271 ),
272 CoordinationOp::GetWork => (json!({ "repo": repo, "number": number }), &["repo", "number"]),
273 CoordinationOp::StartAgentSession => (
274 json!({
275 "repo": repo,
276 "issue": { "type": "integer", "description": "The issue to work on. Left out, give title (and body) to open one." },
277 "title": { "type": "string", "description": "Without issue: the title of a new issue to open and work on." },
278 "body": { "type": "string", "description": "Without issue: the new issue's description." },
279 "instruction": { "type": "string", "description": "What to do beyond what the issue says. Up to 4,000 characters." },
280 "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)." },
281 "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." },
282 }),
283 &["repo"],
284 ),
285 };
286 json!({ "type": "object", "properties": properties, "required": required })
287 }
288}
289
290fn text(input: &Value, key: &str) -> Option<String> {
291 input[key].as_str().map(str::trim).filter(|text| !text.is_empty()).map(str::to_owned)
292}
293
294/// A whole number given as a number, as digits, or as `#12`.
295fn number(input: &Value, key: &str) -> Option<u32> {
296 match &input[key] {
297 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
298 Value::String(digits) => digits.trim().trim_start_matches('#').parse().ok(),
299 _ => None,
300 }
301}
302
303/// An optional whole number: none when left out, refused when not one.
304fn optional_number(input: &Value, key: &str) -> std::result::Result<Option<u32>, String> {
305 match &input[key] {
306 Value::Null => Ok(None),
307 Value::String(text) if text.trim().is_empty() => Ok(None),
308 _ => number(input, key).map(Some).ok_or_else(|| format!("{key} is a number.")),
309 }
310}
311
312/// `blocked_by` for `update_issue`: issue numbers, as numbers, digits or
313/// `#12`. None when left out; refused when it holds anything else.
314pub fn blocked_by(input: &Value) -> std::result::Result<Option<Vec<u32>>, String> {
315 let items = match &input["blocked_by"] {
316 Value::Null => return Ok(None),
317 Value::Array(items) => items,
318 _ => return Err("blocked_by is a list of issue numbers.".to_owned()),
319 };
320 items
321 .iter()
322 .map(|item| number(&json!({ "n": item }), "n").ok_or_else(|| format!("blocked_by holds {item}, which is not an issue number.")))
323 .collect::<std::result::Result<Vec<u32>, String>>()
324 .map(Some)
325}
326
327pub async fn run(op: CoordinationOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
328 let Some(repo) = repo_path(input) else {
329 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
330 };
331 let invalid = |message: String| Ok(Outcome::fail(FailureCode::Invalid, message));
332 let (issue, pull) = match (optional_number(input, "issue"), optional_number(input, "pull")) {
333 (Ok(issue), Ok(pull)) => (issue, pull),
334 (Err(message), _) | (_, Err(message)) => return invalid(message),
335 };
336 let actor = || viewer.clone().unwrap_or_default();
337 let id = || text(input, "id").unwrap_or_default();
338 let issue_number = number(input, "number").unwrap_or_default();
339 let work = &services.work;
340 if matches!(
341 op,
342 CoordinationOp::UpdateSession | CoordinationOp::EndSession | CoordinationOp::GetSession | CoordinationOp::RecordSessionEntries
343 ) && id().is_empty()
344 {
345 return invalid("Name the session by its id (ses_…).".to_owned());
346 }
347 match op {
348 CoordinationOp::StartSession => {
349 let Some(harness) = input["harness"].as_str().and_then(Harness::parse) else {
350 let names: Vec<&str> = Harness::ALL.iter().map(|harness| harness.as_str()).collect();
351 return invalid(format!("harness is one of {}.", names.join(", ")));
352 };
353 let args = StartSessionArgs {
354 actor: actor(),
355 repo,
356 harness,
357 harness_session_id: text(input, "harness_session_id"),
358 issue,
359 pull,
360 branch: text(input, "branch"),
361 title: text(input, "title"),
362 head_commit: text(input, "head_commit"),
363 parent_id: text(input, "parent_id"),
364 agent_type: text(input, "agent_type"),
365 task: text(input, "task"),
366 prompt: text(input, "prompt"),
367 model: text(input, "model"),
368 };
369 g1t_kit::call(work, "start_session", &args).await
370 }
371 CoordinationOp::UpdateSession => {
372 let args = UpdateSessionArgs {
373 actor: actor(),
374 repo,
375 id: id(),
376 issue,
377 pull,
378 branch: text(input, "branch"),
379 head_commit: text(input, "head_commit"),
380 title: text(input, "title"),
381 agent_type: text(input, "agent_type"),
382 task: text(input, "task"),
383 prompt: text(input, "prompt"),
384 model: text(input, "model"),
385 result: text(input, "result"),
386 };
387 g1t_kit::call(work, "update_session", &args).await
388 }
389 CoordinationOp::EndSession => {
390 let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") };
391 g1t_kit::call(work, "end_session", &args).await
392 }
393 CoordinationOp::GetSession => {
394 let args = WorkSessionArgs { viewer: viewer.clone(), repo, id: id(), after_seq: number(input, "after").unwrap_or_default() };
395 g1t_kit::call(work, "get_work_session", &args).await
396 }
397 CoordinationOp::ListSessions => {
398 let state = match text(input, "state") {
399 None => None,
400 Some(state) => match SessionState::parse(&state) {
401 Some(state) => Some(state),
402 None => return invalid(format!("state is active, idle or ended, not {state}.")),
403 },
404 };
405 let subagents = matches!(&input["subagents"], Value::Bool(true)) || input["subagents"].as_str() == Some("true");
406 let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull, subagents, path: text(input, "path") };
407 g1t_kit::call(work, "list_work_sessions", &args).await
408 }
409 CoordinationOp::RecordSessionEntries => {
410 let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] {
411 Value::Null => json!([]),
412 entries => entries.clone(),
413 }) {
414 Ok(entries) => entries,
415 Err(_) => {
416 return invalid(
417 "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(),
418 );
419 }
420 };
421 let touched: Vec<NewTouch> = match serde_json::from_value(match &input["touched"] {
422 Value::Null => json!([]),
423 touched => touched.clone(),
424 }) {
425 Ok(touched) => touched,
426 Err(_) => return invalid("touched is a list of { kind, value }, kind one of file, commit, branch, pull or issue.".to_owned()),
427 };
428 let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries, touched };
429 g1t_kit::call(work, "record_session_entries", &args).await
430 }
431 CoordinationOp::ClaimIssue => {
432 let ttl_minutes = match optional_number(input, "ttl_minutes") {
433 Ok(minutes) => minutes,
434 Err(message) => return invalid(message),
435 };
436 let args = ClaimArgs {
437 actor: actor(),
438 repo,
439 number: issue_number,
440 session_id: text(input, "session_id"),
441 note: text(input, "note"),
442 ttl_minutes,
443 };
444 g1t_kit::call(work, "claim_issue", &args).await
445 }
446 CoordinationOp::ReleaseClaim => {
447 let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") };
448 g1t_kit::call(work, "release_claim", &args).await
449 }
450 CoordinationOp::HandOffIssue => {
451 let Some(note) = text(input, "note") else {
452 return invalid("Say where the work stands in note, for whoever picks it up.".to_owned());
453 };
454 let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note, session_id: text(input, "session_id") };
455 g1t_kit::call(work, "hand_off_issue", &args).await
456 }
457 CoordinationOp::ListClaims => {
458 let state = match text(input, "state") {
459 None => None,
460 Some(state) => match ClaimState::parse(&state) {
461 Some(state) => Some(state),
462 None => return invalid(format!("state is active, released, expired or handed_off, not {state}.")),
463 },
464 };
465 let args = ListClaimsArgs { viewer: viewer.clone(), repo, state, issue };
466 g1t_kit::call(work, "list_claims", &args).await
467 }
468 CoordinationOp::GetWork => {
469 let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number };
470 g1t_kit::call(work, "get_work", &args).await
471 }
472 CoordinationOp::StartAgentSession => {
473 let mode = match text(input, "mode") {
474 None => None,
475 Some(mode) => match AgentMode::parse(&mode) {
476 Some(mode) => Some(mode),
477 None => return invalid(format!("mode is plan, implement or review, not {mode}.")),
478 },
479 };
480 if issue.is_none() && text(input, "title").is_none() {
481 return invalid("Name the issue, or give a title (and body) for a new one.".to_owned());
482 }
483 let handoff = match &input["handoff"] {
484 Value::Bool(handoff) => Some(*handoff),
485 Value::String(text) if text == "true" => Some(true),
486 Value::String(text) if text == "false" => Some(false),
487 _ => None,
488 };
489 let args = StartAgentSessionArgs {
490 actor: actor(),
491 repo,
492 issue,
493 title: text(input, "title"),
494 body: text(input, "body"),
495 instruction: text(input, "instruction"),
496 mode,
497 handoff,
498 };
499 g1t_kit::call(&services.integrations, "start_agent_session", &args).await
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::operations::Op;
508 use g1t_contracts::scopes::{Level, Scope, scope_for};
509
510 #[test]
511 fn each_operation_is_described_scoped_and_listed() {
512 for op in CoordinationOp::ALL {
513 assert!(Op::ALL.contains(&Op::Coordination(op)), "{}", op.name());
514 assert_eq!(Op::by_name(op.name()), Some(Op::Coordination(op)));
515 assert!(!op.title().is_empty() && op.description().len() > 80, "{}", op.name());
516 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
517 let scope = scope_for(op.name()).unwrap();
518 assert_eq!(scope.level() == Level::Read, op.reads(), "{}", op.name());
519 let sessions = op.name().contains("session");
520 let expected = match (sessions, op.reads()) {
521 (true, true) => Scope::PullRequestsRead,
522 (true, false) => Scope::PullRequestsWrite,
523 (false, true) => Scope::IssuesRead,
524 (false, false) => Scope::IssuesWrite,
525 };
526 assert_eq!(scope, expected, "{}", op.name());
527 }
528 }
529
530 #[test]
531 fn routes_reach_each_operation_with_its_input() {
532 let resolve = |method: &str, path: &str, query: &[(&str, &str)], body: Value| {
533 let query: Vec<(String, String)> = query.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
534 let (route, input) = crate::rest::resolve(method, path, &query, body).unwrap();
535 (route.op, input)
536 };
537 let coordination = |op| Op::Coordination(op);
538 let (op, input) = resolve("POST", "/repos/acme/web/sessions", &[], json!({ "harness": "claude_code" }));
539 assert_eq!(op, coordination(CoordinationOp::StartSession));
540 assert_eq!(input, json!({ "harness": "claude_code", "repo": "acme/web" }));
541 let (op, input) = resolve("PATCH", "/repos/acme/web/sessions/ses_1", &[], json!({}));
542 assert_eq!(op, coordination(CoordinationOp::UpdateSession));
543 assert_eq!(input, json!({ "id": "ses_1", "repo": "acme/web" }));
544 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/end", &[], json!({}));
545 assert_eq!(op, coordination(CoordinationOp::EndSession));
546 let (op, input) = resolve("GET", "/repos/acme/web/sessions/ses_1", &[("after", "40")], Value::Null);
547 assert_eq!(op, coordination(CoordinationOp::GetSession));
548 assert_eq!(input["after"], "40");
549 let (op, input) = resolve("GET", "/repos/acme/web/sessions", &[("state", "idle"), ("issue", "4")], Value::Null);
550 assert_eq!(op, coordination(CoordinationOp::ListSessions));
551 assert_eq!(input, json!({ "state": "idle", "issue": "4", "repo": "acme/web" }));
552 let (op, _) = resolve("POST", "/repos/acme/web/sessions/ses_1/entries", &[], json!({ "entries": [] }));
553 assert_eq!(op, coordination(CoordinationOp::RecordSessionEntries));
554 let (op, input) = resolve("POST", "/repos/acme/web/issues/4/claim", &[], json!({ "note": "on it" }));
555 assert_eq!(op, coordination(CoordinationOp::ClaimIssue));
556 assert_eq!(input, json!({ "note": "on it", "number": 4, "repo": "acme/web" }));
557 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/release", &[], json!({}));
558 assert_eq!(op, coordination(CoordinationOp::ReleaseClaim));
559 let (op, _) = resolve("POST", "/repos/acme/web/issues/4/handoff", &[], json!({ "note": "half done" }));
560 assert_eq!(op, coordination(CoordinationOp::HandOffIssue));
561 let (op, _) = resolve("GET", "/repos/acme/web/claims", &[("state", "active")], Value::Null);
562 assert_eq!(op, coordination(CoordinationOp::ListClaims));
563 let (op, input) = resolve("GET", "/repos/acme/web/issues/4/work", &[], Value::Null);
564 assert_eq!(op, coordination(CoordinationOp::GetWork));
565 assert_eq!(input, json!({ "number": 4, "repo": "acme/web" }));
566 }
567
568 #[test]
569 fn mcp_actions_reach_each_operation() {
570 use crate::tools::{Tool, resolve};
571 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" }));
572 assert_eq!(call("session", "start"), Ok(Op::Coordination(CoordinationOp::StartSession)));
573 assert_eq!(call("session", "update"), Ok(Op::Coordination(CoordinationOp::UpdateSession)));
574 assert_eq!(call("session", "record"), Ok(Op::Coordination(CoordinationOp::RecordSessionEntries)));
575 assert_eq!(call("issue", "claim"), Ok(Op::Coordination(CoordinationOp::ClaimIssue)));
576 assert_eq!(call("issue", "work"), Ok(Op::Coordination(CoordinationOp::GetWork)));
577 }
578
579 #[test]
580 fn numbers_come_as_numbers_digits_or_with_a_hash() {
581 assert_eq!(blocked_by(&json!({})), Ok(None));
582 assert_eq!(blocked_by(&json!({ "blocked_by": [3, "4", "#5"] })), Ok(Some(vec![3, 4, 5])));
583 assert_eq!(blocked_by(&json!({ "blocked_by": [] })), Ok(Some(vec![])));
584 assert!(blocked_by(&json!({ "blocked_by": ["x"] })).is_err());
585 assert!(blocked_by(&json!({ "blocked_by": 3 })).is_err());
586 assert_eq!(optional_number(&json!({ "issue": "" }), "issue"), Ok(None));
587 assert!(optional_number(&json!({ "issue": "soon" }), "issue").is_err());
588 }
589}