g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.
Ask g1t reads g1t to answer: the provider is given g1t's own MCP tools and Ask runs them as the person asking, round after round, so "what shipped today across all our projects" or "why did the last Deploy fail" is answered from the workspace's real activity, runs and logs rather than the page alone. Identity makes a credential for each answer (create_ask_token): the person's own token, read scopes only, made for the one workspace, five minutes at most, never listed, never shown to the model or the browser, and revoked when the answer ends; a workspace's rules for personal tokens skip it (migration 0048), revoking still ends it. Integrations is an MCP client of g1t's own server (MCP_URL), so the tools are exactly those any agent gets, filtered for that credential, and every call passes the API's own permission checks, audit log and rate limits; Ask also refuses any tool or action that is not a read before calling it. A policy step decides each call (read runs, a dispatch such as starting a session waits for the person's confirmation, anything else is refused), and g1t-native tools have a registry. An answer takes at most 10 rounds, 30 calls and 2 minutes of tools, gives the model at most 16,000 characters of a result and 120,000 in all, trimming long lists and texts with a note (a job's log keeps its end), then answers with what it has. Both Anthropic's Messages API and OpenAI's Chat Completions get the tools and the rounds of calls in their own shapes, with Anthropic's tools and system prompt cached between rounds. The stream says what each step does as it starts and ends and which pages on g1t the answer drew on; chat keeps each step once, as it last was, and the answer shows the steps as compact rows that fold away once it is written, with its sources under it, in the panel and in Chat alike. The system prompt names who is asking, the workspace, today's date and the page, tells the model to read before saying it cannot see something, starts workspace questions with the workspace's activity, finds failures through a run's jobs and its log's tail, reads a project's own files for how it builds and tests, hands work off to the person's own harness instead of doing it, keeps fetched material as data, and requires every number, title and link to come from what a tool returned. New operations, on REST, MCP and the CLI: get_workspace_activity (GET /workspaces/{workspace}/activity: merged pull requests, releases, deployments, finished workflow runs, pushes, issues and reviews across every repository you can read, with links and a summary of what shipped), get_contents (GET /repos/{owner}/{name}/contents: a file's text or a directory's entries and README) and list_commits (GET /repos/{owner}/{name}/commits, with the commit that last changed a path); get_job_logs takes tail for a log's last chunks, and the events service lists many repositories' events at once. The guide says what Ask can read, the steps and sources it shows, how it hands work off, and its limits. Tested with unit tests for both providers' tool requests and streams, the loop's limits, result trimming, the policy, step labels and sources, the events' wire shapes, the credential's read-only scopes and its exemption, and the new operations; cargo check for the workspace, the web typecheck, every workspace's tests and the docs build. Ask g1t and your direct message with @g1t in Chat are one conversation: g1t heads everyone's direct messages with its mark, and whatever you write to it there, or ask from the top bar on any page (with that page), it answers with the AI provider and model an owner chose for the workspace, streamed live into the direct message so the answer shows in the panel, in Chat and on the phone at once. The chat service writes g1t's answer as an empty message that is thinking, and the conversation's room asks the integrations service from an alarm, so a long answer is never cut short. It reads the direct message since the latest New conversation mark, as alternating turns within Ask's limits, keeps steps apart from the text, saves as it goes, and ends with a normal stored message that notifies like any direct message. Writing again while g1t answers stops it and answers both; Stop keeps what came, Answer again rewrites the latest answer, and twenty answers a minute per person is enforced in the room (the site's own Ask route and its rate limit are gone). With no provider chosen, g1t replies with where an owner chooses one; any failure is a short message from g1t, never silence. g1t's own services can post and edit as @g1t at chat's internal routes, which refuse anything from the internet. Answers are drawn with Streamdown inside AI Elements' conversation, message and suggestion pieces in g1t's colours: half-written Markdown reads cleanly as it streams, code has its file or language and Copy, tables scroll, task lists tick, issues, pull requests, commits and people are chips with their cards on hover (people's messages use the same chips), artifacts show their cards, and pictures load only from g1t. Chat migration 0005 adds the message meta column. Tested: chat's unit tests for the direct message with g1t, what starts an answer, the conversation sent, the not-set-up reply, streaming, stopping and the internal routes' refusal; the site's tests for live answers, suggestions and links; typecheck across the workspaces; and the panel and the direct message drawn in a browser, dark and light, wide and phone-sized. Ask hands work off rather than doing it: propose_session, its one g1t-native tool, is a dispatch that never runs from the model. The answer carries a proposal (the repository, the issue or a new one's title and body, plan, implement or review, and an instruction), drawn as a card under it with Start session, which starts it on the workspace's Anthropic account when one is connected, and Copy Claude Code command, which gives the command to run it with the g1t plugin; the person's click is the confirmation, through POST -/ask. The system prompt tells the model to read the issue and its work first, link a session that already holds it instead of proposing another, and split large work into a few issues; who is working on what is read from sessions, claims and the issue's work before pull requests. An answer proposes at most five sessions. Sessions show their subagents, the issues and pull requests they worked on, and what they touched, and work on an issue can be started in a session on the workspace's own Claude. Each subagent is a session of its own under the one that started it (parent_id, agent_type, its task and prompt, and its final message as result): starting one is idempotent on the parent's harness id and the subagent's id, it works on its parent's issue and branch unless it says otherwise, once ended it stays ended, its heartbeats keep its parent seen, and ending a session ends the subagents it left going. Sessions record the skills they use by name and what they touched (files, commits, branches, pull requests opened, issues named); a file two active sessions touched, siblings included, is an overlap.detected for both, once per pair, and claims are told apart by session so siblings each hold their own and see each other's. g1t ties each session to the issues and pull requests it worked on by every way it was (attached, claim, opened, branch, commit, a G1t-Session trailer on a pushed commit, #N in its prompt, task or result); get_session gives the tree, touches, links and skills, get_work and list_sessions list subagents and every tied session, and list_sessions takes path and subagents (work migration 0036). start_agent_session (POST /repos/{owner}/{name}/agent-sessions, the session tool's dispatch, g1t work dispatch) starts a Claude Managed Agents session on the workspace's Anthropic connection: one agent and environment kept on the account, g1t's skills uploaded beside the account's own, g1t's MCP server and G1T_TOKEN held in a per-session vault with a token for that person and repository only, revoked when it ends; its events, threads and result are followed every minute into a g1t session (harness claude_managed_agents, integrations migration 0009 and a cron), calls waiting for approval are declined, and without a connection it hands off a claude command for the plugin instead. g1t bills nothing for it. The Claude Code plugin (0.2.0) records each subagent from SubagentStart, SubagentStop, PreToolUse and PostToolUse, gives subagents their own session for their g1t commands and claims, records skills and touches, and attaches a hand-off's session to its issue. Git over HTTP takes a g1t token as Bearer. The site has a session page (who, harness, model, state, the issues and pull requests it is tied to and how, its subagent tree with tasks, results and what each touched, skills, commits, and its timeline, live, with Stop for a managed session), session trees on issues and pull requests, and a Start a session dialog on an open issue when Anthropic is connected.
- Tested:
- unit tests for both providers' tool requests and streams, the loop's limits and trimming, the read-only policy and proposals, the credential's scopes, the new operations, the chat answer relay, streaming, stopping and the internal routes' refusal, hook payload parsing, twelve subagents on one issue seeing each other and their overlaps, linking, and the Managed Agents requests and event mapping; one live check against Anthropic created and removed an environment, agent, vault, credentials, session and skill with these shapes. cargo test for the workspace (2,503 passing), the TypeScript typecheck, every workspace's tests (1,742 passing), the plugin's hook tests and the docs build; the answer, its steps, sources and proposal cards drawn in a browser, dark and light. The OpenAPI document, the CLI's operations and the CLI reference are regenerated.
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.1.0", | |
| 10 | + | "version": "0.2.0", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 1373 | 1373 | dependencies = [ | |
| 1374 | 1374 | "base64 0.22.1", | |
| 1375 | 1375 | "form_urlencoded", | |
| 1376 | + | "futures-util", | |
| 1376 | 1377 | "g1t-contracts", | |
| 1377 | 1378 | "g1t-kit", | |
| 1378 | 1379 | "serde", |
| 17 | 17 | base64 = "0.22" | |
| 18 | 18 | sha2 = "0.10" | |
| 19 | 19 | form_urlencoded = "1" | |
| 20 | + | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 20 | 21 | ||
| 21 | 22 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 22 | 23 | # time. |
| 1 | + | //! `get_workspace_activity`: what happened across a workspace's | |
| 2 | + | //! repositories over a span, newest first, as one list a person (or Ask | |
| 3 | + | //! g1t, or an agent) can read: pull requests merged and opened, issues | |
| 4 | + | //! opened and closed, releases, deployments and pushes, each with its | |
| 5 | + | //! repository, who did it, its title where it has one and its address on | |
| 6 | + | //! the site. "What shipped today?" is one call. | |
| 7 | + | //! | |
| 8 | + | //! The repositories are those of the workspace the caller can read, as the | |
| 9 | + | //! repos service lists them for them; the events service reads their events | |
| 10 | + | //! as one list (`repo_ids`). Pull requests' titles, which their events do | |
| 11 | + | //! not carry, come from the work service's newest pull requests of the | |
| 12 | + | //! repositories that had any; people's names from identity. Everything | |
| 13 | + | //! after the reads is [`shape`], a plain function, tested on its own. | |
| 14 | + | ||
| 15 | + | use std::collections::{BTreeSet, HashMap}; | |
| 16 | + | ||
| 17 | + | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs, MAX_LIST_REPOS}; | |
| 18 | + | use g1t_contracts::identity::UsernamesArgs; | |
| 19 | + | use g1t_contracts::repos::{ListArgs as ListReposArgs, Repo}; | |
| 20 | + | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 21 | + | use g1t_contracts::work::{MAX_PULLS_FOR_REPOS, PullsForReposArgs, RepoPulls}; | |
| 22 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 23 | + | use serde_json::{Value, json}; | |
| 24 | + | use worker::Result; | |
| 25 | + | ||
| 26 | + | use crate::operations::Services; | |
| 27 | + | ||
| 28 | + | /// The span when none is given: the last day. | |
| 29 | + | const DEFAULT_SPAN_MS: u64 = 24 * 60 * 60 * 1000; | |
| 30 | + | /// The longest span one call reads. | |
| 31 | + | const MAX_SPAN_MS: u64 = 31 * 24 * 60 * 60 * 1000; | |
| 32 | + | pub const DEFAULT_LIMIT: u32 = 100; | |
| 33 | + | pub const MAX_LIMIT: u32 = 200; | |
| 34 | + | ||
| 35 | + | /// The kinds a caller may ask for, and the events each is. | |
| 36 | + | pub const KINDS: &[(&str, &[&str])] = &[ | |
| 37 | + | ("merged", &["pull.merged"]), | |
| 38 | + | ("pulls", &["pull.opened", "pull.merged", "pull.closed"]), | |
| 39 | + | ("issues", &["issue.opened", "issue.closed"]), | |
| 40 | + | ("releases", &["release.published"]), | |
| 41 | + | ("deployments", &["deployment.succeeded", "deployment.failed"]), | |
| 42 | + | ("pushes", &["git.push"]), | |
| 43 | + | ("reviews", &["review.completed"]), | |
| 44 | + | ("comments", &["comment.created"]), | |
| 45 | + | ("packages", &["package.published"]), | |
| 46 | + | ("runs", &["workflow.completed"]), | |
| 47 | + | ]; | |
| 48 | + | ||
| 49 | + | /// What is read when no kinds are given: everything but comments, which | |
| 50 | + | /// would crowd out the rest on a busy day. | |
| 51 | + | const DEFAULT_KINDS: &[&str] = &["pulls", "issues", "releases", "deployments", "pushes", "reviews", "packages", "runs"]; | |
| 52 | + | ||
| 53 | + | pub fn description() -> &'static str { | |
| 54 | + | "What happened across a workspace's repositories that you can read, newest first: pull requests merged, opened and closed, issues opened and closed, releases published, deployments that succeeded or failed, pushes (with `default_branch` true for what landed on the default branch without a pull request), reviews, packages, and workflow runs that finished (`workflow`, `run_number`, `conclusion`, `run_id` for workflow get_run). Each event has its `kind`, `time`, `repo` (owner/name), `actor` (a username), and where it has them its `number`, `title`, `url` (its page on g1t), `branch`, `commit`, `commits`, `tag` and `error`. `summary` counts merged pull requests, releases, production deployments, default-branch pushes and failed runs: what shipped, and what broke. The span is `since` to `until` (RFC 3339; the last 24 hours when `since` is left out, at most 31 days); narrow it with `kinds` (merged, pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs; all but comments when left out) and `limit` (100 unless you say, at most 200). `complete` is false when more happened than was read: narrow the span or the kinds. For one repository's full timeline use list_events." | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | pub fn input() -> Value { | |
| 58 | + | json!({ | |
| 59 | + | "type": "object", | |
| 60 | + | "properties": { | |
| 61 | + | "workspace": { "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }, | |
| 62 | + | "since": { "type": "string", "description": "RFC 3339: the start of the span, inclusive. The last 24 hours when left out; for \"today\", midnight UTC." }, | |
| 63 | + | "until": { "type": "string", "description": "RFC 3339: the end of the span, exclusive. Now when left out." }, | |
| 64 | + | "kinds": { | |
| 65 | + | "type": "array", | |
| 66 | + | "items": { "type": "string", "enum": KINDS.iter().map(|(name, _)| *name).collect::<Vec<_>>() }, | |
| 67 | + | "description": "What to read: merged (merged pull requests only), pulls, issues, releases, deployments, pushes, reviews, comments, packages, runs (workflow runs that finished). All but comments when left out.", | |
| 68 | + | }, | |
| 69 | + | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "The most events to return, newest first: 100 unless you say, at most 200." }, | |
| 70 | + | }, | |
| 71 | + | "required": ["workspace"], | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// The span asked for, in milliseconds, or why it cannot be read. | |
| 76 | + | pub fn span(input: &Value, now: u64) -> std::result::Result<(u64, u64), String> { | |
| 77 | + | let at = |key: &str| -> std::result::Result<Option<u64>, String> { | |
| 78 | + | match input.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) { | |
| 79 | + | None => Ok(None), | |
| 80 | + | Some(text) => parse_rfc3339(text).map(Some).ok_or_else(|| format!("{key} must be an RFC 3339 time, such as 2026-10-12T00:00:00Z.")), | |
| 81 | + | } | |
| 82 | + | }; | |
| 83 | + | let until = at("until")?.unwrap_or(now).min(now); | |
| 84 | + | let since = at("since")?.unwrap_or(until.saturating_sub(DEFAULT_SPAN_MS)); | |
| 85 | + | if since >= until { | |
| 86 | + | return Err("since must be before until.".to_owned()); | |
| 87 | + | } | |
| 88 | + | if until - since > MAX_SPAN_MS { | |
| 89 | + | return Err("A span can be at most 31 days. Narrow since and until.".to_owned()); | |
| 90 | + | } | |
| 91 | + | Ok((since, until)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// The event types the `kinds` asked for read, or the unknown kind. | |
| 95 | + | pub fn event_types(input: &Value) -> std::result::Result<Vec<&'static str>, String> { | |
| 96 | + | // An array, or over REST a comma-separated list. | |
| 97 | + | let asked: Vec<String> = match input.get("kinds") { | |
| 98 | + | Some(Value::Array(kinds)) => kinds.iter().filter_map(Value::as_str).map(str::to_owned).collect(), | |
| 99 | + | Some(Value::String(kinds)) => kinds.split(',').map(str::to_owned).collect(), | |
| 100 | + | _ => Vec::new(), | |
| 101 | + | } | |
| 102 | + | .into_iter() | |
| 103 | + | .map(|kind| kind.trim().to_lowercase()) | |
| 104 | + | .filter(|kind| !kind.is_empty()) | |
| 105 | + | .collect(); | |
| 106 | + | let names: Vec<&str> = if asked.is_empty() { DEFAULT_KINDS.to_vec() } else { asked.iter().map(String::as_str).collect() }; | |
| 107 | + | let mut types = BTreeSet::new(); | |
| 108 | + | for name in names { | |
| 109 | + | let Some((_, kinds)) = KINDS.iter().find(|(kind, _)| *kind == name) else { | |
| 110 | + | let known: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect(); | |
| 111 | + | return Err(format!("There is no kind {name}. Kinds: {}.", known.join(", "))); | |
| 112 | + | }; | |
| 113 | + | types.extend(kinds.iter().copied()); | |
| 114 | + | } | |
| 115 | + | Ok(types.into_iter().collect()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The most events asked for: a number, or over REST its text. | |
| 119 | + | pub fn limit(input: &Value) -> u32 { | |
| 120 | + | let asked = match input.get("limit") { | |
| 121 | + | Some(Value::String(text)) => text.trim().parse::<u64>().ok(), | |
| 122 | + | Some(value) => value.as_u64(), | |
| 123 | + | None => None, | |
| 124 | + | }; | |
| 125 | + | asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> { | |
| 129 | + | data.get(key).and_then(Value::as_str).filter(|text| !text.is_empty()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// What the reads found, shaped into the answer. | |
| 133 | + | pub struct Found<'a> { | |
| 134 | + | pub workspace: &'a str, | |
| 135 | + | pub site: &'a str, | |
| 136 | + | pub since: u64, | |
| 137 | + | pub until: u64, | |
| 138 | + | pub repos: &'a [Repo], | |
| 139 | + | /// Newest first, as the events service lists them. | |
| 140 | + | pub events: &'a [Event], | |
| 141 | + | /// Whether the events service had more than was read. | |
| 142 | + | pub more: bool, | |
| 143 | + | /// `(repo id, number)` to a pull request's title. | |
| 144 | + | pub pull_titles: &'a HashMap<(String, u32), String>, | |
| 145 | + | /// Account id to username. | |
| 146 | + | pub usernames: &'a HashMap<String, String>, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | pub fn shape(found: &Found) -> Value { | |
| 150 | + | let site = found.site.trim_end_matches('/'); | |
| 151 | + | let by_id: HashMap<&str, &Repo> = found.repos.iter().map(|repo| (repo.id.as_str(), repo)).collect(); | |
| 152 | + | let mut events = Vec::new(); | |
| 153 | + | let (mut merged, mut releases, mut production, mut landed, mut failed) = (0u32, 0u32, 0u32, 0u32, 0u32); | |
| 154 | + | let mut runs_failed = 0u32; | |
| 155 | + | for event in found.events { | |
| 156 | + | let Some(repo) = event.repo_id.as_deref().and_then(|id| by_id.get(id)) else { | |
| 157 | + | continue; | |
| 158 | + | }; | |
| 159 | + | let at = parse_rfc3339(&event.time).unwrap_or_default(); | |
| 160 | + | if at < found.since || at >= found.until { | |
| 161 | + | continue; | |
| 162 | + | } | |
| 163 | + | let full = format!("{}/{}", repo.namespace, repo.name); | |
| 164 | + | let page = format!("{site}/{full}"); | |
| 165 | + | let data = &event.data; | |
| 166 | + | let number = data.get("number").and_then(Value::as_u64).map(|n| n as u32); | |
| 167 | + | let actor = event | |
| 168 | + | .actor | |
| 169 | + | .as_deref() | |
| 170 | + | .and_then(|id| found.usernames.get(id).cloned()) | |
| 171 | + | .or_else(|| data.get("author").and_then(|author| text(author, "username")).map(str::to_owned)) | |
| 172 | + | .or_else(|| text(data, "triggeredBy").map(str::to_owned)); | |
| 173 | + | let mut shown = json!({ "kind": event.kind.replace('.', "_"), "time": event.time, "repo": full }); | |
| 174 | + | if let Some(actor) = actor { | |
| 175 | + | shown["actor"] = json!(actor); | |
| 176 | + | } | |
| 177 | + | match event.kind.as_str() { | |
| 178 | + | "pull.opened" | "pull.merged" | "pull.closed" => { | |
| 179 | + | let Some(number) = number else { continue }; | |
| 180 | + | shown["number"] = json!(number); | |
| 181 | + | if let Some(title) = found.pull_titles.get(&(repo.id.clone(), number)) { | |
| 182 | + | shown["title"] = json!(title); | |
| 183 | + | } | |
| 184 | + | shown["url"] = json!(format!("{page}/pull/{number}")); | |
| 185 | + | if let Some(issue) = data.get("issue").and_then(Value::as_u64) { | |
| 186 | + | shown["issue"] = json!(issue); | |
| 187 | + | } | |
| 188 | + | if event.kind == "pull.merged" { | |
| 189 | + | merged += 1; | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | "issue.opened" | "issue.closed" => { | |
| 193 | + | let Some(number) = number else { continue }; | |
| 194 | + | shown["number"] = json!(number); | |
| 195 | + | if let Some(title) = text(data, "title") { | |
| 196 | + | shown["title"] = json!(title); | |
| 197 | + | } | |
| 198 | + | if let Some(reason) = text(data, "reason") { | |
| 199 | + | shown["reason"] = json!(reason); | |
| 200 | + | } | |
| 201 | + | if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) { | |
| 202 | + | shown["resolved_by"] = json!(pull); | |
| 203 | + | } | |
| 204 | + | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 205 | + | } | |
| 206 | + | "release.published" => { | |
| 207 | + | let tag = text(data, "tagName").unwrap_or_default(); | |
| 208 | + | shown["tag"] = json!(tag); | |
| 209 | + | if let Some(name) = data.get("release").and_then(|release| text(release, "name")) { | |
| 210 | + | shown["title"] = json!(name); | |
| 211 | + | } | |
| 212 | + | shown["url"] = json!(format!("{page}/releases/tag/{tag}")); | |
| 213 | + | releases += 1; | |
| 214 | + | } | |
| 215 | + | "deployment.succeeded" | "deployment.failed" => { | |
| 216 | + | let kind = text(data, "kind").unwrap_or("preview"); | |
| 217 | + | shown["environment"] = json!(kind); | |
| 218 | + | if let Some(project) = text(data, "project") { | |
| 219 | + | shown["project"] = json!(project); | |
| 220 | + | } | |
| 221 | + | if let Some(branch) = text(data, "branch") { | |
| 222 | + | shown["branch"] = json!(branch); | |
| 223 | + | } | |
| 224 | + | if let Some(commit) = text(data, "commit") { | |
| 225 | + | shown["commit"] = json!(commit.chars().take(12).collect::<String>()); | |
| 226 | + | } | |
| 227 | + | if let Some(error) = text(data, "error") { | |
| 228 | + | shown["error"] = json!(error.chars().take(300).collect::<String>()); | |
| 229 | + | } | |
| 230 | + | shown["url"] = json!(match text(data, "path") { | |
| 231 | + | Some(path) => format!("{site}{path}"), | |
| 232 | + | None => format!("{page}/deployments"), | |
| 233 | + | }); | |
| 234 | + | if event.kind == "deployment.succeeded" && kind == "production" { | |
| 235 | + | production += 1; | |
| 236 | + | } | |
| 237 | + | if event.kind == "deployment.failed" { | |
| 238 | + | failed += 1; | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | "git.push" => { | |
| 242 | + | // Branches only: a tag's push is its release. | |
| 243 | + | let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/")) else { continue }; | |
| 244 | + | let default_branch = data.get("defaultBranch").and_then(Value::as_bool) == Some(true); | |
| 245 | + | shown["branch"] = json!(branch); | |
| 246 | + | shown["default_branch"] = json!(default_branch); | |
| 247 | + | if let Some(commits) = data.get("commits").and_then(Value::as_u64) { | |
| 248 | + | shown["commits"] = json!(commits); | |
| 249 | + | } | |
| 250 | + | if let Some(after) = text(data, "after") { | |
| 251 | + | shown["commit"] = json!(after.chars().take(12).collect::<String>()); | |
| 252 | + | shown["url"] = json!(format!("{page}/commit/{after}")); | |
| 253 | + | } | |
| 254 | + | if default_branch { | |
| 255 | + | landed += 1; | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | "review.completed" | "comment.created" => { | |
| 259 | + | if let Some(number) = number.or_else(|| data.get("issue").and_then(Value::as_u64).map(|n| n as u32)) { | |
| 260 | + | shown["number"] = json!(number); | |
| 261 | + | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 262 | + | } | |
| 263 | + | if let Some(verdict) = text(data, "verdict") { | |
| 264 | + | shown["verdict"] = json!(verdict); | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | "workflow.completed" => { | |
| 268 | + | let conclusion = text(data, "conclusion").unwrap_or_default(); | |
| 269 | + | if let Some(workflow) = text(data, "workflow") { | |
| 270 | + | shown["workflow"] = json!(workflow); | |
| 271 | + | } | |
| 272 | + | if let Some(run_number) = data.get("number").and_then(Value::as_u64) { | |
| 273 | + | shown["run_number"] = json!(run_number); | |
| 274 | + | } | |
| 275 | + | shown["conclusion"] = json!(conclusion); | |
| 276 | + | if let Some(branch) = text(data, "ref").map(|r| r.strip_prefix("refs/heads/").unwrap_or(r)) { | |
| 277 | + | shown["branch"] = json!(branch); | |
| 278 | + | } | |
| 279 | + | if let Some(sha) = text(data, "sha") { | |
| 280 | + | shown["commit"] = json!(sha.chars().take(12).collect::<String>()); | |
| 281 | + | } | |
| 282 | + | if let Some(pull) = data.get("pull").and_then(Value::as_u64) { | |
| 283 | + | shown["pull"] = json!(pull); | |
| 284 | + | } | |
| 285 | + | if let Some(run) = text(data, "runId") { | |
| 286 | + | shown["run_id"] = json!(run); | |
| 287 | + | shown["url"] = json!(format!("{page}/actions/runs/{run}")); | |
| 288 | + | } | |
| 289 | + | if conclusion == "failure" { | |
| 290 | + | runs_failed += 1; | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | "package.published" => { | |
| 294 | + | for key in ["ecosystem", "name", "version"] { | |
| 295 | + | if let Some(value) = text(data, key) { | |
| 296 | + | shown[key] = json!(value); | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | _ => continue, | |
| 301 | + | } | |
| 302 | + | events.push(shown); | |
| 303 | + | } | |
| 304 | + | json!({ | |
| 305 | + | "workspace": found.workspace, | |
| 306 | + | "since": rfc3339(found.since), | |
| 307 | + | "until": rfc3339(found.until), | |
| 308 | + | "repositories": found.repos.len(), | |
| 309 | + | "summary": { | |
| 310 | + | "merged_pull_requests": merged, | |
| 311 | + | "releases": releases, | |
| 312 | + | "production_deployments": production, | |
| 313 | + | "failed_deployments": failed, | |
| 314 | + | "default_branch_pushes": landed, | |
| 315 | + | "failed_runs": runs_failed, | |
| 316 | + | }, | |
| 317 | + | "events": events, | |
| 318 | + | "complete": !found.more && found.repos.len() <= MAX_LIST_REPOS, | |
| 319 | + | }) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | pub async fn run(services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 323 | + | let workspace = input["workspace"].as_str().unwrap_or_default().trim().to_lowercase(); | |
| 324 | + | if workspace.is_empty() { | |
| 325 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug.")); | |
| 326 | + | } | |
| 327 | + | let (since, until) = match span(input, g1t_kit::now_ms()) { | |
| 328 | + | Ok(span) => span, | |
| 329 | + | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 330 | + | }; | |
| 331 | + | let types = match event_types(input) { | |
| 332 | + | Ok(types) => types, | |
| 333 | + | Err(problem) => return Ok(Outcome::fail(FailureCode::Invalid, problem)), | |
| 334 | + | }; | |
| 335 | + | let limit = limit(input); | |
| 336 | + | // The workspace's repositories as the caller may read them; a | |
| 337 | + | // workspace they cannot see has none. | |
| 338 | + | let repos: Vec<Repo> = g1t_kit::call( | |
| 339 | + | &services.repos, | |
| 340 | + | "list", | |
| 341 | + | &ListReposArgs { viewer: viewer.clone(), query: None, namespace: Some(workspace.clone()), member_only: false }, | |
| 342 | + | ) | |
| 343 | + | .await?; | |
| 344 | + | let repos: Vec<Repo> = repos.into_iter().filter(|repo| repo.namespace.eq_ignore_ascii_case(&workspace)).collect(); | |
| 345 | + | let site = services.addresses.site.clone(); | |
| 346 | + | let none = (HashMap::new(), HashMap::new()); | |
| 347 | + | if repos.is_empty() { | |
| 348 | + | return Ok(Outcome::Ok(shape(&Found { | |
| 349 | + | workspace: &workspace, | |
| 350 | + | site: &site, | |
| 351 | + | since, | |
| 352 | + | until, | |
| 353 | + | repos: &repos, | |
| 354 | + | events: &[], | |
| 355 | + | more: false, | |
| 356 | + | pull_titles: &none.0, | |
| 357 | + | usernames: &none.1, | |
| 358 | + | }))); | |
| 359 | + | } | |
| 360 | + | let events: Vec<Event> = g1t_kit::call( | |
| 361 | + | &services.events, | |
| 362 | + | "list", | |
| 363 | + | &ListEventsArgs { | |
| 364 | + | repo_ids: repos.iter().take(MAX_LIST_REPOS).map(|repo| repo.id.clone()).collect(), | |
| 365 | + | types: types.iter().map(|kind| (*kind).to_owned()).collect(), | |
| 366 | + | since: Some(rfc3339(since)), | |
| 367 | + | // One past the limit says whether there was more. | |
| 368 | + | limit: Some(limit + 1), | |
| 369 | + | ..ListEventsArgs::default() | |
| 370 | + | }, | |
| 371 | + | ) | |
| 372 | + | .await?; | |
| 373 | + | let more = events.len() > limit as usize; | |
| 374 | + | let events: Vec<Event> = events.into_iter().take(limit as usize).collect(); | |
| 375 | + | // Titles of the pull requests named, and the names of who did what. | |
| 376 | + | let pull_repos: Vec<String> = events | |
| 377 | + | .iter() | |
| 378 | + | .filter(|event| event.kind.starts_with("pull.")) | |
| 379 | + | .filter_map(|event| event.repo_id.clone()) | |
| 380 | + | .collect::<BTreeSet<_>>() | |
| 381 | + | .into_iter() | |
| 382 | + | .take(MAX_PULLS_FOR_REPOS) | |
| 383 | + | .collect(); | |
| 384 | + | let actors: Vec<String> = events.iter().filter_map(|event| event.actor.clone()).collect::<BTreeSet<_>>().into_iter().collect(); | |
| 385 | + | let pulls = async { | |
| 386 | + | if pull_repos.is_empty() { | |
| 387 | + | return Ok(Vec::new()); | |
| 388 | + | } | |
| 389 | + | g1t_kit::call::<_, Vec<RepoPulls>>( | |
| 390 | + | &services.work, | |
| 391 | + | "pulls_for_repos", | |
| 392 | + | &PullsForReposArgs { repo_ids: pull_repos, viewer: viewer.clone(), limit: 100 }, | |
| 393 | + | ) | |
| 394 | + | .await | |
| 395 | + | }; | |
| 396 | + | let names = async { | |
| 397 | + | if actors.is_empty() { | |
| 398 | + | return Ok(HashMap::new()); | |
| 399 | + | } | |
| 400 | + | g1t_kit::call::<_, HashMap<String, String>>(&services.identity, "usernames", &UsernamesArgs { ids: actors }).await | |
| 401 | + | }; | |
| 402 | + | let (pulls, names) = futures_util::future::join(pulls, names).await; | |
| 403 | + | // Without titles or names the events still say what happened. | |
| 404 | + | let mut pull_titles = HashMap::new(); | |
| 405 | + | for repo in pulls.unwrap_or_default() { | |
| 406 | + | for pull in repo.open.into_iter().chain(repo.closed) { | |
| 407 | + | pull_titles.insert((repo.repo_id.clone(), pull.number), pull.title); | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | let usernames = names.unwrap_or_default(); | |
| 411 | + | Ok(Outcome::Ok(shape(&Found { | |
| 412 | + | workspace: &workspace, | |
| 413 | + | site: &site, | |
| 414 | + | since, | |
| 415 | + | until, | |
| 416 | + | repos: &repos, | |
| 417 | + | events: &events, | |
| 418 | + | more, | |
| 419 | + | pull_titles: &pull_titles, | |
| 420 | + | usernames: &usernames, | |
| 421 | + | }))) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | #[cfg(test)] | |
| 425 | + | mod tests { | |
| 426 | + | use super::*; | |
| 427 | + | ||
| 428 | + | fn repo(id: &str, name: &str) -> Repo { | |
| 429 | + | serde_json::from_value(json!({ | |
| 430 | + | "id": id, "namespace": "acme", "name": name, "description": null, "isPrivate": false, | |
| 431 | + | "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z", | |
| 432 | + | })) | |
| 433 | + | .unwrap() | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | fn event(kind: &str, repo: &str, time: &str, actor: &str, data: Value) -> Event { | |
| 437 | + | Event { | |
| 438 | + | id: format!("evt_{time}"), | |
| 439 | + | kind: kind.into(), | |
| 440 | + | source: "test".into(), | |
| 441 | + | time: time.into(), | |
| 442 | + | repo_id: Some(repo.into()), | |
| 443 | + | actor: Some(actor.into()), | |
| 444 | + | data, | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn the_span_is_the_last_day_unless_given() { | |
| 450 | + | let now = parse_rfc3339("2026-10-12T18:00:00Z").unwrap(); | |
| 451 | + | let (since, until) = span(&json!({}), now).unwrap(); | |
| 452 | + | assert_eq!(until, now); | |
| 453 | + | assert_eq!(until - since, DEFAULT_SPAN_MS); | |
| 454 | + | let (since, _) = span(&json!({ "since": "2026-10-12T00:00:00Z" }), now).unwrap(); | |
| 455 | + | assert_eq!(since, parse_rfc3339("2026-10-12T00:00:00Z").unwrap()); | |
| 456 | + | assert!(span(&json!({ "since": "yesterday" }), now).unwrap_err().contains("RFC 3339")); | |
| 457 | + | assert!(span(&json!({ "since": "2026-10-13T00:00:00Z" }), now).is_err()); | |
| 458 | + | assert!(span(&json!({ "since": "2026-08-01T00:00:00Z" }), now).unwrap_err().contains("31 days")); | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | #[test] | |
| 462 | + | fn kinds_name_their_events() { | |
| 463 | + | assert_eq!(event_types(&json!({ "kinds": ["merged"] })).unwrap(), vec!["pull.merged"]); | |
| 464 | + | let all = event_types(&json!({})).unwrap(); | |
| 465 | + | assert!(all.contains(&"pull.merged") && all.contains(&"git.push") && all.contains(&"release.published")); | |
| 466 | + | assert!(!all.contains(&"comment.created")); | |
| 467 | + | assert!(event_types(&json!({ "kinds": ["shipped"] })).unwrap_err().contains("Kinds: merged")); | |
| 468 | + | // Over REST: one comma-separated text. | |
| 469 | + | assert_eq!(event_types(&json!({ "kinds": "merged, releases" })).unwrap(), vec!["pull.merged", "release.published"]); | |
| 470 | + | assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT); | |
| 471 | + | assert_eq!(limit(&json!({ "limit": 5 })), 5); | |
| 472 | + | assert_eq!(limit(&json!({})), DEFAULT_LIMIT); | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | #[test] | |
| 476 | + | fn what_shipped_reads_as_one_list_with_links_and_names() { | |
| 477 | + | let repos = [repo("rep_1", "web"), repo("rep_2", "api")]; | |
| 478 | + | let events = [ | |
| 479 | + | event("deployment.succeeded", "rep_1", "2026-10-12T15:00:00Z", "usr_1", json!({ "kind": "production", "project": "web", "commit": "abcdef1234567890", "path": "/acme/web/deployments/dpl_1", "triggeredBy": "ana" })), | |
| 480 | + | event("release.published", "rep_2", "2026-10-12T14:00:00Z", "usr_2", json!({ "tagName": "v1.2.0", "release": { "name": "Faster pushes" } })), | |
| 481 | + | event("pull.merged", "rep_1", "2026-10-12T13:00:00Z", "usr_1", json!({ "number": 7, "issue": 3 })), | |
| 482 | + | event("git.push", "rep_2", "2026-10-12T12:00:00Z", "usr_2", json!({ "ref": "refs/heads/main", "after": "0123456789abcdef", "defaultBranch": true, "commits": 2 })), | |
| 483 | + | event("git.push", "rep_2", "2026-10-12T11:00:00Z", "usr_2", json!({ "ref": "refs/tags/v1.2.0", "after": "0123" })), | |
| 484 | + | event("workflow.completed", "rep_1", "2026-10-12T11:30:00Z", "usr_1", json!({ "runId": "run_257", "workflow": "Deploy", "number": 257, "conclusion": "failure", "ref": "refs/heads/main", "sha": "fedcba9876543210" })), | |
| 485 | + | event("issue.closed", "rep_1", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 3, "title": "Login fails", "reason": "completed", "resolvedBy": 7 })), | |
| 486 | + | // Outside the span, and a repository the caller cannot read. | |
| 487 | + | event("pull.merged", "rep_1", "2026-10-11T10:00:00Z", "usr_1", json!({ "number": 6 })), | |
| 488 | + | event("pull.merged", "rep_9", "2026-10-12T10:00:00Z", "usr_1", json!({ "number": 1 })), | |
| 489 | + | ]; | |
| 490 | + | let titles = HashMap::from([(("rep_1".to_owned(), 7), "Fix login".to_owned())]); | |
| 491 | + | let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned()), ("usr_2".to_owned(), "bo".to_owned())]); | |
| 492 | + | let shown = shape(&Found { | |
| 493 | + | workspace: "acme", | |
| 494 | + | site: "https://g1t.sh/", | |
| 495 | + | since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(), | |
| 496 | + | until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(), | |
| 497 | + | repos: &repos, | |
| 498 | + | events: &events, | |
| 499 | + | more: false, | |
| 500 | + | pull_titles: &titles, | |
| 501 | + | usernames: &names, | |
| 502 | + | }); | |
| 503 | + | assert_eq!( | |
| 504 | + | shown["summary"], | |
| 505 | + | json!({ "merged_pull_requests": 1, "releases": 1, "production_deployments": 1, "failed_deployments": 0, "default_branch_pushes": 1, "failed_runs": 1 }) | |
| 506 | + | ); | |
| 507 | + | let listed = shown["events"].as_array().unwrap(); | |
| 508 | + | assert_eq!(listed.len(), 6, "{listed:#?}"); | |
| 509 | + | assert_eq!(listed[0]["url"], "https://g1t.sh/acme/web/deployments/dpl_1"); | |
| 510 | + | assert_eq!(listed[0]["commit"], "abcdef123456"); | |
| 511 | + | assert_eq!(listed[1]["title"], "Faster pushes"); | |
| 512 | + | assert_eq!(listed[1]["url"], "https://g1t.sh/acme/api/releases/tag/v1.2.0"); | |
| 513 | + | assert_eq!( | |
| 514 | + | listed[2], | |
| 515 | + | json!({ "kind": "pull_merged", "time": "2026-10-12T13:00:00Z", "repo": "acme/web", "actor": "ana", "number": 7, "title": "Fix login", "url": "https://g1t.sh/acme/web/pull/7", "issue": 3 }) | |
| 516 | + | ); | |
| 517 | + | assert_eq!(listed[3]["default_branch"], true); | |
| 518 | + | assert_eq!(listed[3]["actor"], "bo"); | |
| 519 | + | assert_eq!( | |
| 520 | + | listed[4], | |
| 521 | + | json!({ "kind": "workflow_completed", "time": "2026-10-12T11:30:00Z", "repo": "acme/web", "actor": "ana", "workflow": "Deploy", "run_number": 257, "conclusion": "failure", "branch": "main", "commit": "fedcba987654", "run_id": "run_257", "url": "https://g1t.sh/acme/web/actions/runs/run_257" }) | |
| 522 | + | ); | |
| 523 | + | assert_eq!(listed[5]["resolved_by"], 7); | |
| 524 | + | assert_eq!(shown["complete"], true); | |
| 525 | + | } | |
| 526 | + | } |
| 1 | + | //! `list_commits`: a branch's history, newest first, with each commit's | |
| 2 | + | //! message, author, time and page; with `path`, also the commit that last | |
| 3 | + | //! changed that file or directory. What an agent (or Ask g1t) reads to say | |
| 4 | + | //! what changed lately and why: g1t's commit messages say why. | |
| 5 | + | //! | |
| 6 | + | //! The repos service reads it, as the caller may see it: `log` for the | |
| 7 | + | //! history, `last_commits` of the path's directory for its last change. | |
| 8 | + | //! The shaping is [`commit`], a plain function. | |
| 9 | + | ||
| 10 | + | use g1t_contracts::repos::{Commit, LastCommits, LastCommitsArgs, LogArgs, RepoPath}; | |
| 11 | + | use g1t_contracts::{Outcome, Viewer}; | |
| 12 | + | use serde_json::{Value, json}; | |
| 13 | + | use worker::Result; | |
| 14 | + | ||
| 15 | + | use crate::operations::Services; | |
| 16 | + | ||
| 17 | + | pub const DEFAULT_LIMIT: u32 = 30; | |
| 18 | + | pub const MAX_LIMIT: u32 = 100; | |
| 19 | + | /// The most of one commit's message an answer carries. | |
| 20 | + | const MAX_MESSAGE_CHARS: usize = 4_000; | |
| 21 | + | /// How long finding a path's last change may take, in milliseconds. | |
| 22 | + | const PATH_BUDGET_MS: u64 = 8_000; | |
| 23 | + | ||
| 24 | + | pub fn description() -> &'static str { | |
| 25 | + | "A branch's commits, newest first (the default branch unless `ref` names a branch, tag or commit): each with its `sha`, `message` (why it was made: g1t's commit messages say), `author`, `authored_at` and `html_url`. `limit` is 30 unless you say, at most 100. With `path` (a file or directory), also `last_changed`: the commit that last changed it." | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub fn input() -> Value { | |
| 29 | + | json!({ | |
| 30 | + | "type": "object", | |
| 31 | + | "properties": { | |
| 32 | + | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, | |
| 33 | + | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." }, | |
| 34 | + | "path": { "type": "string", "description": "A file or directory, such as scripts/deploy/npm-ci.mjs: adds the commit that last changed it." }, | |
| 35 | + | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "description": "How many commits: 30 unless you say, at most 100." }, | |
| 36 | + | }, | |
| 37 | + | "required": ["repo"], | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// One commit as an answer shows it. | |
| 42 | + | pub fn commit(site: &str, repo: &RepoPath, commit: &Commit) -> Value { | |
| 43 | + | let site = site.trim_end_matches('/'); | |
| 44 | + | let total = commit.message.chars().count(); | |
| 45 | + | let message = if total > MAX_MESSAGE_CHARS { | |
| 46 | + | format!("{}… [{} more characters]", commit.message.chars().take(MAX_MESSAGE_CHARS).collect::<String>(), total - MAX_MESSAGE_CHARS) | |
| 47 | + | } else { | |
| 48 | + | commit.message.trim_end().to_owned() | |
| 49 | + | }; | |
| 50 | + | json!({ | |
| 51 | + | "sha": commit.hash, | |
| 52 | + | "message": message, | |
| 53 | + | "author": { "name": commit.author.name }, | |
| 54 | + | "authored_at": commit.authored_at, | |
| 55 | + | "parents": commit.parents, | |
| 56 | + | "html_url": format!("{site}/{}/{}/commit/{}", repo.namespace, repo.name, commit.hash), | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | fn limit(input: &Value) -> u32 { | |
| 61 | + | let asked = match input.get("limit") { | |
| 62 | + | Some(Value::String(text)) => text.trim().parse::<u64>().ok(), | |
| 63 | + | Some(value) => value.as_u64(), | |
| 64 | + | None => None, | |
| 65 | + | }; | |
| 66 | + | asked.map_or(DEFAULT_LIMIT, |n| n.clamp(1, u64::from(MAX_LIMIT)) as u32) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub async fn run(services: &Services, viewer: &Viewer, repo: &RepoPath, input: &Value) -> Result<Outcome<Value>> { | |
| 70 | + | let site = services.addresses.site.clone(); | |
| 71 | + | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); | |
| 72 | + | let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned(); | |
| 73 | + | let log: Outcome<Vec<Commit>> = g1t_kit::call( | |
| 74 | + | &services.repos, | |
| 75 | + | "log", | |
| 76 | + | &LogArgs { path: repo.clone(), viewer: viewer.clone(), git_ref: git_ref.clone(), limit: limit(input) }, | |
| 77 | + | ) | |
| 78 | + | .await?; | |
| 79 | + | let commits = match log { | |
| 80 | + | Outcome::Ok(commits) => commits, | |
| 81 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 82 | + | }; | |
| 83 | + | let mut answer = json!({ | |
| 84 | + | "repo": format!("{}/{}", repo.namespace, repo.name), | |
| 85 | + | "commits": commits.iter().map(|found| commit(&site, repo, found)).collect::<Vec<_>>(), | |
| 86 | + | }); | |
| 87 | + | if !path.is_empty() { | |
| 88 | + | let (directory, name) = match path.rsplit_once('/') { | |
| 89 | + | Some((directory, name)) => (directory.to_owned(), name.to_owned()), | |
| 90 | + | None => (String::new(), path.clone()), | |
| 91 | + | }; | |
| 92 | + | let last: Outcome<LastCommits> = g1t_kit::call( | |
| 93 | + | &services.repos, | |
| 94 | + | "last_commits", | |
| 95 | + | &LastCommitsArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, tree_path: directory, budget_ms: Some(PATH_BUDGET_MS) }, | |
| 96 | + | ) | |
| 97 | + | .await?; | |
| 98 | + | answer["path"] = json!(path); | |
| 99 | + | answer["last_changed"] = match last { | |
| 100 | + | Outcome::Ok(last) => match last.entries.iter().find(|entry| entry.name == name) { | |
| 101 | + | Some(entry) => commit(&site, repo, &entry.commit), | |
| 102 | + | None if last.complete => json!(format!("{path} is not in this repository at this ref.")), | |
| 103 | + | None => json!("Not found in time: its history is long. Look through the commits for it."), | |
| 104 | + | }, | |
| 105 | + | Outcome::Fail(failure) => json!(failure.message), | |
| 106 | + | }; | |
| 107 | + | } | |
| 108 | + | Ok(Outcome::Ok(answer)) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[cfg(test)] | |
| 112 | + | mod tests { | |
| 113 | + | use super::*; | |
| 114 | + | use g1t_contracts::repos::Signature; | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn a_commit_says_why_and_where_it_is() { | |
| 118 | + | let found = Commit { | |
| 119 | + | hash: "abc123".into(), | |
| 120 | + | tree_hash: "t".into(), | |
| 121 | + | message: "The runner retries installs that break off.\n".into(), | |
| 122 | + | author: Signature { name: "Chase Pierce".into(), email: "c@example.com".into() }, | |
| 123 | + | parents: vec!["p1".into()], | |
| 124 | + | authored_at: "2026-10-13T01:00:00Z".into(), | |
| 125 | + | }; | |
| 126 | + | let repo = RepoPath { namespace: "acme".into(), name: "web".into() }; | |
| 127 | + | let shown = commit("https://g1t.sh/", &repo, &found); | |
| 128 | + | assert_eq!(shown["sha"], "abc123"); | |
| 129 | + | assert_eq!(shown["message"], "The runner retries installs that break off."); | |
| 130 | + | assert_eq!(shown["author"], json!({ "name": "Chase Pierce" })); | |
| 131 | + | assert!(shown.get("email").is_none() && shown["author"].get("email").is_none(), "no email addresses"); | |
| 132 | + | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/commit/abc123"); | |
| 133 | + | assert_eq!(limit(&json!({ "limit": "500" })), MAX_LIMIT); | |
| 134 | + | assert_eq!(limit(&json!({})), DEFAULT_LIMIT); | |
| 135 | + | } | |
| 136 | + | } |
| 1 | + | //! `get_contents`: what a repository holds at a path, at a branch, tag or | |
| 2 | + | //! commit: a file's text, or a directory's entries with its README. What | |
| 3 | + | //! an agent (or Ask g1t) reads to learn how a project builds and tests, | |
| 4 | + | //! what a config says, or what a file does, without cloning it. | |
| 5 | + | //! | |
| 6 | + | //! The repos service reads it, as the caller may see it: `blob` for a | |
| 7 | + | //! file, `tree` for a directory. A path that is not a file is tried as a | |
| 8 | + | //! directory. The shaping is [`file`] and [`directory`], plain functions. | |
| 9 | + | ||
| 10 | + | use g1t_contracts::repos::{BlobArgs, BlobView, EntryKind, GetArgs, Repo, RepoPath, TreeArgs, TreeView}; | |
| 11 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 12 | + | use serde_json::{Value, json}; | |
| 13 | + | use worker::Result; | |
| 14 | + | ||
| 15 | + | use crate::operations::Services; | |
| 16 | + | ||
| 17 | + | /// The most of a file's text one answer carries; more is cut with a note. | |
| 18 | + | pub const MAX_TEXT_CHARS: usize = 200_000; | |
| 19 | + | /// The most entries of a directory one answer lists. | |
| 20 | + | pub const MAX_ENTRIES: usize = 1_000; | |
| 21 | + | ||
| 22 | + | pub fn description() -> &'static str { | |
| 23 | + | "What a repository holds at a path, at `ref` (a branch, tag or commit; the default branch when left out). A file: its `text` (null for a binary file or one too large to show; at most 200,000 characters, the rest cut with a note), `size` in bytes and `html_url`. A directory (or the root, with no `path`): its `entries` (each `name`, `path` and `type`: dir, file, symlink or submodule) and its README's `text` when it has one. Read the root first to find README, CONTRIBUTING, package.json, Cargo.toml or a workflow under .g1t/workflows, then the files you need." | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | pub fn input() -> Value { | |
| 27 | + | json!({ | |
| 28 | + | "type": "object", | |
| 29 | + | "properties": { | |
| 30 | + | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, | |
| 31 | + | "path": { "type": "string", "description": "A file or directory, such as README.md or src/lib; the root when left out." }, | |
| 32 | + | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." }, | |
| 33 | + | }, | |
| 34 | + | "required": ["repo"], | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | fn kind(entry: EntryKind) -> &'static str { | |
| 39 | + | match entry { | |
| 40 | + | EntryKind::Tree => "dir", | |
| 41 | + | EntryKind::Blob | EntryKind::Exec => "file", | |
| 42 | + | EntryKind::Symlink => "symlink", | |
| 43 | + | EntryKind::Gitlink => "submodule", | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | fn full_name(repo: &Repo) -> String { | |
| 48 | + | format!("{}/{}", repo.namespace, repo.name) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// A file's answer. | |
| 52 | + | pub fn file(site: &str, view: &BlobView) -> Value { | |
| 53 | + | let site = site.trim_end_matches('/'); | |
| 54 | + | let text = view.text.as_ref().map(|text| { | |
| 55 | + | let total = text.chars().count(); | |
| 56 | + | if total <= MAX_TEXT_CHARS { | |
| 57 | + | text.clone() | |
| 58 | + | } else { | |
| 59 | + | let kept: String = text.chars().take(MAX_TEXT_CHARS).collect(); | |
| 60 | + | format!("{kept}\n[Cut: {} more characters.]", total - MAX_TEXT_CHARS) | |
| 61 | + | } | |
| 62 | + | }); | |
| 63 | + | json!({ | |
| 64 | + | "type": "file", | |
| 65 | + | "repo": full_name(&view.repo), | |
| 66 | + | "ref": view.git_ref, | |
| 67 | + | "path": view.path, | |
| 68 | + | "size": view.size, | |
| 69 | + | "binary": text.is_none(), | |
| 70 | + | "text": text, | |
| 71 | + | "html_url": format!("{site}/{}/blob/{}/{}", full_name(&view.repo), view.git_ref, view.path.trim_start_matches('/')), | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// A directory's answer. | |
| 76 | + | pub fn directory(site: &str, view: &TreeView) -> Value { | |
| 77 | + | let site = site.trim_end_matches('/'); | |
| 78 | + | let base = view.path.trim_matches('/'); | |
| 79 | + | let entries: Vec<Value> = view | |
| 80 | + | .entries | |
| 81 | + | .iter() | |
| 82 | + | .take(MAX_ENTRIES) | |
| 83 | + | .map(|entry| { | |
| 84 | + | let path = if base.is_empty() { entry.name.clone() } else { format!("{base}/{}", entry.name) }; | |
| 85 | + | json!({ "name": entry.name, "path": path, "type": kind(entry.kind) }) | |
| 86 | + | }) | |
| 87 | + | .collect(); | |
| 88 | + | let html_url = if base.is_empty() { | |
| 89 | + | format!("{site}/{}", full_name(&view.repo)) | |
| 90 | + | } else { | |
| 91 | + | format!("{site}/{}/tree/{}/{base}", full_name(&view.repo), view.git_ref) | |
| 92 | + | }; | |
| 93 | + | json!({ | |
| 94 | + | "type": "dir", | |
| 95 | + | "repo": full_name(&view.repo), | |
| 96 | + | "ref": view.git_ref, | |
| 97 | + | "path": base, | |
| 98 | + | "entries": entries, | |
| 99 | + | "truncated": view.entries.len() > MAX_ENTRIES, | |
| 100 | + | "readme": view.readme.as_ref().map(|readme| json!({ "name": readme.name, "text": readme.text })), | |
| 101 | + | "html_url": html_url, | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | pub async fn run(services: &Services, viewer: &Viewer, repo: &RepoPath, input: &Value) -> Result<Outcome<Value>> { | |
| 106 | + | let site = services.addresses.site.clone(); | |
| 107 | + | let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned(); | |
| 108 | + | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); | |
| 109 | + | let tree = |tree_path: String, git_ref: Option<String>| TreeArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, tree_path }; | |
| 110 | + | if !path.is_empty() { | |
| 111 | + | // A file needs a ref: the default branch, unless one was given. | |
| 112 | + | let git_ref = match &git_ref { | |
| 113 | + | Some(git_ref) => git_ref.clone(), | |
| 114 | + | None => { | |
| 115 | + | let found: Outcome<Repo> = | |
| 116 | + | g1t_kit::call(&services.repos, "get", &GetArgs { path: repo.clone(), viewer: viewer.clone() }).await?; | |
| 117 | + | match found { | |
| 118 | + | Outcome::Ok(found) => found.default_branch, | |
| 119 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | }; | |
| 123 | + | let blob: Outcome<BlobView> = g1t_kit::call( | |
| 124 | + | &services.repos, | |
| 125 | + | "blob", | |
| 126 | + | &BlobArgs { path: repo.clone(), viewer: viewer.clone(), git_ref: git_ref.clone(), file_path: path.clone() }, | |
| 127 | + | ) | |
| 128 | + | .await?; | |
| 129 | + | if let Outcome::Ok(view) = blob { | |
| 130 | + | return Ok(Outcome::Ok(file(&site, &view))); | |
| 131 | + | } | |
| 132 | + | // Not a file: a directory, or nothing. | |
| 133 | + | let listed: Outcome<TreeView> = g1t_kit::call(&services.repos, "tree", &tree(path.clone(), Some(git_ref))).await?; | |
| 134 | + | return Ok(match listed { | |
| 135 | + | Outcome::Ok(view) => Outcome::Ok(directory(&site, &view)), | |
| 136 | + | Outcome::Fail(failure) if failure.code == FailureCode::NotFound => { | |
| 137 | + | Outcome::fail(FailureCode::NotFound, format!("{}/{} has no file or directory {path}.", repo.namespace, repo.name)) | |
| 138 | + | } | |
| 139 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 140 | + | }); | |
| 141 | + | } | |
| 142 | + | let listed: Outcome<TreeView> = g1t_kit::call(&services.repos, "tree", &tree(String::new(), git_ref)).await?; | |
| 143 | + | Ok(match listed { | |
| 144 | + | Outcome::Ok(view) => Outcome::Ok(directory(&site, &view)), | |
| 145 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[cfg(test)] | |
| 150 | + | mod tests { | |
| 151 | + | use super::*; | |
| 152 | + | use g1t_contracts::repos::{Readme, TreeEntry}; | |
| 153 | + | ||
| 154 | + | fn repo() -> Repo { | |
| 155 | + | serde_json::from_value(json!({ | |
| 156 | + | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, | |
| 157 | + | "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z", | |
| 158 | + | })) | |
| 159 | + | .unwrap() | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn a_file_is_its_text_and_where_it_is() { | |
| 164 | + | let view = BlobView { repo: repo(), git_ref: "main".into(), path: "CONTRIBUTING.md".into(), size: 12, text: Some("npm test\n".into()) }; | |
| 165 | + | let shown = file("https://g1t.sh/", &view); | |
| 166 | + | assert_eq!(shown["type"], "file"); | |
| 167 | + | assert_eq!(shown["text"], "npm test\n"); | |
| 168 | + | assert_eq!(shown["binary"], false); | |
| 169 | + | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/blob/main/CONTRIBUTING.md"); | |
| 170 | + | let binary = file("https://g1t.sh", &BlobView { text: None, ..view }); | |
| 171 | + | assert_eq!(binary["binary"], true); | |
| 172 | + | assert!(binary["text"].is_null()); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 176 | + | fn a_huge_file_is_cut_with_a_note() { | |
| 177 | + | let text = "x".repeat(MAX_TEXT_CHARS + 10); | |
| 178 | + | let view = BlobView { repo: repo(), git_ref: "main".into(), path: "big.txt".into(), size: text.len() as u64, text: Some(text) }; | |
| 179 | + | let shown = file("https://g1t.sh", &view); | |
| 180 | + | assert!(shown["text"].as_str().unwrap().ends_with("[Cut: 10 more characters.]")); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn a_directory_lists_its_entries_and_readme() { | |
| 185 | + | let view = TreeView { | |
| 186 | + | repo: repo(), | |
| 187 | + | git_ref: "main".into(), | |
| 188 | + | path: "".into(), | |
| 189 | + | head: None, | |
| 190 | + | entries: vec![ | |
| 191 | + | TreeEntry { name: "src".into(), hash: "a".into(), kind: EntryKind::Tree }, | |
| 192 | + | TreeEntry { name: "README.md".into(), hash: "b".into(), kind: EntryKind::Blob }, | |
| 193 | + | ], | |
| 194 | + | readme: Some(Readme { name: "README.md".into(), text: Some("# Web".into()) }), | |
| 195 | + | }; | |
| 196 | + | let shown = directory("https://g1t.sh", &view); | |
| 197 | + | assert_eq!(shown["entries"], json!([{ "name": "src", "path": "src", "type": "dir" }, { "name": "README.md", "path": "README.md", "type": "file" }])); | |
| 198 | + | assert_eq!(shown["readme"]["text"], "# Web"); | |
| 199 | + | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web"); | |
| 200 | + | let nested = directory("https://g1t.sh", &TreeView { path: "src".into(), readme: None, ..view }); | |
| 201 | + | assert_eq!(nested["entries"][0]["path"], "src/src"); | |
| 202 | + | assert_eq!(nested["html_url"], "https://g1t.sh/acme/web/tree/main/src"); | |
| 203 | + | } | |
| 204 | + | } |
| 8 | 8 | //! role that may open issues and pull requests; only the person whose | |
| 9 | 9 | //! token started a session, or made a claim, changes it. | |
| 10 | 10 | ||
| 11 | + | use g1t_contracts::agent_sessions::{AgentMode, StartAgentSessionArgs}; | |
| 11 | 12 | use g1t_contracts::work::*; | |
| 12 | 13 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 13 | 14 | use serde_json::{Value, json}; | |
| ⋯ | |||
| 29 | 30 | HandOffIssue, | |
| 30 | 31 | ListClaims, | |
| 31 | 32 | GetWork, | |
| 33 | + | StartAgentSession, | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | impl CoordinationOp { | |
| 35 | 37 | /// Every one: `Op::ALL` lists each as `Op::Coordination(…)`, which a | |
| 36 | 38 | /// test checks against this. | |
| 37 | − | pub const ALL: [CoordinationOp; 11] = [ | |
| 39 | + | pub const ALL: [CoordinationOp; 12] = [ | |
| 38 | 40 | CoordinationOp::StartSession, | |
| 39 | 41 | CoordinationOp::UpdateSession, | |
| 40 | 42 | CoordinationOp::EndSession, | |
| ⋯ | |||
| 46 | 48 | CoordinationOp::HandOffIssue, | |
| 47 | 49 | CoordinationOp::ListClaims, | |
| 48 | 50 | CoordinationOp::GetWork, | |
| 51 | + | CoordinationOp::StartAgentSession, | |
| 49 | 52 | ]; | |
| 50 | 53 | ||
| 51 | 54 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 61 | 64 | CoordinationOp::HandOffIssue => "hand_off_issue", | |
| 62 | 65 | CoordinationOp::ListClaims => "list_claims", | |
| 63 | 66 | CoordinationOp::GetWork => "get_work", | |
| 67 | + | CoordinationOp::StartAgentSession => "start_agent_session", | |
| 64 | 68 | } | |
| 65 | 69 | } | |
| 66 | 70 | ||
| ⋯ | |||
| 78 | 82 | CoordinationOp::HandOffIssue => "Hand off an issue", | |
| 79 | 83 | CoordinationOp::ListClaims => "List claims", | |
| 80 | 84 | CoordinationOp::GetWork => "Get an issue's work context", | |
| 85 | + | CoordinationOp::StartAgentSession => "Start an agent session on an issue", | |
| 81 | 86 | } | |
| 82 | 87 | } | |
| 83 | 88 | ||
| ⋯ | |||
| 91 | 96 | ||
| 92 | 97 | pub fn description(self) -> &'static str { | |
| 93 | 98 | match self { | |
| 94 | − | 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. 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.", | |
| 95 | − | 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. Returns the session; one ended with end_session is refused with `409`.", | |
| 96 | − | CoordinationOp::EndSession => "End one of your sessions, with an optional `reason`. A session.ended event. Ending one already ended returns it as it is. Its claims are not released: release them, hand the issue off, or let them expire.", | |
| 97 | − | 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. Anyone who can read the repository may.", | |
| 98 | − | 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, and `issue` or `pull` for those attached to one. Anyone who can read the repository may.", | |
| 99 | − | CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result or note), `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. 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`.", | |
| 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`.", | |
| 100 | 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`.", | |
| 101 | 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.", | |
| 102 | 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.", | |
| 103 | 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.", | |
| 104 | 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.", | |
| 105 | 111 | } | |
| 106 | 112 | } | |
| 107 | 113 | ||
| ⋯ | |||
| 125 | 131 | } | |
| 126 | 132 | properties | |
| 127 | 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." }); | |
| 128 | 149 | let (properties, required): (Value, &[&str]) = match self { | |
| 129 | 150 | CoordinationOp::StartSession => ( | |
| 130 | − | fields(json!({ | |
| 131 | − | "harness": { | |
| 132 | − | "type": "string", | |
| 133 | − | "enum": Harness::ALL.map(Harness::as_str), | |
| 134 | − | "description": "The harness the session runs in.", | |
| 135 | − | }, | |
| 136 | − | "harness_session_id": { | |
| 137 | − | "type": "string", | |
| 138 | − | "description": "The harness's own id for the session, such as Claude Code's session_id. Starting again with it returns the same session.", | |
| 139 | − | }, | |
| 140 | − | })), | |
| 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 | + | ), | |
| 141 | 166 | &["repo", "harness"], | |
| 142 | 167 | ), | |
| 143 | − | CoordinationOp::UpdateSession => (fields(json!({ "id": id })), &["repo", "id"]), | |
| 168 | + | CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]), | |
| 144 | 169 | CoordinationOp::EndSession => ( | |
| 145 | 170 | json!({ | |
| 146 | 171 | "repo": repo, | |
| 147 | 172 | "id": id, | |
| 148 | 173 | "reason": { "type": "string", "description": "Why it ended, in a few words. \"ended\" if left out." }, | |
| 174 | + | "result": result, | |
| 149 | 175 | }), | |
| 150 | 176 | &["repo", "id"], | |
| 151 | 177 | ), | |
| ⋯ | |||
| 161 | 187 | json!({ | |
| 162 | 188 | "repo": repo, | |
| 163 | 189 | "state": { "type": "string", "enum": ["active", "idle", "ended"], "description": "Only sessions in this state." }, | |
| 164 | − | "issue": { "type": "integer", "description": "Only sessions on this issue." }, | |
| 165 | − | "pull": { "type": "integer", "description": "Only sessions on this pull request." }, | |
| 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." }, | |
| 166 | 194 | }), | |
| 167 | 195 | &["repo"], | |
| 168 | 196 | ), | |
| ⋯ | |||
| 176 | 204 | "items": { | |
| 177 | 205 | "type": "object", | |
| 178 | 206 | "properties": { | |
| 179 | − | "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note"] }, | |
| 180 | − | "text": { "type": "string" }, | |
| 207 | + | "kind": { "type": "string", "enum": ["prompt", "message", "tool_call", "tool_result", "note", "skill"] }, | |
| 208 | + | "text": { "type": "string", "description": "For a skill, its name only." }, | |
| 181 | 209 | "tool": { "type": "string", "description": "For tool_call and tool_result: the tool's name." }, | |
| 182 | 210 | "commit": { "type": "string", "description": "The commit it was recorded at; the session's head if left out." }, | |
| 183 | 211 | "key": { "type": "string", "description": "An idempotency key, unique in the session: an entry with a key recorded before is skipped." }, | |
| ⋯ | |||
| 185 | 213 | "required": ["kind", "text"], | |
| 186 | 214 | }, | |
| 187 | 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 | + | }, | |
| 188 | 228 | }), | |
| 189 | − | &["repo", "id", "entries"], | |
| 229 | + | &["repo", "id"], | |
| 190 | 230 | ), | |
| 191 | 231 | CoordinationOp::ClaimIssue => ( | |
| 192 | 232 | json!({ | |
| ⋯ | |||
| 203 | 243 | "repo": repo, | |
| 204 | 244 | "number": number, | |
| 205 | 245 | "reason": { "type": "string", "description": "Why, in a few words. \"released\" if left out." }, | |
| 246 | + | "session_id": session_id, | |
| 206 | 247 | }), | |
| 207 | 248 | &["repo", "number"], | |
| 208 | 249 | ), | |
| ⋯ | |||
| 212 | 253 | "number": number, | |
| 213 | 254 | "to": { "type": "string", "description": "The username it is for. Left out, it is for anyone." }, | |
| 214 | 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, | |
| 215 | 257 | }), | |
| 216 | 258 | &["repo", "number", "note"], | |
| 217 | 259 | ), | |
| ⋯ | |||
| 228 | 270 | &["repo"], | |
| 229 | 271 | ), | |
| 230 | 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 | + | ), | |
| 231 | 285 | }; | |
| 232 | 286 | json!({ "type": "object", "properties": properties, "required": required }) | |
| 233 | 287 | } | |
| ⋯ | |||
| 306 | 360 | branch: text(input, "branch"), | |
| 307 | 361 | title: text(input, "title"), | |
| 308 | 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"), | |
| 309 | 368 | }; | |
| 310 | 369 | g1t_kit::call(work, "start_session", &args).await | |
| 311 | 370 | } | |
| ⋯ | |||
| 319 | 378 | branch: text(input, "branch"), | |
| 320 | 379 | head_commit: text(input, "head_commit"), | |
| 321 | 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"), | |
| 322 | 386 | }; | |
| 323 | 387 | g1t_kit::call(work, "update_session", &args).await | |
| 324 | 388 | } | |
| 325 | 389 | CoordinationOp::EndSession => { | |
| 326 | − | let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason") }; | |
| 390 | + | let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") }; | |
| 327 | 391 | g1t_kit::call(work, "end_session", &args).await | |
| 328 | 392 | } | |
| 329 | 393 | CoordinationOp::GetSession => { | |
| ⋯ | |||
| 338 | 402 | None => return invalid(format!("state is active, idle or ended, not {state}.")), | |
| 339 | 403 | }, | |
| 340 | 404 | }; | |
| 341 | − | let args = ListWorkSessionsArgs { viewer: viewer.clone(), repo, state, issue, pull }; | |
| 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") }; | |
| 342 | 407 | g1t_kit::call(work, "list_work_sessions", &args).await | |
| 343 | 408 | } | |
| 344 | 409 | CoordinationOp::RecordSessionEntries => { | |
| 345 | − | let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(input["entries"].clone()) { | |
| 410 | + | let entries: Vec<KeyedSessionEntry> = match serde_json::from_value(match &input["entries"] { | |
| 411 | + | Value::Null => json!([]), | |
| 412 | + | entries => entries.clone(), | |
| 413 | + | }) { | |
| 346 | 414 | Ok(entries) => entries, | |
| 347 | 415 | Err(_) => { | |
| 348 | 416 | return invalid( | |
| 349 | − | "entries is a list of { kind, text }, kind one of prompt, message, tool_call, tool_result or note, with tool, commit and key as needed.".to_owned(), | |
| 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(), | |
| 350 | 418 | ); | |
| 351 | 419 | } | |
| 352 | 420 | }; | |
| 353 | − | let args = RecordEntriesArgs { actor: actor(), repo, id: id(), entries }; | |
| 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 }; | |
| 354 | 429 | g1t_kit::call(work, "record_session_entries", &args).await | |
| 355 | 430 | } | |
| 356 | 431 | CoordinationOp::ClaimIssue => { | |
| ⋯ | |||
| 369 | 444 | g1t_kit::call(work, "claim_issue", &args).await | |
| 370 | 445 | } | |
| 371 | 446 | CoordinationOp::ReleaseClaim => { | |
| 372 | − | let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason") }; | |
| 447 | + | let args = ReleaseArgs { actor: actor(), repo, number: issue_number, reason: text(input, "reason"), session_id: text(input, "session_id") }; | |
| 373 | 448 | g1t_kit::call(work, "release_claim", &args).await | |
| 374 | 449 | } | |
| 375 | 450 | CoordinationOp::HandOffIssue => { | |
| 376 | 451 | let Some(note) = text(input, "note") else { | |
| 377 | 452 | return invalid("Say where the work stands in note, for whoever picks it up.".to_owned()); | |
| 378 | 453 | }; | |
| 379 | − | let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note }; | |
| 454 | + | let args = HandOffArgs { actor: actor(), repo, number: issue_number, to: text(input, "to"), note, session_id: text(input, "session_id") }; | |
| 380 | 455 | g1t_kit::call(work, "hand_off_issue", &args).await | |
| 381 | 456 | } | |
| 382 | 457 | CoordinationOp::ListClaims => { | |
| ⋯ | |||
| 394 | 469 | let args = GetWorkArgs { viewer: viewer.clone(), repo, number: issue_number }; | |
| 395 | 470 | g1t_kit::call(work, "get_work", &args).await | |
| 396 | 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 | + | } | |
| 397 | 501 | } | |
| 398 | 502 | } | |
| 399 | 503 | ||
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | + | mod activity; | |
| 9 | + | mod commits; | |
| 10 | + | mod contents; | |
| 8 | 11 | mod run_artifacts; | |
| 9 | 12 | mod caches; | |
| 10 | 13 | mod archives; |
| 66 | 66 | ( | |
| 67 | 67 | "Workspaces", | |
| 68 | 68 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 69 | − | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 69 | + | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace, Op::GetWorkspaceActivity], | |
| 70 | 70 | ), | |
| 71 | 71 | ( | |
| 72 | 72 | "Personal access tokens", | |
| ⋯ | |||
| 115 | 115 | Op::ListRepos, | |
| 116 | 116 | Op::CreateRepo, | |
| 117 | 117 | Op::GetRepo, | |
| 118 | + | Op::GetContents, | |
| 119 | + | Op::ListCommits, | |
| 118 | 120 | Op::Archives(ArchiveOp::DownloadTarball), | |
| 119 | 121 | Op::Archives(ArchiveOp::DownloadZipball), | |
| 120 | 122 | Op::UpdateRepo, | |
| ⋯ | |||
| 386 | 388 | ), | |
| 387 | 389 | ( | |
| 388 | 390 | "Sessions", | |
| 389 | − | "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to an issue and a pull request, and recording prompts, reasoning and the tools that ran. A pull request's session shows what was recorded on it.", | |
| 391 | + | "Who is working on what, and how a pull request was made. A session is one run of your own harness (a Claude Code session, a Codex run) on a repository: started once, kept alive by heartbeats, attached to an issue and a pull request, and recording prompts, reasoning, the tools that ran, the skills used and what it touched. Each subagent is a session of its own under the one that started it. A pull request's session shows what was recorded on it. A session can also be started on an issue in your own Claude, on the workspace's Anthropic account.", | |
| 390 | 392 | &[ | |
| 391 | 393 | Op::Coordination(CoordinationOp::StartSession), | |
| 392 | 394 | Op::Coordination(CoordinationOp::UpdateSession), | |
| ⋯ | |||
| 394 | 396 | Op::Coordination(CoordinationOp::GetSession), | |
| 395 | 397 | Op::Coordination(CoordinationOp::ListSessions), | |
| 396 | 398 | Op::Coordination(CoordinationOp::RecordSessionEntries), | |
| 399 | + | Op::Coordination(CoordinationOp::StartAgentSession), | |
| 397 | 400 | Op::ReadSession, | |
| 398 | 401 | Op::RecordSession, | |
| 399 | 402 | ], | |
| ⋯ | |||
| 776 | 779 | Op::UnpinProject => "Unpin a project", | |
| 777 | 780 | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 778 | 781 | Op::ListProjects => "List a workspace's projects", | |
| 782 | + | Op::GetWorkspaceActivity => "Get a workspace's activity", | |
| 783 | + | Op::GetContents => "Get repository contents", | |
| 784 | + | Op::ListCommits => "List commits", | |
| 779 | 785 | Op::GetProject => "Get a project", | |
| 780 | 786 | Op::UpdateProject => "Update a project", | |
| 781 | 787 | Op::ListTeams => "List teams", | |
| 268 | 268 | UnpinProject, | |
| 269 | 269 | ReorderPinnedProjects, | |
| 270 | 270 | ListProjects, | |
| 271 | + | GetWorkspaceActivity, | |
| 272 | + | GetContents, | |
| 273 | + | ListCommits, | |
| 271 | 274 | GetProject, | |
| 272 | 275 | UpdateProject, | |
| 273 | 276 | ListTeams, | |
| ⋯ | |||
| 697 | 700 | } | |
| 698 | 701 | ||
| 699 | 702 | impl Op { | |
| 700 | − | pub const ALL: [Op; 372] = [ | |
| 703 | + | pub const ALL: [Op; 376] = [ | |
| 701 | 704 | Op::Whoami, | |
| 702 | 705 | Op::GetWorkspace, | |
| 703 | 706 | Op::CreateWorkspace, | |
| ⋯ | |||
| 856 | 859 | Op::ListProjects, | |
| 857 | 860 | Op::GetProject, | |
| 858 | 861 | Op::UpdateProject, | |
| 862 | + | Op::GetWorkspaceActivity, | |
| 863 | + | Op::GetContents, | |
| 864 | + | Op::ListCommits, | |
| 859 | 865 | Op::ListTeams, | |
| 860 | 866 | Op::GetTeam, | |
| 861 | 867 | Op::CreateTeam, | |
| ⋯ | |||
| 1070 | 1076 | Op::Coordination(CoordinationOp::HandOffIssue), | |
| 1071 | 1077 | Op::Coordination(CoordinationOp::ListClaims), | |
| 1072 | 1078 | Op::Coordination(CoordinationOp::GetWork), | |
| 1079 | + | Op::Coordination(CoordinationOp::StartAgentSession), | |
| 1073 | 1080 | ]; | |
| 1074 | 1081 | ||
| 1075 | 1082 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1235 | 1242 | Op::UnpinProject => "unpin_project", | |
| 1236 | 1243 | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 1237 | 1244 | Op::ListProjects => "list_projects", | |
| 1245 | + | Op::GetWorkspaceActivity => "get_workspace_activity", | |
| 1246 | + | Op::GetContents => "get_contents", | |
| 1247 | + | Op::ListCommits => "list_commits", | |
| 1238 | 1248 | Op::GetProject => "get_project", | |
| 1239 | 1249 | Op::UpdateProject => "update_project", | |
| 1240 | 1250 | Op::ListTeams => "list_teams", | |
| ⋯ | |||
| 1559 | 1569 | "One workflow run with its jobs: each job's steps and how they went, its annotations (::error:: and the like), and why it stopped. Read a job's log with get_job_logs. `attempts` lists every attempt (each re-run is one) with who started it and how it ended; give `attempt` to read an earlier one, whose jobs keep their own ids and logs." | |
| 1560 | 1570 | } | |
| 1561 | 1571 | Op::GetJobLogs => { | |
| 1562 | − | "A job's log, in order, after `after` (a sequence number from an earlier call). `done` says whether more will come. Lines starting ##[group], ##[endgroup], ##[error] and ##[warning] mark groups and messages." | |
| 1572 | + | "A job's log, in order, after `after` (a sequence number from an earlier call), or with `tail` its last chunks, where a failed job says why. `done` says whether more will come. Lines starting ##[group], ##[endgroup], ##[error] and ##[warning] mark groups and messages." | |
| 1563 | 1573 | } | |
| 1564 | 1574 | Op::DispatchWorkflow => { | |
| 1565 | 1575 | "Run a workflow that has `on: workflow_dispatch`, on a branch or tag (the default branch if none), with its inputs. Needs the Write role or higher." | |
| ⋯ | |||
| 1708 | 1718 | Op::ReorderPinnedProjects => { | |
| 1709 | 1719 | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1710 | 1720 | } | |
| 1721 | + | Op::GetWorkspaceActivity => crate::activity::description(), | |
| 1722 | + | Op::GetContents => crate::contents::description(), | |
| 1723 | + | Op::ListCommits => crate::commits::description(), | |
| 1711 | 1724 | Op::ListProjects => { | |
| 1712 | 1725 | "A workspace's projects that you can see, by name. A project is what a workspace builds and runs, from a repository or a root directory in one; every repository has a project of its own name. Each has what it is (`kind`: app, library, tool, docs or other) and why (`kind_reason`), where it runs (`runs`: `g1t` when g1t deploys it, `elsewhere` when it is deployed by other means, at `production_url`), and its `links`." | |
| 1713 | 1726 | } | |
| ⋯ | |||
| 2603 | 2616 | "repo": repo_schema(), | |
| 2604 | 2617 | "job": { "type": "string", "description": "The job's id, from get_workflow_run." }, | |
| 2605 | 2618 | "after": { "type": "integer", "description": "Only chunks after this sequence number." }, | |
| 2619 | + | "tail": { "type": "integer", "minimum": 1, "maximum": 500, "description": "The last this many chunks instead, where a failed job says why. after is ignored with it." }, | |
| 2606 | 2620 | }), | |
| 2607 | 2621 | &["repo", "job"], | |
| 2608 | 2622 | ), | |
| ⋯ | |||
| 2983 | 2997 | &["workspace", "projects"], | |
| 2984 | 2998 | ), | |
| 2985 | 2999 | Op::ListProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 3000 | + | Op::GetWorkspaceActivity => crate::activity::input(), | |
| 3001 | + | Op::GetContents => crate::contents::input(), | |
| 3002 | + | Op::ListCommits => crate::commits::input(), | |
| 2986 | 3003 | Op::GetProject => object( | |
| 2987 | 3004 | json!({ | |
| 2988 | 3005 | "workspace": workspace_schema(), | |
| ⋯ | |||
| 3381 | 3398 | | Op::ListProjects | |
| 3382 | 3399 | | Op::GetProject | |
| 3383 | 3400 | | Op::UpdateProject | |
| 3401 | + | | Op::GetWorkspaceActivity | |
| 3384 | 3402 | | Op::ListTeams | |
| 3385 | 3403 | | Op::GetTeam | |
| 3386 | 3404 | | Op::CreateTeam | |
| ⋯ | |||
| 4693 | 4711 | pass( | |
| 4694 | 4712 | actions, | |
| 4695 | 4713 | "logs", | |
| 4696 | − | &json!({ "repo": repo, "viewer": viewer, "job": text(input, "job"), "after": integer(input, "after").unwrap_or(0) }), | |
| 4714 | + | &json!({ "repo": repo, "viewer": viewer, "job": text(input, "job"), "after": integer(input, "after").unwrap_or(0), "tail": integer(input, "tail") }), | |
| 4697 | 4715 | ) | |
| 4698 | 4716 | .await | |
| 4699 | 4717 | } | |
| ⋯ | |||
| 5402 | 5420 | } | |
| 5403 | 5421 | // What a project is, where it runs and its links: the projects | |
| 5404 | 5422 | // service keeps them and decides who may change them. | |
| 5423 | + | Op::GetWorkspaceActivity => crate::activity::run(services, viewer, input).await, | |
| 5424 | + | Op::GetContents => crate::contents::run(services, viewer, &repo, input).await, | |
| 5425 | + | Op::ListCommits => crate::commits::run(services, viewer, &repo, input).await, | |
| 5405 | 5426 | Op::ListProjects | Op::GetProject | Op::UpdateProject => { | |
| 5406 | 5427 | crate::projects::run(self, services, viewer, input).await | |
| 5407 | 5428 | } | |
| 2256 | 2256 | "ended_at": null, | |
| 2257 | 2257 | "end_reason": null | |
| 2258 | 2258 | }, | |
| 2259 | − | "notes": "Idempotent on `harness_session_id`: a second start with it returns this session, so call it from a hook without checking first. `state` is `active`, `idle` (no heartbeat for 15 minutes) or `ended` (ended, or no heartbeat for 2 hours: `end_reason` `timed_out`). See [Sessions](/guides/sessions/)." | |
| 2259 | + | "notes": "Idempotent on `harness_session_id`: a second start with it returns this session, so call it from a hook without checking first. `state` is `active`, `idle` (no heartbeat for 15 minutes) or `ended` (ended, or no heartbeat for 2 hours: `end_reason` `timed_out`). A subagent gives `parent_id`, `agent_type`, `task` and `prompt`. See [Sessions](/guides/sessions/)." | |
| 2260 | 2260 | }, | |
| 2261 | + | "start_agent_session": { | |
| 2262 | + | "request": { | |
| 2263 | + | "issue": 12, | |
| 2264 | + | "mode": "implement", | |
| 2265 | + | "instruction": "Keep the old greeting behind a flag." | |
| 2266 | + | }, | |
| 2267 | + | "response": { | |
| 2268 | + | "issue": 12, | |
| 2269 | + | "session": { | |
| 2270 | + | "id": "ses_01m4c9a8b7c6d5e4f3g2h1j0k9", | |
| 2271 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2272 | + | "issue": 12, | |
| 2273 | + | "pull": null, | |
| 2274 | + | "branch": null, | |
| 2275 | + | "actor": { | |
| 2276 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2277 | + | "username": "syntaqx" | |
| 2278 | + | }, | |
| 2279 | + | "harness": "claude_managed_agents", | |
| 2280 | + | "harness_session_id": "sesn_011CZkZAtmR3yMPDzynEDxu7", | |
| 2281 | + | "title": "#12 Make the greeting name the caller", | |
| 2282 | + | "state": "active", | |
| 2283 | + | "head_commit": null, | |
| 2284 | + | "started_at": "2026-10-12T15:10:02.117Z", | |
| 2285 | + | "last_seen_at": "2026-10-12T15:10:02.117Z", | |
| 2286 | + | "ended_at": null, | |
| 2287 | + | "end_reason": null, | |
| 2288 | + | "parent_id": null, | |
| 2289 | + | "agent_type": null, | |
| 2290 | + | "task": null, | |
| 2291 | + | "prompt": null, | |
| 2292 | + | "result": null, | |
| 2293 | + | "model": "claude-opus-5-5", | |
| 2294 | + | "subagents": 0 | |
| 2295 | + | }, | |
| 2296 | + | "session_url": "https://g1t.sh/syntaqx/hello/work/sessions/ses_01m4c9a8b7c6d5e4f3g2h1j0k9", | |
| 2297 | + | "provider": "anthropic", | |
| 2298 | + | "provider_session_id": "sesn_011CZkZAtmR3yMPDzynEDxu7", | |
| 2299 | + | "provider_session_url": "https://platform.claude.com/workspaces/default/sessions/sesn_011CZkZAtmR3yMPDzynEDxu7", | |
| 2300 | + | "handoff": false, | |
| 2301 | + | "command": null, | |
| 2302 | + | "prompt": "Work on issue #12 in syntaqx/hello: Make the greeting name the caller.\n\n…" | |
| 2303 | + | }, | |
| 2304 | + | "notes": "Runs on the workspace's own Anthropic key: g1t runs no model and bills nothing for it. Without an Anthropic connection (or with `handoff: true`) nothing is started and `command` is a Claude Code command line to run with g1t's plugin. See [Sessions](/guides/sessions/#start-a-session-on-your-anthropic-account)." | |
| 2305 | + | }, | |
| 2261 | 2306 | "update_session": { | |
| 2262 | 2307 | "request": { | |
| 2263 | 2308 | "pull": 14, | |
| ⋯ | |||
| 2352 | 2397 | "commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2353 | 2398 | "at": "2026-10-12T14:30:12.660Z" | |
| 2354 | 2399 | } | |
| 2355 | − | ] | |
| 2400 | + | ], | |
| 2401 | + | "children": [ | |
| 2402 | + | { | |
| 2403 | + | "id": "ses_01m4b2d8x1y2z3a4b5c6d7e8f9", | |
| 2404 | + | "repo_id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr", | |
| 2405 | + | "issue": 12, | |
| 2406 | + | "pull": null, | |
| 2407 | + | "branch": "greet-caller", | |
| 2408 | + | "actor": { | |
| 2409 | + | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 2410 | + | "username": "syntaqx" | |
| 2411 | + | }, | |
| 2412 | + | "harness": "claude_code", | |
| 2413 | + | "harness_session_id": "5b0c1f9e-3d2a-4e8b-9f61-0c7d2a9e4b13/agent/a4d2c8f1e0b3a297", | |
| 2414 | + | "title": null, | |
| 2415 | + | "state": "ended", | |
| 2416 | + | "head_commit": null, | |
| 2417 | + | "started_at": "2026-10-12T14:05:40.112Z", | |
| 2418 | + | "last_seen_at": "2026-10-12T14:07:02.930Z", | |
| 2419 | + | "ended_at": "2026-10-12T14:07:02.930Z", | |
| 2420 | + | "end_reason": "done", | |
| 2421 | + | "parent_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2422 | + | "agent_type": "Explore", | |
| 2423 | + | "task": "Find where the greeting is built", | |
| 2424 | + | "prompt": "Find every place the greeting text is built and say which file to change.", | |
| 2425 | + | "result": "The greeting is built in src/greet.rs (greet) and nowhere else.", | |
| 2426 | + | "model": "claude-sonnet-5-5", | |
| 2427 | + | "subagents": 0 | |
| 2428 | + | } | |
| 2429 | + | ], | |
| 2430 | + | "touched": [ | |
| 2431 | + | { | |
| 2432 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2433 | + | "kind": "file", | |
| 2434 | + | "value": "src/greet.rs", | |
| 2435 | + | "at": "2026-10-12T14:12:30.004Z" | |
| 2436 | + | }, | |
| 2437 | + | { | |
| 2438 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2439 | + | "kind": "branch", | |
| 2440 | + | "value": "greet-caller", | |
| 2441 | + | "at": "2026-10-12T14:30:40.871Z" | |
| 2442 | + | } | |
| 2443 | + | ], | |
| 2444 | + | "links": [ | |
| 2445 | + | { | |
| 2446 | + | "kind": "pull", | |
| 2447 | + | "number": 14, | |
| 2448 | + | "title": "Name the caller in the greeting", | |
| 2449 | + | "state": "open", | |
| 2450 | + | "via": ["attached", "branch"], | |
| 2451 | + | "session_ids": ["ses_01m4b2c3d4e5f6g7h8j9k0m1n2"] | |
| 2452 | + | }, | |
| 2453 | + | { | |
| 2454 | + | "kind": "issue", | |
| 2455 | + | "number": 12, | |
| 2456 | + | "title": "Make the greeting name the caller", | |
| 2457 | + | "state": "open", | |
| 2458 | + | "via": ["attached", "claim"], | |
| 2459 | + | "session_ids": ["ses_01m4b2c3d4e5f6g7h8j9k0m1n2", "ses_01m4b2d8x1y2z3a4b5c6d7e8f9"] | |
| 2460 | + | } | |
| 2461 | + | ], | |
| 2462 | + | "skills": [ | |
| 2463 | + | { | |
| 2464 | + | "session_id": "ses_01m4b2c3d4e5f6g7h8j9k0m1n2", | |
| 2465 | + | "name": "work-on-issue", | |
| 2466 | + | "uses": 1 | |
| 2467 | + | } | |
| 2468 | + | ], | |
| 2469 | + | "root": null | |
| 2356 | 2470 | }, | |
| 2357 | − | "notes": "Entries are numbered within the session and come 500 at a time; send the last `seq` you have as `after` for the next page." | |
| 2471 | + | "notes": "Entries are numbered within the session and come 500 at a time; send the last `seq` you have as `after` for the next page. `children` are the session's subagents and theirs; `touched`, `links` and `skills` cover the whole tree." | |
| 2358 | 2472 | }, | |
| 2359 | 2473 | "list_sessions": { | |
| 2360 | 2474 | "query": { | |
| ⋯ | |||
| 2397 | 2511 | "text": "cargo test", | |
| 2398 | 2512 | "tool": "Bash", | |
| 2399 | 2513 | "key": "tool-7" | |
| 2514 | + | }, | |
| 2515 | + | { | |
| 2516 | + | "kind": "skill", | |
| 2517 | + | "text": "work-on-issue", | |
| 2518 | + | "key": "skill-3" | |
| 2400 | 2519 | } | |
| 2520 | + | ], | |
| 2521 | + | "touched": [ | |
| 2522 | + | { "kind": "file", "value": "src/greet.rs" }, | |
| 2523 | + | { "kind": "branch", "value": "greet-caller" } | |
| 2401 | 2524 | ] | |
| 2402 | 2525 | }, | |
| 2403 | 2526 | "response": { | |
| 2404 | − | "recorded": 2, | |
| 2527 | + | "recorded": 3, | |
| 2405 | 2528 | "duplicates": 0 | |
| 2406 | 2529 | }, | |
| 2407 | 2530 | "notes": "Up to 200 entries a request. An entry whose `key` the session recorded before is counted in `duplicates` and not recorded again. Entries of a session on a pull request show in that pull request's session too (read_session)." | |
| ⋯ | |||
| 6889 | 7012 | ], | |
| 6890 | 7013 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 6891 | 7014 | }, | |
| 7015 | + | "list_commits": { | |
| 7016 | + | "notes": "With `path`, `last_changed` is the commit that last changed that file or directory.", | |
| 7017 | + | "params": { | |
| 7018 | + | "owner": "flagon-io", | |
| 7019 | + | "name": "g1t" | |
| 7020 | + | }, | |
| 7021 | + | "query": { | |
| 7022 | + | "path": "scripts/deploy/npm-ci.mjs", | |
| 7023 | + | "limit": "1" | |
| 7024 | + | }, | |
| 7025 | + | "response": { | |
| 7026 | + | "repo": "flagon-io/g1t", | |
| 7027 | + | "commits": [ | |
| 7028 | + | { | |
| 7029 | + | "sha": "85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5", | |
| 7030 | + | "message": "The runner's broker test reads the relayed answer's body as chunks.", | |
| 7031 | + | "author": { "name": "Chase Pierce" }, | |
| 7032 | + | "authored_at": "2026-10-12T22:04:11Z", | |
| 7033 | + | "parents": ["75c6704f2b1a3c4d5e6f708192a3b4c5d6e7f809"], | |
| 7034 | + | "html_url": "https://g1t.sh/flagon-io/g1t/commit/85c083a0e4b1d2c3f4a5b6c7d8e9f0a1b2c3d4e5" | |
| 7035 | + | } | |
| 7036 | + | ], | |
| 7037 | + | "path": "scripts/deploy/npm-ci.mjs", | |
| 7038 | + | "last_changed": { | |
| 7039 | + | "sha": "5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e", | |
| 7040 | + | "message": "Installs in cloud jobs retry when npm's download breaks off.", | |
| 7041 | + | "author": { "name": "Chase Pierce" }, | |
| 7042 | + | "authored_at": "2026-10-13T01:12:40Z", | |
| 7043 | + | "parents": ["0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3"], | |
| 7044 | + | "html_url": "https://g1t.sh/flagon-io/g1t/commit/5d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e" | |
| 7045 | + | } | |
| 7046 | + | } | |
| 7047 | + | }, | |
| 7048 | + | "get_contents": { | |
| 7049 | + | "notes": "A file's text, or with no `path` (or a directory's) the entries and README. `ref` is a branch, tag or commit; the default branch when left out.", | |
| 7050 | + | "params": { | |
| 7051 | + | "owner": "flagon-io", | |
| 7052 | + | "name": "g1t" | |
| 7053 | + | }, | |
| 7054 | + | "query": { | |
| 7055 | + | "path": "CONTRIBUTING.md" | |
| 7056 | + | }, | |
| 7057 | + | "response": { | |
| 7058 | + | "type": "file", | |
| 7059 | + | "repo": "flagon-io/g1t", | |
| 7060 | + | "ref": "main", | |
| 7061 | + | "path": "CONTRIBUTING.md", | |
| 7062 | + | "size": 412, | |
| 7063 | + | "binary": false, | |
| 7064 | + | "text": "# Contributing\n\nRun the tests before you push:\n\n```sh\ncargo test --workspace\nnpm test --workspaces --if-present\n```\n", | |
| 7065 | + | "html_url": "https://g1t.sh/flagon-io/g1t/blob/main/CONTRIBUTING.md" | |
| 7066 | + | } | |
| 7067 | + | }, | |
| 7068 | + | "get_workspace_activity": { | |
| 7069 | + | "notes": "What shipped today across a workspace: `since` at midnight UTC and `kinds=merged,releases,deployments,pushes`. Each event links to its page on g1t (`url`). Ask g1t reads this to answer questions about a workspace.", | |
| 7070 | + | "params": { | |
| 7071 | + | "workspace": "flagon-io" | |
| 7072 | + | }, | |
| 7073 | + | "query": { | |
| 7074 | + | "since": "2026-10-12T00:00:00Z", | |
| 7075 | + | "kinds": "merged,releases,deployments" | |
| 7076 | + | }, | |
| 7077 | + | "response": { | |
| 7078 | + | "workspace": "flagon-io", | |
| 7079 | + | "since": "2026-10-12T00:00:00Z", | |
| 7080 | + | "until": "2026-10-12T21:40:00Z", | |
| 7081 | + | "repositories": 4, | |
| 7082 | + | "summary": { | |
| 7083 | + | "merged_pull_requests": 1, | |
| 7084 | + | "releases": 1, | |
| 7085 | + | "production_deployments": 1, | |
| 7086 | + | "failed_deployments": 0, | |
| 7087 | + | "default_branch_pushes": 0, | |
| 7088 | + | "failed_runs": 0 | |
| 7089 | + | }, | |
| 7090 | + | "events": [ | |
| 7091 | + | { | |
| 7092 | + | "kind": "deployment_succeeded", | |
| 7093 | + | "time": "2026-10-12T21:12:04Z", | |
| 7094 | + | "repo": "flagon-io/g1t", | |
| 7095 | + | "actor": "syntaqx", | |
| 7096 | + | "environment": "production", | |
| 7097 | + | "project": "g1t", | |
| 7098 | + | "branch": "main", | |
| 7099 | + | "commit": "75c6704f2b1a", | |
| 7100 | + | "url": "https://g1t.sh/flagon-io/g1t/deployments/dpl_01kkp3m8w2f6t9qh4c7d1r5n0x" | |
| 7101 | + | }, | |
| 7102 | + | { | |
| 7103 | + | "kind": "release_published", | |
| 7104 | + | "time": "2026-10-12T19:30:51Z", | |
| 7105 | + | "repo": "flagon-io/runner", | |
| 7106 | + | "actor": "syntaqx", | |
| 7107 | + | "tag": "v0.3.3", | |
| 7108 | + | "title": "Runner 0.3.3", | |
| 7109 | + | "url": "https://g1t.sh/flagon-io/runner/releases/tag/v0.3.3" | |
| 7110 | + | }, | |
| 7111 | + | { | |
| 7112 | + | "kind": "pull_merged", | |
| 7113 | + | "time": "2026-10-12T18:02:17Z", | |
| 7114 | + | "repo": "flagon-io/g1t", | |
| 7115 | + | "actor": "syntaqx", | |
| 7116 | + | "number": 412, | |
| 7117 | + | "title": "Ask g1t answers with the workspace's own AI provider", | |
| 7118 | + | "url": "https://g1t.sh/flagon-io/g1t/pull/412", | |
| 7119 | + | "issue": 398 | |
| 7120 | + | } | |
| 7121 | + | ], | |
| 7122 | + | "complete": true | |
| 7123 | + | } | |
| 7124 | + | }, | |
| 6892 | 7125 | "list_projects": { | |
| 6893 | 7126 | "params": { | |
| 6894 | 7127 | "workspace": "flagon-io" | |
| 248 | 248 | Op::Coordination(CoordinationOp::ReleaseClaim | CoordinationOp::HandOffIssue) => through::<work::Claim>(op, sent), | |
| 249 | 249 | Op::Coordination(CoordinationOp::ListClaims) => through::<Vec<work::Claim>>(op, sent), | |
| 250 | 250 | Op::Coordination(CoordinationOp::GetWork) => through::<work::WorkContext>(op, sent), | |
| 251 | + | Op::Coordination(CoordinationOp::StartAgentSession) => through::<g1t_contracts::agent_sessions::AgentSessionStarted>(op, sent), | |
| 251 | 252 | _ => sent, | |
| 252 | 253 | } | |
| 253 | 254 | } |
| 143 | 143 | route("DELETE", "/user/memberships/:workspace", Op::LeaveWorkspace, &[]), | |
| 144 | 144 | // A workspace's projects: what each is, where it runs, its links. | |
| 145 | 145 | route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]), | |
| 146 | + | // What happened across its repositories: merged pull requests, releases, deployments, pushes. | |
| 147 | + | route( | |
| 148 | + | "GET", | |
| 149 | + | "/workspaces/:workspace/activity", | |
| 150 | + | Op::GetWorkspaceActivity, | |
| 151 | + | &[("since", "since"), ("until", "until"), ("kinds", "kinds"), ("limit", "limit")], | |
| 152 | + | ), | |
| 146 | 153 | route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]), | |
| 147 | 154 | route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]), | |
| 148 | 155 | // A project's observability: what its software did, and how it is collected. | |
| ⋯ | |||
| 343 | 350 | ), | |
| 344 | 351 | route("POST", "/repos", Op::CreateRepo, &[]), | |
| 345 | 352 | route("GET", "/repos/:owner/:name", Op::GetRepo, &[]), | |
| 353 | + | // A file's text or a directory's entries: `path` and `ref` in the query. | |
| 354 | + | route("GET", "/repos/:owner/:name/contents", Op::GetContents, &[("path", "path"), ("ref", "ref")]), | |
| 355 | + | route("GET", "/repos/:owner/:name/commits", Op::ListCommits, &[("ref", "ref"), ("path", "path"), ("limit", "limit")]), | |
| 346 | 356 | // A branch, tag or commit as one archive: a redirect to a link good for | |
| 347 | 357 | // ten minutes, as GitHub's go to codeload. | |
| 348 | 358 | route("GET", "/repos/:owner/:name/tarball/:ref", Op::Archives(ArchiveOp::DownloadTarball), &[]), | |
| ⋯ | |||
| 611 | 621 | "GET", | |
| 612 | 622 | "/repos/:owner/:name/sessions", | |
| 613 | 623 | Op::Coordination(CoordinationOp::ListSessions), | |
| 614 | − | &[("state", "state"), ("issue", "issue"), ("pull", "pull")], | |
| 624 | + | &[("state", "state"), ("issue", "issue"), ("pull", "pull"), ("path", "path"), ("subagents", "subagents")], | |
| 615 | 625 | ), | |
| 626 | + | route("POST", "/repos/:owner/:name/agent-sessions", Op::Coordination(CoordinationOp::StartAgentSession), &[]), | |
| 616 | 627 | route("GET", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::GetSession), &[("after", "after")]), | |
| 617 | 628 | route("PATCH", "/repos/:owner/:name/sessions/:id", Op::Coordination(CoordinationOp::UpdateSession), &[]), | |
| 618 | 629 | route("POST", "/repos/:owner/:name/sessions/:id/end", Op::Coordination(CoordinationOp::EndSession), &[]), | |
| ⋯ | |||
| 836 | 847 | "GET", | |
| 837 | 848 | "/repos/:owner/:name/actions/jobs/:job/logs", | |
| 838 | 849 | Op::GetJobLogs, | |
| 839 | − | &[("after", "after")], | |
| 850 | + | &[("after", "after"), ("tail", "tail")], | |
| 840 | 851 | ), | |
| 841 | 852 | // Packages, at GitHub's addresses with the workspace in place of the | |
| 842 | 853 | // organization. A name with a slash is one URL-encoded segment. | |
| 75 | 75 | actions: &[ | |
| 76 | 76 | a("list", Op::ListRepos, "Repositories you can see"), | |
| 77 | 77 | a("get", Op::GetRepo, "One repository"), | |
| 78 | + | a("commits", Op::ListCommits, "A branch's commits with their messages (why each was made); with path, the commit that last changed it"), | |
| 79 | + | a("contents", Op::GetContents, "A file's text, or a directory's entries and README, at a branch, tag or commit"), | |
| 78 | 80 | a("tarball", Op::Archives(ArchiveOp::DownloadTarball), "A 10-minute link to a branch, tag or commit as a .tar.gz"), | |
| 79 | 81 | a("zipball", Op::Archives(ArchiveOp::DownloadZipball), "A 10-minute link to a branch, tag or commit as a .zip"), | |
| 80 | 82 | a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"), | |
| ⋯ | |||
| 190 | 192 | Tool { | |
| 191 | 193 | name: "session", | |
| 192 | 194 | title: "Sessions", | |
| 193 | − | description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id), `update` as a heartbeat or to attach an issue, pull request, branch or head commit, `record` prompts, reasoning and tool calls (idempotent on key), `end` when done. A session goes idle after 15 minutes without a heartbeat and ends after 2 hours.", | |
| 195 | + | description: "Your harness session on a repository, so others see who is working on what: `start` once (idempotent on harness_session_id; a subagent starts its own with parent_id), `update` as a heartbeat or to attach an issue, pull request, branch or head commit, `record` prompts, reasoning, tool calls, skills used and what it touched (idempotent on key), `end` when done with what it reported. `list` with an issue, or a path, shows who else (sibling subagents too) is on the same work. `dispatch` starts work on an issue in a session on the person's own Claude. A session goes idle after 15 minutes without a heartbeat and ends after 2 hours.", | |
| 194 | 196 | default_action: None, | |
| 195 | 197 | actions: &[ | |
| 196 | 198 | a("start", Op::Coordination(CoordinationOp::StartSession), "Start one, or get the one started with this harness_session_id"), | |
| ⋯ | |||
| 198 | 200 | a("record", Op::Coordination(CoordinationOp::RecordSessionEntries), "Record prompts, reasoning and tool calls; a key makes each idempotent"), | |
| 199 | 201 | a("end", Op::Coordination(CoordinationOp::EndSession), "End it"), | |
| 200 | 202 | a("get", Op::Coordination(CoordinationOp::GetSession), "One session and its entries"), | |
| 201 | − | a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by issue or pull request"), | |
| 203 | + | a("list", Op::Coordination(CoordinationOp::ListSessions), "A repository's sessions: active, idle or ended, by issue, pull request or file touched; with subagents, each tree whole"), | |
| 204 | + | a("dispatch", Op::Coordination(CoordinationOp::StartAgentSession), "Start work on an issue in a session on the person's own Claude (Managed Agents with the workspace's Anthropic key), or get a Claude Code hand-off"), | |
| 202 | 205 | ], | |
| 203 | 206 | }, | |
| 204 | 207 | Tool { | |
| ⋯ | |||
| 221 | 224 | a("get", Op::GetWorkflow, "One workflow by id, file or name, with its file on a branch or tag"), | |
| 222 | 225 | a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"), | |
| 223 | 226 | a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps; an earlier attempt with attempt"), | |
| 224 | − | a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"), | |
| 227 | + | a("job_logs", Op::GetJobLogs, "A job's log after a sequence number, or its last lines with tail (why it failed)"), | |
| 225 | 228 | a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"), | |
| 226 | 229 | a("cancel", Op::CancelWorkflowRun, "Cancel a run, letting its jobs clean up; force stops them outright"), | |
| 227 | 230 | a("rerun", Op::RerunWorkflowRun, "Run a finished run again: all, failed_only, or one job; debug for debug logging"), | |
| ⋯ | |||
| 404 | 407 | Tool { | |
| 405 | 408 | name: "workspace", | |
| 406 | 409 | title: "Workspaces", | |
| 407 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations, set rulesets that hold across its repositories, read and change its projects (what each is, where it runs, its links), and keep your own pinned projects at the top of its sidebar.", | |
| 410 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): read what happened across its repositories (`activity`: what merged, shipped and deployed, by whom, over a span), create, update or delete one, invite members, connect integrations, set rulesets that hold across its repositories, read and change its projects (what each is, where it runs, its links), and keep your own pinned projects at the top of its sidebar.", | |
| 408 | 411 | default_action: None, | |
| 409 | 412 | actions: &[ | |
| 410 | 413 | a("get", Op::GetWorkspace, "A workspace's details and settings"), | |
| ⋯ | |||
| 424 | 427 | a("update_integration", Op::UpdateIntegration, "Change one: rotate its key or its settings"), | |
| 425 | 428 | a("disconnect_integration", Op::DisconnectIntegration, "Remove one"), | |
| 426 | 429 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 430 | + | a("activity", Op::GetWorkspaceActivity, "What happened across its repositories over a span: merged pull requests, releases, deployments, pushes, issues; what shipped"), | |
| 427 | 431 | a("list_projects", Op::ListProjects, "Its projects you can see: what each is, where it runs, its links"), | |
| 428 | 432 | a("get_project", Op::GetProject, "One project"), | |
| 429 | 433 | a("update_project", Op::UpdateProject, "Change a project's name, description, kind, where it runs or its links"), | |
| 1 | 1 | --- | |
| 2 | 2 | title: Ask g1t | |
| 3 | − | description: Ask g1t about your work from the top bar of any page. It answers with your workspace's own AI provider and key, knows which page you are on, and reads the issue or pull request in front of you. | |
| 3 | + | description: Ask g1t about your work from the top bar of any page. It answers with your workspace's own AI provider and key, reads g1t as you with read-only access, and shows each step it takes and the pages it drew on. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Ask**, in the top bar, opens a conversation with g1t beside the page | |
| ⋯ | |||
| 29 | 29 | an endpoint that does not list its models, the model's name. | |
| 30 | 30 | ||
| 31 | 31 | Every member can then ask. Until a provider is chosen, Ask says so: owners | |
| 32 | − | get a link to set it up, and members are told to ask an owner. **Turn Ask | |
| 32 | + | get a link to set it up, and members are told to ask an owner. A message | |
| 33 | + | to @g1t in Chat before then gets a short reply from g1t saying the same. **Turn Ask | |
| 33 | 34 | off** on the same page stops it answering, and removing the provider it | |
| 34 | 35 | uses does too. | |
| 35 | 36 | ||
| ⋯ | |||
| 37 | 38 | Anthropic-compatible endpoints through Anthropic's Messages API, and the | |
| 38 | 39 | rest through OpenAI's Chat Completions API. | |
| 39 | 40 | ||
| 40 | − | ## What it knows | |
| 41 | + | ## What it can read | |
| 41 | 42 | ||
| 42 | − | Each question is sent with a short note saying that this is Ask in your | |
| 43 | − | workspace and which page you are on: the repository, and the issue or pull | |
| 43 | + | Ask reads g1t to answer: whatever you can read, and nothing more. It uses | |
| 44 | + | the same tools the [g1t MCP server](/reference/mcp/) gives any agent, run | |
| 45 | + | as you: repositories and their files and commits, issues, pull requests | |
| 46 | + | and their changes, workflow runs and their logs, deployments, releases, | |
| 47 | + | production telemetry, and what happened across your workspace. Every read | |
| 48 | + | goes through the same permission checks as your own, so it never sees a | |
| 49 | + | repository, issue or log you could not open yourself. | |
| 50 | + | ||
| 51 | + | Each question asked from the panel is sent with a short note saying that | |
| 52 | + | this is Ask in your workspace and which page you are on (a question | |
| 53 | + | written in Chat says you are in Chat): the repository, and the issue or pull | |
| 44 | 54 | request number, when there is one. On an issue or a pull request, g1t also | |
| 45 | 55 | reads its title, state and description, as you can see them, and sends | |
| 46 | 56 | those along, so you can ask "what is left to do here?" without pasting | |
| 47 | 57 | anything. | |
| 48 | 58 | ||
| 49 | − | That is all it is given. Ask cannot change anything in g1t or read | |
| 50 | − | anything beyond that page; if you ask it to, it says so. Its answers come | |
| 51 | − | from a model and can be wrong. | |
| 59 | + | It only reads. For each answer g1t makes a credential that is yours, holds | |
| 60 | + | read permissions only, reaches only this workspace, and ends with the | |
| 61 | + | answer, a few minutes at most. Nobody sees it: not the model, not your | |
| 62 | + | browser. Anything that would change g1t is refused, whatever your role. | |
| 63 | + | ||
| 64 | + | Each question is also sent with which page you are on and today's date. On | |
| 65 | + | an issue or a pull request, g1t sends its title, state and description, so | |
| 66 | + | you can ask "what is left to do here?" without pasting anything. Some | |
| 67 | + | questions it answers well: | |
| 68 | + | ||
| 69 | + | - What shipped today across all our projects? | |
| 70 | + | - Why did the last Deploy fail? | |
| 71 | + | - Summarize this pull request. | |
| 72 | + | - How do I run this project's tests? | |
| 73 | + | - What changed in this file recently, and why? | |
| 74 | + | ||
| 75 | + | For a question about the whole workspace, Ask reads the workspace's | |
| 76 | + | activity first: merged pull requests, releases, deployments, workflow runs | |
| 77 | + | and pushes across every repository you can read, in one read | |
| 78 | + | (`activity` on the MCP server's `workspace` tool, or | |
| 79 | + | [`GET /workspaces/{workspace}/activity`](/reference/api/workspaces/get-workspace-activity/)). | |
| 80 | + | ||
| 81 | + | Answers link to what they name on g1t, and every number, title and link | |
| 82 | + | comes from what Ask read; when something does not exist, it says so. | |
| 83 | + | Answers come from a model and can still be wrong: follow the links to | |
| 84 | + | check. | |
| 85 | + | ||
| 86 | + | ### Steps and sources | |
| 87 | + | ||
| 88 | + | While Ask works, the panel shows each step as it runs, such as **Read | |
| 89 | + | activity in acme since 2026-10-12** or **Read pull request #7 in | |
| 90 | + | acme/web**, with what it found and a link to the page it read. A step that | |
| 91 | + | was refused, or that failed, says why. Once the answer is written the steps | |
| 92 | + | fold into one line you can open again. Under the answer, **Sources** lists | |
| 93 | + | the pages on g1t it links to, and the ones it read directly. | |
| 94 | + | ||
| 95 | + | ### Asking for work | |
| 96 | + | ||
| 97 | + | Ask does not do the work itself: it does not write code or change | |
| 98 | + | anything. g1t coordinates the work, and a session in your own harness does | |
| 99 | + | it. When you ask for work to be done, such as planning a change, fixing a | |
| 100 | + | bug or implementing an issue, Ask reads the issue the work belongs to, | |
| 101 | + | checks whether a session already holds it, and proposes a session: a card | |
| 102 | + | under the answer that names the issue (or a new one it would open) and | |
| 103 | + | what the session should do (plan, implement or review). | |
| 104 | + | ||
| 105 | + | Nothing starts until you choose on the card: | |
| 106 | + | ||
| 107 | + | - **Start session** runs it on your workspace's Anthropic account as a | |
| 108 | + | [Claude Managed Agents session](/guides/sessions/), with g1t's tools and | |
| 109 | + | skills beside your own. It shows when an owner has connected Anthropic. | |
| 110 | + | - **Copy Claude Code command** copies a `claude` command that runs it on | |
| 111 | + | your computer with the [g1t plugin](/guides/claude-code-plugin/), with | |
| 112 | + | your own skills, settings and subagents. | |
| 113 | + | ||
| 114 | + | Either way the session is tied to the issue: it shows on the issue, on the | |
| 115 | + | repository's Work page and on its own session page, with its subagents, and | |
| 116 | + | Ask can tell you where it stands. If a session already holds the issue, Ask | |
| 117 | + | links it rather than proposing another. | |
| 118 | + | ||
| 119 | + | ## One conversation, in Ask and in Chat | |
| 120 | + | ||
| 121 | + | Ask is your **direct message with @g1t** in [Chat](/guides/chat/#talk-with-g1t). | |
| 122 | + | What you ask in the panel is posted there, and g1t's answer is written | |
| 123 | + | there as it streams, so it shows in both places at once: start a question | |
| 124 | + | on an issue page, and read the answer later in Chat, on your phone, or | |
| 125 | + | in the desktop app. **Open in Chat** at the top of the panel takes you to | |
| 126 | + | it. Nobody else can read it, and it stays until you delete it. | |
| 127 | + | ||
| 128 | + | - **New conversation** leaves a mark in the direct message. From then on, | |
| 129 | + | g1t reads only what comes after it, and the panel shows only that; what | |
| 130 | + | came before stays in Chat to read. | |
| 131 | + | - **Stop**, or **Esc** while an answer is coming, ends the answer; what had | |
| 132 | + | come so far stays. Writing again while g1t is still answering does the | |
| 133 | + | same: g1t stops and answers your newest words, reading both. | |
| 134 | + | - **Answer again**, under g1t's latest answer, has it write that answer | |
| 135 | + | anew. | |
| 136 | + | - **Copy** copies an answer as Markdown. Each answer shows the provider | |
| 137 | + | and model that wrote it. | |
| 52 | 138 | ||
| 53 | − | ## Conversations | |
| 139 | + | Answers are Markdown: code is highlighted with its language and a Copy | |
| 140 | + | button, tables scroll within the answer, and issues, pull requests and | |
| 141 | + | commits (`acme/web#12`, a link to one, a commit hash) show as chips that | |
| 142 | + | open their title and state when you hover over them. Links to | |
| 143 | + | [artifacts](/guides/artifacts/) show their card. Pictures show only from | |
| 144 | + | g1t itself; any other picture is a link. | |
| 54 | 145 | ||
| 55 | − | Ask keeps one conversation per workspace, in your browser tab: it stays as | |
| 56 | − | you move between pages and when you reload, and is gone when you close the | |
| 57 | − | tab. **New conversation** starts over, and **Stop** ends an answer while it | |
| 58 | − | is being written; what had come so far stays. Each answer shows the | |
| 59 | − | provider and model that wrote it. | |
| 146 | + | In the panel, **Enter** sends, **Shift+Enter** starts a new line, and | |
| 147 | + | **⌘K** (**Ctrl+K**) puts you back in the box. On an empty conversation, | |
| 148 | + | the panel suggests a few questions for the page you are on. | |
| 60 | 149 | ||
| 61 | 150 | ## Limits | |
| 62 | 151 | ||
| 63 | − | - 20 questions a minute for each person. | |
| 64 | − | - A question can be up to 16,000 characters, and a conversation up to | |
| 65 | − | 64,000 characters; past that, start a new one. Only the conversation's | |
| 66 | − | latest 40 messages are sent. | |
| 152 | + | - 20 questions a minute for each person; past that, g1t says to wait a | |
| 153 | + | moment. | |
| 154 | + | - g1t reads the conversation since the last **New conversation**, up to | |
| 155 | + | its latest 40 messages and 64,000 characters, the newest first; a | |
| 156 | + | message longer than 16,000 characters is cut to that. | |
| 67 | 157 | - An answer is at most 4,096 tokens long, and says when it was cut off. | |
| 158 | + | - An answer reads g1t in at most 10 rounds and 30 reads, over at most 2 | |
| 159 | + | minutes; then it answers with what it has read and says what it could | |
| 160 | + | not check. | |
| 161 | + | - The model is given at most 16,000 characters of any one read and 120,000 | |
| 162 | + | in all. A longer read is trimmed: long lists keep their first items (a | |
| 163 | + | job's log keeps its end, where a failure says why), long texts are | |
| 164 | + | shortened, and each says how much was left out. | |
| 165 | + | - Its reads count against the [API's rate limit](/reference/rate-limits/) | |
| 166 | + | like any other access token's. Each step is also a call to your AI | |
| 167 | + | provider, on your provider's bill. | |
| 139 | 139 | | --- | --- | | |
| 140 | 140 | | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. | | |
| 141 | 141 | | **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. | | |
| 142 | − | | **People** | Your direct messages, the latest first. The pencil starts a new message. Empty, it says **Message a teammate.** | | |
| 142 | + | | **Direct messages** | [g1t](#talk-with-g1t) first, then your direct messages, the latest first. The pencil starts a new message. Empty, it says **Message a teammate.** | | |
| 143 | 143 | ||
| 144 | 144 | Unread conversations are in bold with a count; mentions of you are counted | |
| 145 | 145 | in lavender. **All**, **Unread** and **Mentions** filter every section, and | |
| ⋯ | |||
| 159 | 159 | order you pick them in. | |
| 160 | 160 | - A DM with only yourself is a place for notes. | |
| 161 | 161 | ||
| 162 | + | ## Talk with g1t | |
| 163 | + | ||
| 164 | + | **g1t** is at the top of your direct messages, with its mark for a face. | |
| 165 | + | Write to it like anyone else and it answers, with your workspace's own AI | |
| 166 | + | provider and the model an owner chose: see [Ask g1t](/guides/ask/) for | |
| 167 | + | setting it up and what it knows. | |
| 168 | + | ||
| 169 | + | - It is the same conversation as **Ask** in the top bar: a question asked | |
| 170 | + | there is posted here with the page it was asked from, and the answer | |
| 171 | + | shows in both places as it is written. | |
| 172 | + | - The answer streams in as g1t writes it. **Stop answering**, over the box | |
| 173 | + | you write in, ends it; what came stays. Writing again while it answers | |
| 174 | + | does the same, and g1t answers your newest words. | |
| 175 | + | - **New conversation**, at the top, leaves a mark: g1t reads only what is | |
| 176 | + | said after it. Everything before stays to read. | |
| 177 | + | - Under its latest answer, **Answer again** has it answer anew, and | |
| 178 | + | **Copy** copies an answer. Each answer says which provider and model | |
| 179 | + | wrote it. | |
| 180 | + | - Until an owner chooses a provider, g1t replies with where to choose one. | |
| 181 | + | - Only you and g1t are in it: g1t can't be added to a group message or a | |
| 182 | + | channel, and mentioning `@g1t` in a channel doesn't ask it anything. | |
| 183 | + | You can delete g1t's messages in it, as well as your own. | |
| 184 | + | - It notifies you of an answer the way any direct message does, once the | |
| 185 | + | answer is done. | |
| 186 | + | ||
| 162 | 187 | ## Threads | |
| 163 | 188 | ||
| 164 | 189 | Reply to any message to start a thread under it. The thread keeps a side | |