Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 1 | //! Runs: every sandbox g1t starts for checks, the merge queue and finding |
| 2 | //! out whether a pull request merges cleanly, as a record people can watch | |
| 3 | //! and look back on. Runs of g1t's former agents are kept and listed too. | |
| Inbox: the events service tells people what needs them as events arrive | 4 | //! |
| 5 | //! The runner service opens a run as it starts a sandbox (`open_run`) and | |
| 6 | //! the sandbox reports its steps through the API with the run's one-time | |
| 7 | //! token (`report_run`), as checks do. The runner closes it when the | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 8 | //! sandbox stops. |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 9 | //! |
| 10 | //! Sessions are the read side of what coding agents record on a pull | |
| 11 | //! request (`append_session`): `list_sessions` and `get_session`. | |
| Inbox: the events service tells people what needs them as events arrive | 12 | |
| 13 | use g1t_contracts::agents::*; | |
| 14 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | use g1t_contracts::time::rfc3339; | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 16 | use g1t_contracts::work::{PullStatus, SessionEntry}; |
| Inbox: the events service tells people what needs them as events arrive | 17 | use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer, new_id}; |
| 18 | use g1t_kit::now_ms; | |
| 19 | use serde::Deserialize; | |
| 20 | use worker::Result; | |
| 21 | use worker::wasm_bindgen::JsValue; | |
| 22 | ||
| 23 | use crate::checks::{hash, new_token}; | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 24 | use crate::rows::SessionRow; |
| Inbox: the events service tells people what needs them as events arrive | 25 | use crate::{Work, optional}; |
| 26 | ||
| Merge branch 'run-notes' into fast-push | 27 | /// How the runner starts a step that notes something about the sandbox |
| 28 | /// rather than what the run is doing: a host it was refused (the runner's | |
| 29 | /// egress.ts `blockedStep`). | |
| Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build. | 30 | /// A workspace renamed: its runs move to the slug it has now. `?1` is the |
| 31 | /// current slug, `?2` a stale one (see `g1t_kit::rename`). | |
| 32 | pub(crate) const RENAMED: &[&str] = &[ | |
| 33 | "UPDATE agent_runs SET workspace = ?1 WHERE workspace = ?2", | |
| 34 | "UPDATE agent_runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 35 | ]; | |
| 36 | ||
| 37 | /// A repository transferred (see `g1t_kit::transfer`): its runs go with | |
| 38 | /// it, as its issues and pull requests do by id. `?1`/`?2` the path now | |
| 39 | /// and before, `?3`/`?4` the workspaces, `?5` the repository's id. | |
| 40 | pub(crate) const TRANSFERRED: &[&str] = &[ | |
| 41 | "UPDATE agent_runs SET workspace = ?3, repo = ?1 WHERE repo_id = ?5 AND repo = ?2", | |
| 42 | ]; | |
| 43 | ||
| Merge branch 'run-notes' into fast-push | 44 | const NOTE_PREFIXES: [&str; 1] = ["Blocked: "]; |
| 45 | ||
| 46 | /// Where a report leaves the run: its newest step that says what the run | |
| 47 | /// is doing. A note (a refused host) stays in the steps but never stands | |
| 48 | /// for the run, which carries on past it. `None` when there is only notes. | |
| 49 | fn current_step(steps: &[String]) -> Option<String> { | |
| 50 | steps.iter().rev().find(|step| !NOTE_PREFIXES.iter().any(|prefix| step.starts_with(prefix))).cloned() | |
| 51 | } | |
| 52 | ||
| Inbox: the events service tells people what needs them as events arrive | 53 | /// The most steps a run keeps; older ones fall off the start. |
| 54 | const MAX_STEPS: u32 = 200; | |
| 55 | /// The most steps taken from one report. | |
| 56 | const MAX_STEPS_PER_REPORT: usize = 20; | |
| 57 | /// The longest a step is kept. | |
| 58 | const MAX_STEP_CHARS: usize = 240; | |
| 59 | /// A run that has said nothing for this long is taken to have died. | |
| 60 | const SILENT_HOURS: u64 = 3; | |
| 61 | const DEFAULT_LIST: u32 = 50; | |
| 62 | const MAX_LIST: u32 = 200; | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 63 | /// The most session entries one view returns. |
| 64 | const SESSION_ENTRIES: u32 = 2000; | |
| Inbox: the events service tells people what needs them as events arrive | 65 | |
| 66 | /// Every column but the steps, which only a run's own page needs. | |
| 67 | const COLUMNS: &str = "id, workspace, repo_id, repo, number, pull_id, kind, agent, model, status, | |
| 68 | step, step_count, cost_usd, turns, sandbox, token_hash, started_by, error, created_at, | |
| 69 | started_at, finished_at, updated_at, budget_usd, time_cap_minutes, halted, | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 70 | COALESCE((SELECT title FROM pulls WHERE pulls.id = agent_runs.pull_id), agent_runs.title) AS title"; |
| Inbox: the events service tells people what needs them as events arrive | 71 | |
| 72 | #[derive(Deserialize)] | |
| 73 | pub(crate) struct RunRow { | |
| 74 | id: String, | |
| 75 | #[allow(dead_code)] | |
| 76 | workspace: String, | |
| 77 | #[allow(dead_code)] | |
| 78 | repo_id: String, | |
| 79 | repo: String, | |
| 80 | number: Option<u32>, | |
| 81 | pub(crate) pull_id: Option<String>, | |
| 82 | kind: String, | |
| 83 | agent: String, | |
| 84 | model: Option<String>, | |
| 85 | status: String, | |
| 86 | step: Option<String>, | |
| 87 | #[serde(default)] | |
| 88 | steps: Option<String>, | |
| 89 | step_count: u32, | |
| 90 | cost_usd: Option<f64>, | |
| 91 | turns: Option<u32>, | |
| 92 | token_hash: String, | |
| 93 | started_by: Option<String>, | |
| 94 | error: Option<String>, | |
| 95 | created_at: String, | |
| 96 | started_at: Option<String>, | |
| 97 | finished_at: Option<String>, | |
| 98 | updated_at: String, | |
| 99 | title: Option<String>, | |
| 100 | #[serde(default)] | |
| 101 | budget_usd: Option<f64>, | |
| 102 | #[serde(default)] | |
| 103 | time_cap_minutes: Option<u32>, | |
| 104 | #[serde(default)] | |
| 105 | halted: Option<String>, | |
| 106 | } | |
| 107 | ||
| 108 | impl RunRow { | |
| 109 | fn status(&self) -> RunStatus { | |
| 110 | RunStatus::parse(&self.status).unwrap_or(RunStatus::Failed) | |
| 111 | } | |
| 112 | ||
| 113 | /// The run as `member` (or not) may see it: model and cost are the | |
| 114 | /// workspace's business. | |
| 115 | fn into_run(self, member: bool) -> AgentRun { | |
| 116 | let (namespace, name) = self.repo.split_once('/').unwrap_or((&self.repo, "")); | |
| 117 | AgentRun { | |
| 118 | repo: RepoPath { | |
| 119 | namespace: namespace.to_owned(), | |
| 120 | name: name.to_owned(), | |
| 121 | }, | |
| 122 | status: self.status(), | |
| 123 | kind: RunKind::parse(&self.kind).unwrap_or(RunKind::Implement), | |
| 124 | steps: self | |
| 125 | .steps | |
| 126 | .as_deref() | |
| 127 | .and_then(|steps| serde_json::from_str(steps).ok()) | |
| 128 | .unwrap_or_default(), | |
| 129 | id: self.id, | |
| 130 | number: self.number.filter(|number| *number > 0), | |
| 131 | title: self.title, | |
| 132 | agent: self.agent, | |
| 133 | model: self.model.filter(|_| member), | |
| 134 | step: self.step, | |
| 135 | step_count: self.step_count, | |
| 136 | started_by: self.started_by, | |
| 137 | error: self.error, | |
| 138 | cost_usd: self.cost_usd.filter(|_| member), | |
| 139 | turns: self.turns, | |
| 140 | budget_usd: self.budget_usd.filter(|_| member), | |
| 141 | time_cap_minutes: self.time_cap_minutes, | |
| 142 | halted: self.halted, | |
| 143 | created_at: self.created_at, | |
| 144 | started_at: self.started_at, | |
| 145 | finished_at: self.finished_at, | |
| 146 | updated_at: self.updated_at, | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | /// One line, short enough to read at a glance. | |
| 152 | pub(crate) fn one_line(text: &str, limit: usize) -> String { | |
| 153 | let line = text.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 154 | if line.chars().count() <= limit { | |
| 155 | return line; | |
| 156 | } | |
| 157 | let mut short: String = line.chars().take(limit.saturating_sub(1)).collect(); | |
| 158 | short.push('…'); | |
| 159 | short | |
| 160 | } | |
| 161 | ||
| 162 | /// A principal that can read any repository of `namespace`, for lookups a | |
| 163 | /// trusted service asks for. What it is given reads and nothing more. | |
| 164 | pub(crate) fn member_of(actor: &User, namespace: &str) -> Viewer { | |
| 165 | let mut viewer = actor.clone(); | |
| 166 | let slug = namespace.to_lowercase(); | |
| 167 | if !viewer.is_member(&slug) { | |
| 168 | viewer.workspaces.push(Membership { | |
| 169 | base_permission: Some(g1t_contracts::access::BasePermission::Read), | |
| 170 | ..Membership::member(slug) | |
| 171 | }); | |
| 172 | } | |
| 173 | Some(viewer) | |
| 174 | } | |
| 175 | ||
| 176 | /// Whether the viewer belongs to the workspace: what a run spent, its model | |
| 177 | /// and its budget are the workspace's business, not every reader's (an | |
| 178 | /// outside collaborator sees the work, not the bill). | |
| 179 | fn is_member(viewer: &Viewer, namespace: &str) -> bool { | |
| 180 | viewer | |
| 181 | .as_ref() | |
| 182 | .is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase())) | |
| 183 | } | |
| 184 | ||
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 185 | #[derive(Deserialize)] |
| 186 | struct SessionListRow { | |
| 187 | pull_id: String, | |
| 188 | number: u32, | |
| 189 | title: String, | |
| 190 | status: PullStatus, | |
| 191 | agent: String, | |
| 192 | entries: u32, | |
| 193 | tools: u32, | |
| 194 | started_at: String, | |
| 195 | last_at: String, | |
| 196 | prompt: Option<String>, | |
| 197 | } | |
| 198 | ||
| 199 | #[derive(Deserialize)] | |
| 200 | struct RunSumRow { | |
| 201 | pull_id: String, | |
| 202 | kind: String, | |
| 203 | status: String, | |
| 204 | cost_usd: Option<f64>, | |
| 205 | } | |
| 206 | ||
| Inbox: the events service tells people what needs them as events arrive | 207 | impl Work { |
| 208 | /// The repository at `path`, if `viewer` may see it, and whether they | |
| 209 | /// are a member of its workspace (which shows what runs cost). | |
| 210 | async fn visible_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<(Repo, bool)>> { | |
| 211 | Ok(match self.repo(path, viewer).await? { | |
| 212 | Outcome::Ok(repo) => { | |
| 213 | let member = is_member(viewer, &repo.namespace); | |
| 214 | Outcome::Ok((repo, member)) | |
| 215 | } | |
| 216 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 217 | }) | |
| 218 | } | |
| 219 | ||
| 220 | async fn run_row(&self, id: &str) -> Result<Option<RunRow>> { | |
| 221 | self.db | |
| 222 | .prepare(format!("SELECT {COLUMNS}, steps FROM agent_runs WHERE id = ?")) | |
| 223 | .bind(&[id.into()])? | |
| 224 | .first::<RunRow>(None) | |
| 225 | .await | |
| 226 | } | |
| 227 | ||
| 228 | pub(crate) async fn open_run(&self, a: OpenRunArgs) -> Result<Outcome<AgentRunTicket>> { | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 229 | // Only sandboxes that are not agents are started now; earlier |
| 230 | // kinds stay readable on the runs already recorded. | |
| 231 | if !matches!(a.kind, RunKind::Checks | RunKind::Queue | RunKind::Mergecheck) { | |
| 232 | return Ok(Outcome::fail( | |
| 233 | FailureCode::Invalid, | |
| 234 | format!("A {} run is no longer started.", a.kind.as_str()), | |
| 235 | )); | |
| 236 | } | |
| Inbox: the events service tells people what needs them as events arrive | 237 | // The runner is trusted: it names the repository it is starting a |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 238 | // sandbox in, whoever the sandbox acts as. The run records where |
| 239 | // the repository is now, looked up by its id, not the path it was | |
| 240 | // named by, which may be from before a transfer or a rename. | |
| 241 | let (repo_id, path) = match &a.pull_id { | |
| Inbox: the events service tells people what needs them as events arrive | 242 | Some(pull_id) => match self.pull_by_id(pull_id).await? { |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 243 | Some(pull) => { |
| 244 | let now: Option<RepoPath> = g1t_kit::call( | |
| 245 | &self.repos, | |
| 246 | "path_by_id", | |
| 247 | &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() }, | |
| 248 | ) | |
| 249 | .await?; | |
| 250 | (pull.repo_id, now.unwrap_or_else(|| a.repo.clone())) | |
| 251 | } | |
| Inbox: the events service tells people what needs them as events arrive | 252 | None => return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found.")), |
| 253 | }, | |
| 254 | None => match self.repo(&a.repo, &member_of(&a.actor, &a.repo.namespace)).await? { | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 255 | Outcome::Ok(repo) => (repo.id, RepoPath { namespace: repo.namespace, name: repo.name }), |
| Inbox: the events service tells people what needs them as events arrive | 256 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 257 | }, | |
| 258 | }; | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 259 | let namespace = path.namespace.to_lowercase(); |
| Inbox: the events service tells people what needs them as events arrive | 260 | // Nothing new starts on an archived or deleted repository. |
| 261 | if !self.repo_active(&repo_id).await? { | |
| 262 | return Ok(Outcome::fail( | |
| 263 | FailureCode::Forbidden, | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 264 | format!("{}/{} is archived or deleted, so nothing new starts on it.", path.namespace, path.name), |
| Inbox: the events service tells people what needs them as events arrive | 265 | )); |
| 266 | } | |
| 267 | let now = now_ms(); | |
| 268 | let id = new_id("arn", now); | |
| 269 | let token = new_token(); | |
| 270 | let timestamp = rfc3339(now); | |
| 271 | let agent = a | |
| 272 | .agent | |
| 273 | .clone() | |
| 274 | .unwrap_or_else(|| g1t_contracts::identity::AGENT_NAME.to_owned()); | |
| 275 | self.db | |
| 276 | .prepare( | |
| 277 | "INSERT INTO agent_runs | |
| 278 | (id, workspace, repo_id, repo, number, pull_id, title, kind, agent, model, status, | |
| 279 | sandbox, token_hash, started_by, created_at, updated_at, budget_usd, time_cap_minutes) | |
| 280 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?)", | |
| 281 | ) | |
| 282 | .bind(&[ | |
| 283 | id.as_str().into(), | |
| 284 | namespace.into(), | |
| 285 | repo_id.into(), | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 286 | format!("{}/{}", path.namespace, path.name).into(), |
| Inbox: the events service tells people what needs them as events arrive | 287 | a.number.filter(|n| *n > 0).map_or(JsValue::NULL, JsValue::from), |
| 288 | optional(&a.pull_id), | |
| 289 | optional(&a.title.map(|title| one_line(&title, 200))), | |
| 290 | a.kind.as_str().into(), | |
| 291 | agent.into(), | |
| 292 | optional(&a.model), | |
| 293 | a.sandbox.into(), | |
| 294 | hash(&token).into(), | |
| 295 | optional(&a.started_by), | |
| 296 | timestamp.as_str().into(), | |
| 297 | timestamp.as_str().into(), | |
| 298 | a.budget_usd.filter(|usd| usd.is_finite() && *usd > 0.0).map_or(JsValue::NULL, JsValue::from), | |
| 299 | a.time_cap_minutes.map_or(JsValue::NULL, JsValue::from), | |
| 300 | ])? | |
| 301 | .run() | |
| 302 | .await?; | |
| 303 | Ok(Outcome::Ok(AgentRunTicket { run_id: id, token })) | |
| 304 | } | |
| 305 | ||
| 306 | /// Adds `text` to a run's steps, keeping the latest `MAX_STEPS`. | |
| 307 | pub(crate) fn add_step(&self, run_id: &str, at: &str, text: &str) -> Result<worker::D1PreparedStatement> { | |
| 308 | self.db | |
| 309 | .prepare( | |
| 310 | "UPDATE agent_runs SET | |
| 311 | steps = CASE WHEN json_array_length(steps) >= ?4 | |
| 312 | THEN json_insert(json_remove(steps, '$[0]'), '$[#]', json_object('at', ?1, 'text', ?2)) | |
| 313 | ELSE json_insert(steps, '$[#]', json_object('at', ?1, 'text', ?2)) END, | |
| 314 | step_count = step_count + 1 | |
| 315 | WHERE id = ?3", | |
| 316 | ) | |
| 317 | .bind(&[at.into(), text.into(), run_id.into(), MAX_STEPS.into()]) | |
| 318 | } | |
| 319 | ||
| 320 | pub(crate) async fn report_run(&self, a: ReportRunArgs) -> Result<Outcome<RunStatus>> { | |
| 321 | let Some(run) = self | |
| 322 | .run_row(&a.run_id) | |
| 323 | .await? | |
| 324 | .filter(|run| run.token_hash == hash(&a.token)) | |
| 325 | else { | |
| 326 | return Ok(Outcome::fail(FailureCode::NotFound, "Run not found.")); | |
| 327 | }; | |
| 328 | // Finished, or stopped by a person: nothing more is taken, and the | |
| 329 | // sandbox learns why. | |
| 330 | if run.finished_at.is_some() { | |
| 331 | return Ok(Outcome::Ok(run.status())); | |
| 332 | } | |
| 333 | // It reached a cap of its guardrails (guardrails.rs). | |
| 334 | if let Some(halt) = a.halt { | |
| 335 | let pull_id = run.pull_id.as_deref(); | |
| 336 | return self | |
| 337 | .halt_run(&run.id, &run.repo_id, pull_id, run.number, &run.kind, halt, a.error, a.cost_usd) | |
| 338 | .await; | |
| 339 | } | |
| 340 | let now = rfc3339(now_ms()); | |
| 341 | let steps: Vec<String> = a | |
| 342 | .steps | |
| 343 | .iter() | |
| 344 | .map(|step| one_line(step, MAX_STEP_CHARS)) | |
| 345 | .filter(|step| !step.is_empty()) | |
| 346 | .collect(); | |
| 347 | // A burst keeps its end: that is where the run is now. | |
| 348 | let skip = steps.len().saturating_sub(MAX_STEPS_PER_REPORT); | |
| 349 | let mut statements = Vec::new(); | |
| 350 | for step in &steps[skip..] { | |
| 351 | statements.push(self.add_step(&run.id, &now, step)?); | |
| 352 | } | |
| 353 | let current = a | |
| 354 | .step | |
| 355 | .as_deref() | |
| 356 | .map(|step| one_line(step, MAX_STEP_CHARS)) | |
| 357 | .filter(|step| !step.is_empty()) | |
| Merge branch 'run-notes' into fast-push | 358 | .or_else(|| current_step(&steps)); |
| Inbox: the events service tells people what needs them as events arrive | 359 | let outcome = a |
| 360 | .outcome | |
| 361 | .filter(|outcome| matches!(outcome, RunStatus::Succeeded | RunStatus::Failed)); | |
| 362 | let cost = a.cost_usd.filter(|cost| cost.is_finite() && *cost >= 0.0); | |
| 363 | statements.push( | |
| 364 | self.db | |
| 365 | .prepare( | |
| 366 | "UPDATE agent_runs SET | |
| 367 | status = COALESCE(?1, CASE WHEN status = 'queued' THEN 'running' ELSE status END), | |
| 368 | started_at = COALESCE(started_at, ?2), | |
| 369 | step = COALESCE(?3, step), | |
| 370 | cost_usd = COALESCE(?4, cost_usd), | |
| 371 | turns = COALESCE(?5, turns), | |
| 372 | error = COALESCE(?6, error), | |
| 373 | finished_at = CASE WHEN ?1 IS NULL THEN finished_at ELSE ?2 END, | |
| 374 | updated_at = ?2 | |
| 375 | WHERE id = ?7 AND finished_at IS NULL", | |
| 376 | ) | |
| 377 | .bind(&[ | |
| 378 | outcome.map_or(JsValue::NULL, |outcome| outcome.as_str().into()), | |
| 379 | now.as_str().into(), | |
| 380 | optional(¤t), | |
| 381 | cost.map_or(JsValue::NULL, JsValue::from), | |
| 382 | a.turns.map_or(JsValue::NULL, JsValue::from), | |
| 383 | optional(&a.error.map(|error| one_line(&error, 1000))), | |
| 384 | run.id.as_str().into(), | |
| 385 | ])?, | |
| 386 | ); | |
| 387 | self.db.batch(statements).await?; | |
| 388 | Ok(Outcome::Ok(outcome.unwrap_or(RunStatus::Running))) | |
| 389 | } | |
| 390 | ||
| 391 | /// Runs whose sandbox died without anyone noticing, marked failed. | |
| 392 | pub(crate) async fn sweep_silent(&self) -> Result<()> { | |
| 393 | let now = now_ms(); | |
| 394 | let cutoff = rfc3339(now.saturating_sub(SILENT_HOURS * 3_600_000)); | |
| 395 | self.db | |
| 396 | .prepare( | |
| 397 | "UPDATE agent_runs | |
| 398 | SET status = 'failed', error = COALESCE(error, 'It stopped reporting.'), | |
| 399 | finished_at = ?, updated_at = ? | |
| 400 | WHERE status IN ('queued', 'running') AND updated_at < ?", | |
| 401 | ) | |
| 402 | .bind(&[rfc3339(now).into(), rfc3339(now).into(), cutoff.into()])? | |
| 403 | .run() | |
| 404 | .await?; | |
| 405 | Ok(()) | |
| 406 | } | |
| 407 | ||
| 408 | pub(crate) async fn list_runs(&self, a: ListRunsArgs) -> Result<Outcome<Vec<AgentRun>>> { | |
| 409 | let (column, key, member) = if let Some(workspace) = &a.workspace { | |
| 410 | let slug = workspace.to_lowercase(); | |
| 411 | if !is_member(&a.viewer, &slug) { | |
| 412 | return Ok(Outcome::fail( | |
| 413 | FailureCode::Forbidden, | |
| 414 | "Only members of the workspace can see its fleet.", | |
| 415 | )); | |
| 416 | } | |
| 417 | ("workspace", slug, true) | |
| 418 | } else if let Some(path) = &a.repo { | |
| 419 | match self.visible_repo(path, &a.viewer).await? { | |
| 420 | Outcome::Ok((repo, member)) => ("repo_id", repo.id, member), | |
| 421 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 422 | } | |
| 423 | } else { | |
| 424 | return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository or a workspace.")); | |
| 425 | }; | |
| 426 | self.sweep_silent().await?; | |
| 427 | let limit = a.limit.unwrap_or(DEFAULT_LIST).clamp(1, MAX_LIST); | |
| 428 | let rows = self | |
| 429 | .db | |
| 430 | .prepare(format!( | |
| 431 | "SELECT {COLUMNS} FROM agent_runs | |
| 432 | WHERE {column} = ?1 | |
| 433 | AND (?2 = 0 OR status IN ('queued', 'running')) | |
| 434 | AND (?3 IS NULL OR kind = ?3) | |
| 435 | AND (?4 IS NULL OR status = ?4) | |
| 436 | AND (?5 IS NULL OR number = ?5) | |
| 437 | ORDER BY created_at DESC LIMIT ?6" | |
| 438 | )) | |
| 439 | .bind(&[ | |
| 440 | key.into(), | |
| 441 | u32::from(a.active).into(), | |
| 442 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), | |
| 443 | a.status.map_or(JsValue::NULL, |status| status.as_str().into()), | |
| 444 | a.number.map_or(JsValue::NULL, JsValue::from), | |
| 445 | limit.into(), | |
| 446 | ])? | |
| 447 | .all() | |
| 448 | .await? | |
| 449 | .results::<RunRow>()?; | |
| 450 | Ok(Outcome::Ok(rows.into_iter().map(|row| row.into_run(member)).collect())) | |
| 451 | } | |
| 452 | ||
| 453 | pub(crate) async fn get_run(&self, a: GetRunArgs) -> Result<Outcome<AgentRun>> { | |
| 454 | let (repo, member) = match self.visible_repo(&a.repo, &a.viewer).await? { | |
| 455 | Outcome::Ok(found) => found, | |
| 456 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 457 | }; | |
| 458 | Ok(match self.run_row(&a.id).await?.filter(|run| run.repo_id == repo.id) { | |
| 459 | Some(row) => Outcome::Ok(row.into_run(member)), | |
| 460 | None => Outcome::fail(FailureCode::NotFound, "Run not found."), | |
| 461 | }) | |
| 462 | } | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 463 | |
| 464 | // --- Sessions ---------------------------------------------------------- | |
| 465 | ||
| 466 | pub(crate) async fn list_sessions(&self, a: ListSessionsArgs) -> Result<Outcome<Vec<SessionSummary>>> { | |
| 467 | let Some(path) = &a.repo else { | |
| 468 | return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository.")); | |
| 469 | }; | |
| 470 | let (repo, member) = match self.visible_repo(path, &a.viewer).await? { | |
| 471 | Outcome::Ok(found) => found, | |
| 472 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 473 | }; | |
| 474 | let rows = self | |
| 475 | .db | |
| 476 | .prepare( | |
| 477 | "SELECT p.id AS pull_id, p.number, p.title, p.status, p.agent, | |
| 478 | count(*) AS entries, | |
| 479 | sum(CASE WHEN e.kind = 'tool_call' THEN 1 ELSE 0 END) AS tools, | |
| 480 | min(e.at) AS started_at, max(e.at) AS last_at, | |
| 481 | (SELECT substr(f.text, 1, 300) FROM session_entries f | |
| 482 | WHERE f.pull_id = p.id AND f.kind = 'prompt' ORDER BY f.seq LIMIT 1) AS prompt | |
| 483 | FROM pulls p JOIN session_entries e ON e.pull_id = p.id | |
| 484 | WHERE p.repo_id = ?1 | |
| 485 | AND (?2 IS NULL OR p.status = ?2) | |
| 486 | AND (?3 IS NULL OR p.number = ?3) | |
| 487 | AND (?4 IS NULL OR EXISTS | |
| 488 | (SELECT 1 FROM agent_runs r WHERE r.pull_id = p.id AND r.kind = ?4)) | |
| 489 | GROUP BY p.id ORDER BY last_at DESC LIMIT 100", | |
| 490 | ) | |
| 491 | .bind(&[ | |
| 492 | repo.id.as_str().into(), | |
| 493 | a.outcome.map_or(JsValue::NULL, |status| status.as_str().into()), | |
| 494 | a.number.map_or(JsValue::NULL, JsValue::from), | |
| 495 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), | |
| 496 | ])? | |
| 497 | .all() | |
| 498 | .await? | |
| 499 | .results::<SessionListRow>()?; | |
| 500 | let ids: Vec<&str> = rows.iter().map(|row| row.pull_id.as_str()).collect(); | |
| 501 | let runs = if ids.is_empty() { | |
| 502 | Vec::new() | |
| 503 | } else { | |
| 504 | self.db | |
| 505 | .prepare( | |
| 506 | "SELECT pull_id, kind, status, cost_usd FROM agent_runs | |
| 507 | WHERE repo_id = ? AND pull_id IN (SELECT value FROM json_each(?))", | |
| 508 | ) | |
| 509 | .bind(&[repo.id.as_str().into(), serde_json::to_string(&ids)?.into()])? | |
| 510 | .all() | |
| 511 | .await? | |
| 512 | .results::<RunSumRow>()? | |
| 513 | }; | |
| 514 | Ok(Outcome::Ok( | |
| 515 | rows.into_iter() | |
| 516 | .map(|row| { | |
| 517 | let mine: Vec<&RunSumRow> = runs.iter().filter(|run| run.pull_id == row.pull_id).collect(); | |
| 518 | let mut kinds: Vec<RunKind> = Vec::new(); | |
| 519 | for run in &mine { | |
| 520 | if let Some(kind) = RunKind::parse(&run.kind) | |
| 521 | && !kinds.contains(&kind) | |
| 522 | { | |
| 523 | kinds.push(kind); | |
| 524 | } | |
| 525 | } | |
| 526 | let spent: f64 = mine.iter().filter_map(|run| run.cost_usd).sum(); | |
| 527 | SessionSummary { | |
| 528 | number: row.number, | |
| 529 | title: row.title, | |
| 530 | status: row.status, | |
| 531 | agent: row.agent, | |
| 532 | entries: row.entries, | |
| 533 | tools: row.tools, | |
| 534 | prompt: row.prompt.map(|prompt| one_line(&prompt, 200)), | |
| 535 | runs: mine.len() as u32, | |
| 536 | cost_usd: (member && mine.iter().any(|run| run.cost_usd.is_some())).then_some(spent), | |
| 537 | active: mine | |
| 538 | .iter() | |
| 539 | .any(|run| RunStatus::parse(&run.status).is_some_and(RunStatus::is_active)), | |
| 540 | kinds, | |
| 541 | started_at: row.started_at, | |
| 542 | last_at: row.last_at, | |
| 543 | } | |
| 544 | }) | |
| 545 | .collect(), | |
| 546 | )) | |
| 547 | } | |
| 548 | ||
| 549 | pub(crate) async fn get_session(&self, a: GetSessionArgs) -> Result<Outcome<SessionView>> { | |
| 550 | let (repo, pull) = match self.pull_at(&a.repo, a.number, &a.viewer).await? { | |
| 551 | Outcome::Ok(found) => found, | |
| 552 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 553 | }; | |
| 554 | let member = is_member(&a.viewer, &repo.namespace); | |
| 555 | let entries: Vec<SessionEntry> = self | |
| 556 | .db | |
| 557 | .prepare( | |
| 558 | "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries | |
| 559 | WHERE pull_id = ? ORDER BY seq LIMIT ?", | |
| 560 | ) | |
| 561 | .bind(&[pull.id.as_str().into(), SESSION_ENTRIES.into()])? | |
| 562 | .all() | |
| 563 | .await? | |
| 564 | .results::<SessionRow>()? | |
| 565 | .into_iter() | |
| 566 | .map(SessionEntry::from) | |
| 567 | .collect(); | |
| 568 | let runs: Vec<AgentRun> = self | |
| 569 | .db | |
| 570 | .prepare(format!( | |
| 571 | "SELECT {COLUMNS}, steps FROM agent_runs WHERE pull_id = ? ORDER BY created_at DESC LIMIT 50" | |
| 572 | )) | |
| 573 | .bind(&[pull.id.as_str().into()])? | |
| 574 | .all() | |
| 575 | .await? | |
| 576 | .results::<RunRow>()? | |
| 577 | .into_iter() | |
| 578 | .map(|row| row.into_run(member)) | |
| 579 | .collect(); | |
| 580 | let cost_usd = (member && runs.iter().any(|run| run.cost_usd.is_some())) | |
| 581 | .then(|| runs.iter().filter_map(|run| run.cost_usd).sum()); | |
| 582 | Ok(Outcome::Ok(SessionView { | |
| 583 | pull, | |
| 584 | entries, | |
| 585 | runs, | |
| 586 | cost_usd, | |
| 587 | })) | |
| 588 | } | |
| Inbox: the events service tells people what needs them as events arrive | 589 | } |
| 590 | ||
| 591 | #[cfg(test)] | |
| 592 | mod tests { | |
| 593 | use super::*; | |
| 594 | ||
| 595 | #[test] | |
| Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build. | 596 | fn transfer_and_deletion_statements_take_what_they_name() { |
| 597 | for sql in TRANSFERRED.iter().chain(crate::guardrails::TRANSFERRED) { | |
| 598 | let n = g1t_kit::transfer::parameters(sql); | |
| 599 | assert!((1..=5).contains(&n), "{sql}"); | |
| 600 | } | |
| 601 | for sql in crate::guardrails::DELETED { | |
| 602 | assert_eq!(g1t_kit::rename::parameters(sql), 1, "{sql}"); | |
| 603 | } | |
| 604 | } | |
| 605 | ||
| 606 | #[test] | |
| 607 | fn rename_statements_take_the_two_slugs() { | |
| 608 | for sql in RENAMED.iter().chain(crate::guardrails::RENAMED) { | |
| 609 | assert_eq!(g1t_kit::rename::parameters(sql), 2, "{sql}"); | |
| 610 | } | |
| 611 | } | |
| 612 | ||
| 613 | #[test] | |
| Merge branch 'run-notes' into fast-push | 614 | fn a_refused_host_is_noted_but_never_where_the_run_is() { |
| 615 | let steps = |list: &[&str]| list.iter().map(|step| (*step).to_owned()).collect::<Vec<String>>(); | |
| 616 | assert_eq!(current_step(&steps(&["Running npm ci", "Blocked: sparrow.cloudflare.com (not an allowed domain)"])).as_deref(), Some("Running npm ci")); | |
| 617 | assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)"])), None); | |
| 618 | assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)", "Typecheck"])).as_deref(), Some("Typecheck")); | |
| 619 | assert_eq!(current_step(&[]), None); | |
| 620 | } | |
| 621 | ||
| 622 | #[test] | |
| Inbox: the events service tells people what needs them as events arrive | 623 | fn steps_are_one_short_line() { |
| 624 | assert_eq!(one_line(" Read\n src/lib.rs ", 40), "Read src/lib.rs"); | |
| 625 | let long = one_line(&"x".repeat(300), 10); | |
| 626 | assert_eq!(long.chars().count(), 10); | |
| 627 | assert!(long.ends_with('…')); | |
| 628 | } | |
| 629 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.