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.

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