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. | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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`. | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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}; |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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; |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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`). | |
| 30 | const NOTE_PREFIXES: [&str; 1] = ["Blocked: "]; | |
| 31 | ||
| 32 | /// Where a report leaves the run: its newest step that says what the run | |
| 33 | /// is doing. A note (a refused host) stays in the steps but never stands | |
| 34 | /// for the run, which carries on past it. `None` when there is only notes. | |
| 35 | fn current_step(steps: &[String]) -> Option<String> { | |
| 36 | steps.iter().rev().find(|step| !NOTE_PREFIXES.iter().any(|prefix| step.starts_with(prefix))).cloned() | |
| 37 | } | |
| 38 | ||
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 39 | /// The most steps a run keeps; older ones fall off the start. |
| 40 | const MAX_STEPS: u32 = 200; | |
| 41 | /// The most steps taken from one report. | |
| 42 | const MAX_STEPS_PER_REPORT: usize = 20; | |
| 43 | /// The longest a step is kept. | |
| 44 | const MAX_STEP_CHARS: usize = 240; | |
| 45 | /// A run that has said nothing for this long is taken to have died. | |
| 46 | const SILENT_HOURS: u64 = 3; | |
| 47 | const DEFAULT_LIST: u32 = 50; | |
| 48 | 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. | 49 | /// The most session entries one view returns. |
| 50 | const SESSION_ENTRIES: u32 = 2000; | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 51 | |
| 52 | /// Every column but the steps, which only a run's own page needs. | |
| 53 | const COLUMNS: &str = "id, workspace, repo_id, repo, number, pull_id, kind, agent, model, status, | |
| 54 | step, step_count, cost_usd, turns, sandbox, token_hash, started_by, error, created_at, | |
| 55 | 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. | 56 | COALESCE((SELECT title FROM pulls WHERE pulls.id = agent_runs.pull_id), agent_runs.title) AS title"; |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 57 | |
| 58 | #[derive(Deserialize)] | |
| 59 | pub(crate) struct RunRow { | |
| 60 | id: String, | |
| 61 | #[allow(dead_code)] | |
| 62 | workspace: String, | |
| 63 | #[allow(dead_code)] | |
| 64 | repo_id: String, | |
| 65 | repo: String, | |
| 66 | number: Option<u32>, | |
| 67 | pub(crate) pull_id: Option<String>, | |
| 68 | kind: String, | |
| 69 | agent: String, | |
| 70 | model: Option<String>, | |
| 71 | status: String, | |
| 72 | step: Option<String>, | |
| 73 | #[serde(default)] | |
| 74 | steps: Option<String>, | |
| 75 | step_count: u32, | |
| 76 | cost_usd: Option<f64>, | |
| 77 | turns: Option<u32>, | |
| 78 | token_hash: String, | |
| 79 | started_by: Option<String>, | |
| 80 | error: Option<String>, | |
| 81 | created_at: String, | |
| 82 | started_at: Option<String>, | |
| 83 | finished_at: Option<String>, | |
| 84 | updated_at: String, | |
| 85 | title: Option<String>, | |
| 86 | #[serde(default)] | |
| 87 | budget_usd: Option<f64>, | |
| 88 | #[serde(default)] | |
| 89 | time_cap_minutes: Option<u32>, | |
| 90 | #[serde(default)] | |
| 91 | halted: Option<String>, | |
| 92 | } | |
| 93 | ||
| 94 | impl RunRow { | |
| 95 | fn status(&self) -> RunStatus { | |
| 96 | RunStatus::parse(&self.status).unwrap_or(RunStatus::Failed) | |
| 97 | } | |
| 98 | ||
| 99 | /// The run as `member` (or not) may see it: model and cost are the | |
| 100 | /// workspace's business. | |
| 101 | fn into_run(self, member: bool) -> AgentRun { | |
| 102 | let (namespace, name) = self.repo.split_once('/').unwrap_or((&self.repo, "")); | |
| 103 | AgentRun { | |
| 104 | repo: RepoPath { | |
| 105 | namespace: namespace.to_owned(), | |
| 106 | name: name.to_owned(), | |
| 107 | }, | |
| 108 | status: self.status(), | |
| 109 | kind: RunKind::parse(&self.kind).unwrap_or(RunKind::Implement), | |
| 110 | steps: self | |
| 111 | .steps | |
| 112 | .as_deref() | |
| 113 | .and_then(|steps| serde_json::from_str(steps).ok()) | |
| 114 | .unwrap_or_default(), | |
| 115 | id: self.id, | |
| 116 | number: self.number.filter(|number| *number > 0), | |
| 117 | title: self.title, | |
| 118 | agent: self.agent, | |
| 119 | model: self.model.filter(|_| member), | |
| 120 | step: self.step, | |
| 121 | step_count: self.step_count, | |
| 122 | started_by: self.started_by, | |
| 123 | error: self.error, | |
| 124 | cost_usd: self.cost_usd.filter(|_| member), | |
| 125 | turns: self.turns, | |
| 126 | budget_usd: self.budget_usd.filter(|_| member), | |
| 127 | time_cap_minutes: self.time_cap_minutes, | |
| 128 | halted: self.halted, | |
| 129 | created_at: self.created_at, | |
| 130 | started_at: self.started_at, | |
| 131 | finished_at: self.finished_at, | |
| 132 | updated_at: self.updated_at, | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | /// One line, short enough to read at a glance. | |
| 138 | pub(crate) fn one_line(text: &str, limit: usize) -> String { | |
| 139 | let line = text.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 140 | if line.chars().count() <= limit { | |
| 141 | return line; | |
| 142 | } | |
| 143 | let mut short: String = line.chars().take(limit.saturating_sub(1)).collect(); | |
| 144 | short.push('…'); | |
| 145 | short | |
| 146 | } | |
| 147 | ||
| 148 | /// A principal that can read any repository of `namespace`, for lookups a | |
| 149 | /// trusted service asks for. What it is given reads and nothing more. | |
| 150 | pub(crate) fn member_of(actor: &User, namespace: &str) -> Viewer { | |
| 151 | let mut viewer = actor.clone(); | |
| 152 | let slug = namespace.to_lowercase(); | |
| 153 | if !viewer.is_member(&slug) { | |
| 154 | viewer.workspaces.push(Membership { | |
| 155 | base_permission: Some(g1t_contracts::access::BasePermission::Read), | |
| 156 | ..Membership::member(slug) | |
| 157 | }); | |
| 158 | } | |
| 159 | Some(viewer) | |
| 160 | } | |
| 161 | ||
| 162 | /// Whether the viewer belongs to the workspace: what a run spent, its model | |
| 163 | /// and its budget are the workspace's business, not every reader's (an | |
| 164 | /// outside collaborator sees the work, not the bill). | |
| 165 | fn is_member(viewer: &Viewer, namespace: &str) -> bool { | |
| 166 | viewer | |
| 167 | .as_ref() | |
| 168 | .is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase())) | |
| 169 | } | |
| 170 | ||
| 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. | 171 | #[derive(Deserialize)] |
| 172 | struct SessionListRow { | |
| 173 | pull_id: String, | |
| 174 | number: u32, | |
| 175 | title: String, | |
| 176 | status: PullStatus, | |
| 177 | agent: String, | |
| 178 | entries: u32, | |
| 179 | tools: u32, | |
| 180 | started_at: String, | |
| 181 | last_at: String, | |
| 182 | prompt: Option<String>, | |
| 183 | } | |
| 184 | ||
| 185 | #[derive(Deserialize)] | |
| 186 | struct RunSumRow { | |
| 187 | pull_id: String, | |
| 188 | kind: String, | |
| 189 | status: String, | |
| 190 | cost_usd: Option<f64>, | |
| 191 | } | |
| 192 | ||
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 193 | impl Work { |
| 194 | /// The repository at `path`, if `viewer` may see it, and whether they | |
| 195 | /// are a member of its workspace (which shows what runs cost). | |
| 196 | async fn visible_repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<(Repo, bool)>> { | |
| 197 | Ok(match self.repo(path, viewer).await? { | |
| 198 | Outcome::Ok(repo) => { | |
| 199 | let member = is_member(viewer, &repo.namespace); | |
| 200 | Outcome::Ok((repo, member)) | |
| 201 | } | |
| 202 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 203 | }) | |
| 204 | } | |
| 205 | ||
| 206 | async fn run_row(&self, id: &str) -> Result<Option<RunRow>> { | |
| 207 | self.db | |
| 208 | .prepare(format!("SELECT {COLUMNS}, steps FROM agent_runs WHERE id = ?")) | |
| 209 | .bind(&[id.into()])? | |
| 210 | .first::<RunRow>(None) | |
| 211 | .await | |
| 212 | } | |
| 213 | ||
| 214 | 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. | 215 | // Only sandboxes that are not agents are started now; earlier |
| 216 | // kinds stay readable on the runs already recorded. | |
| 217 | if !matches!(a.kind, RunKind::Checks | RunKind::Queue | RunKind::Mergecheck) { | |
| 218 | return Ok(Outcome::fail( | |
| 219 | FailureCode::Invalid, | |
| 220 | format!("A {} run is no longer started.", a.kind.as_str()), | |
| 221 | )); | |
| 222 | } | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 223 | // 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 | 224 | // sandbox in, whoever the sandbox acts as. The run records where |
| 225 | // the repository is now, looked up by its id, not the path it was | |
| 226 | // named by, which may be from before a transfer or a rename. | |
| 227 | let (repo_id, path) = match &a.pull_id { | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 228 | 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 | 229 | Some(pull) => { |
| 230 | let now: Option<RepoPath> = g1t_kit::call( | |
| 231 | &self.repos, | |
| 232 | "path_by_id", | |
| 233 | &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() }, | |
| 234 | ) | |
| 235 | .await?; | |
| 236 | (pull.repo_id, now.unwrap_or_else(|| a.repo.clone())) | |
| 237 | } | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 238 | None => return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found.")), |
| 239 | }, | |
| 240 | 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 | 241 | Outcome::Ok(repo) => (repo.id, RepoPath { namespace: repo.namespace, name: repo.name }), |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 242 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 243 | }, | |
| 244 | }; | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 245 | let namespace = path.namespace.to_lowercase(); |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 246 | // Nothing new starts on an archived or deleted repository. |
| 247 | if !self.repo_active(&repo_id).await? { | |
| 248 | return Ok(Outcome::fail( | |
| 249 | FailureCode::Forbidden, | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 250 | format!("{}/{} is archived or deleted, so nothing new starts on it.", path.namespace, path.name), |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 251 | )); |
| 252 | } | |
| 253 | let now = now_ms(); | |
| 254 | let id = new_id("arn", now); | |
| 255 | let token = new_token(); | |
| 256 | let timestamp = rfc3339(now); | |
| 257 | let agent = a | |
| 258 | .agent | |
| 259 | .clone() | |
| 260 | .unwrap_or_else(|| g1t_contracts::identity::AGENT_NAME.to_owned()); | |
| 261 | self.db | |
| 262 | .prepare( | |
| 263 | "INSERT INTO agent_runs | |
| 264 | (id, workspace, repo_id, repo, number, pull_id, title, kind, agent, model, status, | |
| 265 | sandbox, token_hash, started_by, created_at, updated_at, budget_usd, time_cap_minutes) | |
| 266 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?)", | |
| 267 | ) | |
| 268 | .bind(&[ | |
| 269 | id.as_str().into(), | |
| 270 | namespace.into(), | |
| 271 | repo_id.into(), | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 272 | format!("{}/{}", path.namespace, path.name).into(), |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 273 | a.number.filter(|n| *n > 0).map_or(JsValue::NULL, JsValue::from), |
| 274 | optional(&a.pull_id), | |
| 275 | optional(&a.title.map(|title| one_line(&title, 200))), | |
| 276 | a.kind.as_str().into(), | |
| 277 | agent.into(), | |
| 278 | optional(&a.model), | |
| 279 | a.sandbox.into(), | |
| 280 | hash(&token).into(), | |
| 281 | optional(&a.started_by), | |
| 282 | timestamp.as_str().into(), | |
| 283 | timestamp.as_str().into(), | |
| 284 | a.budget_usd.filter(|usd| usd.is_finite() && *usd > 0.0).map_or(JsValue::NULL, JsValue::from), | |
| 285 | a.time_cap_minutes.map_or(JsValue::NULL, JsValue::from), | |
| 286 | ])? | |
| 287 | .run() | |
| 288 | .await?; | |
| 289 | Ok(Outcome::Ok(AgentRunTicket { run_id: id, token })) | |
| 290 | } | |
| 291 | ||
| 292 | /// Adds `text` to a run's steps, keeping the latest `MAX_STEPS`. | |
| 293 | pub(crate) fn add_step(&self, run_id: &str, at: &str, text: &str) -> Result<worker::D1PreparedStatement> { | |
| 294 | self.db | |
| 295 | .prepare( | |
| 296 | "UPDATE agent_runs SET | |
| 297 | steps = CASE WHEN json_array_length(steps) >= ?4 | |
| 298 | THEN json_insert(json_remove(steps, '$[0]'), '$[#]', json_object('at', ?1, 'text', ?2)) | |
| 299 | ELSE json_insert(steps, '$[#]', json_object('at', ?1, 'text', ?2)) END, | |
| 300 | step_count = step_count + 1 | |
| 301 | WHERE id = ?3", | |
| 302 | ) | |
| 303 | .bind(&[at.into(), text.into(), run_id.into(), MAX_STEPS.into()]) | |
| 304 | } | |
| 305 | ||
| 306 | pub(crate) async fn report_run(&self, a: ReportRunArgs) -> Result<Outcome<RunStatus>> { | |
| 307 | let Some(run) = self | |
| 308 | .run_row(&a.run_id) | |
| 309 | .await? | |
| 310 | .filter(|run| run.token_hash == hash(&a.token)) | |
| 311 | else { | |
| 312 | return Ok(Outcome::fail(FailureCode::NotFound, "Run not found.")); | |
| 313 | }; | |
| 314 | // Finished, or stopped by a person: nothing more is taken, and the | |
| 315 | // sandbox learns why. | |
| 316 | if run.finished_at.is_some() { | |
| 317 | return Ok(Outcome::Ok(run.status())); | |
| 318 | } | |
| 319 | // It reached a cap of its guardrails (guardrails.rs). | |
| 320 | if let Some(halt) = a.halt { | |
| 321 | let pull_id = run.pull_id.as_deref(); | |
| 322 | return self | |
| 323 | .halt_run(&run.id, &run.repo_id, pull_id, run.number, &run.kind, halt, a.error, a.cost_usd) | |
| 324 | .await; | |
| 325 | } | |
| 326 | let now = rfc3339(now_ms()); | |
| 327 | let steps: Vec<String> = a | |
| 328 | .steps | |
| 329 | .iter() | |
| 330 | .map(|step| one_line(step, MAX_STEP_CHARS)) | |
| 331 | .filter(|step| !step.is_empty()) | |
| 332 | .collect(); | |
| 333 | // A burst keeps its end: that is where the run is now. | |
| 334 | let skip = steps.len().saturating_sub(MAX_STEPS_PER_REPORT); | |
| 335 | let mut statements = Vec::new(); | |
| 336 | for step in &steps[skip..] { | |
| 337 | statements.push(self.add_step(&run.id, &now, step)?); | |
| 338 | } | |
| 339 | let current = a | |
| 340 | .step | |
| 341 | .as_deref() | |
| 342 | .map(|step| one_line(step, MAX_STEP_CHARS)) | |
| 343 | .filter(|step| !step.is_empty()) | |
| Merge branch 'run-notes' into fast-push | 344 | .or_else(|| current_step(&steps)); |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 345 | let outcome = a |
| 346 | .outcome | |
| 347 | .filter(|outcome| matches!(outcome, RunStatus::Succeeded | RunStatus::Failed)); | |
| 348 | let cost = a.cost_usd.filter(|cost| cost.is_finite() && *cost >= 0.0); | |
| 349 | statements.push( | |
| 350 | self.db | |
| 351 | .prepare( | |
| 352 | "UPDATE agent_runs SET | |
| 353 | status = COALESCE(?1, CASE WHEN status = 'queued' THEN 'running' ELSE status END), | |
| 354 | started_at = COALESCE(started_at, ?2), | |
| 355 | step = COALESCE(?3, step), | |
| 356 | cost_usd = COALESCE(?4, cost_usd), | |
| 357 | turns = COALESCE(?5, turns), | |
| 358 | error = COALESCE(?6, error), | |
| 359 | finished_at = CASE WHEN ?1 IS NULL THEN finished_at ELSE ?2 END, | |
| 360 | updated_at = ?2 | |
| 361 | WHERE id = ?7 AND finished_at IS NULL", | |
| 362 | ) | |
| 363 | .bind(&[ | |
| 364 | outcome.map_or(JsValue::NULL, |outcome| outcome.as_str().into()), | |
| 365 | now.as_str().into(), | |
| 366 | optional(¤t), | |
| 367 | cost.map_or(JsValue::NULL, JsValue::from), | |
| 368 | a.turns.map_or(JsValue::NULL, JsValue::from), | |
| 369 | optional(&a.error.map(|error| one_line(&error, 1000))), | |
| 370 | run.id.as_str().into(), | |
| 371 | ])?, | |
| 372 | ); | |
| 373 | self.db.batch(statements).await?; | |
| 374 | Ok(Outcome::Ok(outcome.unwrap_or(RunStatus::Running))) | |
| 375 | } | |
| 376 | ||
| 377 | /// Runs whose sandbox died without anyone noticing, marked failed. | |
| 378 | pub(crate) async fn sweep_silent(&self) -> Result<()> { | |
| 379 | let now = now_ms(); | |
| 380 | let cutoff = rfc3339(now.saturating_sub(SILENT_HOURS * 3_600_000)); | |
| 381 | self.db | |
| 382 | .prepare( | |
| 383 | "UPDATE agent_runs | |
| 384 | SET status = 'failed', error = COALESCE(error, 'It stopped reporting.'), | |
| 385 | finished_at = ?, updated_at = ? | |
| 386 | WHERE status IN ('queued', 'running') AND updated_at < ?", | |
| 387 | ) | |
| 388 | .bind(&[rfc3339(now).into(), rfc3339(now).into(), cutoff.into()])? | |
| 389 | .run() | |
| 390 | .await?; | |
| 391 | Ok(()) | |
| 392 | } | |
| 393 | ||
| 394 | pub(crate) async fn list_runs(&self, a: ListRunsArgs) -> Result<Outcome<Vec<AgentRun>>> { | |
| 395 | let (column, key, member) = if let Some(workspace) = &a.workspace { | |
| 396 | let slug = workspace.to_lowercase(); | |
| 397 | if !is_member(&a.viewer, &slug) { | |
| 398 | return Ok(Outcome::fail( | |
| 399 | FailureCode::Forbidden, | |
| 400 | "Only members of the workspace can see its fleet.", | |
| 401 | )); | |
| 402 | } | |
| 403 | ("workspace", slug, true) | |
| 404 | } else if let Some(path) = &a.repo { | |
| 405 | match self.visible_repo(path, &a.viewer).await? { | |
| 406 | Outcome::Ok((repo, member)) => ("repo_id", repo.id, member), | |
| 407 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 408 | } | |
| 409 | } else { | |
| 410 | return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository or a workspace.")); | |
| 411 | }; | |
| 412 | self.sweep_silent().await?; | |
| 413 | let limit = a.limit.unwrap_or(DEFAULT_LIST).clamp(1, MAX_LIST); | |
| 414 | let rows = self | |
| 415 | .db | |
| 416 | .prepare(format!( | |
| 417 | "SELECT {COLUMNS} FROM agent_runs | |
| 418 | WHERE {column} = ?1 | |
| 419 | AND (?2 = 0 OR status IN ('queued', 'running')) | |
| 420 | AND (?3 IS NULL OR kind = ?3) | |
| 421 | AND (?4 IS NULL OR status = ?4) | |
| 422 | AND (?5 IS NULL OR number = ?5) | |
| 423 | ORDER BY created_at DESC LIMIT ?6" | |
| 424 | )) | |
| 425 | .bind(&[ | |
| 426 | key.into(), | |
| 427 | u32::from(a.active).into(), | |
| 428 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), | |
| 429 | a.status.map_or(JsValue::NULL, |status| status.as_str().into()), | |
| 430 | a.number.map_or(JsValue::NULL, JsValue::from), | |
| 431 | limit.into(), | |
| 432 | ])? | |
| 433 | .all() | |
| 434 | .await? | |
| 435 | .results::<RunRow>()?; | |
| 436 | Ok(Outcome::Ok(rows.into_iter().map(|row| row.into_run(member)).collect())) | |
| 437 | } | |
| 438 | ||
| 439 | pub(crate) async fn get_run(&self, a: GetRunArgs) -> Result<Outcome<AgentRun>> { | |
| 440 | let (repo, member) = match self.visible_repo(&a.repo, &a.viewer).await? { | |
| 441 | Outcome::Ok(found) => found, | |
| 442 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 443 | }; | |
| 444 | Ok(match self.run_row(&a.id).await?.filter(|run| run.repo_id == repo.id) { | |
| 445 | Some(row) => Outcome::Ok(row.into_run(member)), | |
| 446 | None => Outcome::fail(FailureCode::NotFound, "Run not found."), | |
| 447 | }) | |
| 448 | } | |
| 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. | 449 | |
| 450 | // --- Sessions ---------------------------------------------------------- | |
| 451 | ||
| 452 | pub(crate) async fn list_sessions(&self, a: ListSessionsArgs) -> Result<Outcome<Vec<SessionSummary>>> { | |
| 453 | let Some(path) = &a.repo else { | |
| 454 | return Ok(Outcome::fail(FailureCode::Invalid, "Name a repository.")); | |
| 455 | }; | |
| 456 | let (repo, member) = match self.visible_repo(path, &a.viewer).await? { | |
| 457 | Outcome::Ok(found) => found, | |
| 458 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 459 | }; | |
| 460 | let rows = self | |
| 461 | .db | |
| 462 | .prepare( | |
| 463 | "SELECT p.id AS pull_id, p.number, p.title, p.status, p.agent, | |
| 464 | count(*) AS entries, | |
| 465 | sum(CASE WHEN e.kind = 'tool_call' THEN 1 ELSE 0 END) AS tools, | |
| 466 | min(e.at) AS started_at, max(e.at) AS last_at, | |
| 467 | (SELECT substr(f.text, 1, 300) FROM session_entries f | |
| 468 | WHERE f.pull_id = p.id AND f.kind = 'prompt' ORDER BY f.seq LIMIT 1) AS prompt | |
| 469 | FROM pulls p JOIN session_entries e ON e.pull_id = p.id | |
| 470 | WHERE p.repo_id = ?1 | |
| 471 | AND (?2 IS NULL OR p.status = ?2) | |
| 472 | AND (?3 IS NULL OR p.number = ?3) | |
| 473 | AND (?4 IS NULL OR EXISTS | |
| 474 | (SELECT 1 FROM agent_runs r WHERE r.pull_id = p.id AND r.kind = ?4)) | |
| 475 | GROUP BY p.id ORDER BY last_at DESC LIMIT 100", | |
| 476 | ) | |
| 477 | .bind(&[ | |
| 478 | repo.id.as_str().into(), | |
| 479 | a.outcome.map_or(JsValue::NULL, |status| status.as_str().into()), | |
| 480 | a.number.map_or(JsValue::NULL, JsValue::from), | |
| 481 | a.kind.map_or(JsValue::NULL, |kind| kind.as_str().into()), | |
| 482 | ])? | |
| 483 | .all() | |
| 484 | .await? | |
| 485 | .results::<SessionListRow>()?; | |
| 486 | let ids: Vec<&str> = rows.iter().map(|row| row.pull_id.as_str()).collect(); | |
| 487 | let runs = if ids.is_empty() { | |
| 488 | Vec::new() | |
| 489 | } else { | |
| 490 | self.db | |
| 491 | .prepare( | |
| 492 | "SELECT pull_id, kind, status, cost_usd FROM agent_runs | |
| 493 | WHERE repo_id = ? AND pull_id IN (SELECT value FROM json_each(?))", | |
| 494 | ) | |
| 495 | .bind(&[repo.id.as_str().into(), serde_json::to_string(&ids)?.into()])? | |
| 496 | .all() | |
| 497 | .await? | |
| 498 | .results::<RunSumRow>()? | |
| 499 | }; | |
| 500 | Ok(Outcome::Ok( | |
| 501 | rows.into_iter() | |
| 502 | .map(|row| { | |
| 503 | let mine: Vec<&RunSumRow> = runs.iter().filter(|run| run.pull_id == row.pull_id).collect(); | |
| 504 | let mut kinds: Vec<RunKind> = Vec::new(); | |
| 505 | for run in &mine { | |
| 506 | if let Some(kind) = RunKind::parse(&run.kind) | |
| 507 | && !kinds.contains(&kind) | |
| 508 | { | |
| 509 | kinds.push(kind); | |
| 510 | } | |
| 511 | } | |
| 512 | let spent: f64 = mine.iter().filter_map(|run| run.cost_usd).sum(); | |
| 513 | SessionSummary { | |
| 514 | number: row.number, | |
| 515 | title: row.title, | |
| 516 | status: row.status, | |
| 517 | agent: row.agent, | |
| 518 | entries: row.entries, | |
| 519 | tools: row.tools, | |
| 520 | prompt: row.prompt.map(|prompt| one_line(&prompt, 200)), | |
| 521 | runs: mine.len() as u32, | |
| 522 | cost_usd: (member && mine.iter().any(|run| run.cost_usd.is_some())).then_some(spent), | |
| 523 | active: mine | |
| 524 | .iter() | |
| 525 | .any(|run| RunStatus::parse(&run.status).is_some_and(RunStatus::is_active)), | |
| 526 | kinds, | |
| 527 | started_at: row.started_at, | |
| 528 | last_at: row.last_at, | |
| 529 | } | |
| 530 | }) | |
| 531 | .collect(), | |
| 532 | )) | |
| 533 | } | |
| 534 | ||
| 535 | pub(crate) async fn get_session(&self, a: GetSessionArgs) -> Result<Outcome<SessionView>> { | |
| 536 | let (repo, pull) = match self.pull_at(&a.repo, a.number, &a.viewer).await? { | |
| 537 | Outcome::Ok(found) => found, | |
| 538 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 539 | }; | |
| 540 | let member = is_member(&a.viewer, &repo.namespace); | |
| 541 | let entries: Vec<SessionEntry> = self | |
| 542 | .db | |
| 543 | .prepare( | |
| 544 | "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries | |
| 545 | WHERE pull_id = ? ORDER BY seq LIMIT ?", | |
| 546 | ) | |
| 547 | .bind(&[pull.id.as_str().into(), SESSION_ENTRIES.into()])? | |
| 548 | .all() | |
| 549 | .await? | |
| 550 | .results::<SessionRow>()? | |
| 551 | .into_iter() | |
| 552 | .map(SessionEntry::from) | |
| 553 | .collect(); | |
| 554 | let runs: Vec<AgentRun> = self | |
| 555 | .db | |
| 556 | .prepare(format!( | |
| 557 | "SELECT {COLUMNS}, steps FROM agent_runs WHERE pull_id = ? ORDER BY created_at DESC LIMIT 50" | |
| 558 | )) | |
| 559 | .bind(&[pull.id.as_str().into()])? | |
| 560 | .all() | |
| 561 | .await? | |
| 562 | .results::<RunRow>()? | |
| 563 | .into_iter() | |
| 564 | .map(|row| row.into_run(member)) | |
| 565 | .collect(); | |
| 566 | let cost_usd = (member && runs.iter().any(|run| run.cost_usd.is_some())) | |
| 567 | .then(|| runs.iter().filter_map(|run| run.cost_usd).sum()); | |
| 568 | Ok(Outcome::Ok(SessionView { | |
| 569 | pull, | |
| 570 | entries, | |
| 571 | runs, | |
| 572 | cost_usd, | |
| 573 | })) | |
| 574 | } | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 575 | } |
| 576 | ||
| 577 | #[cfg(test)] | |
| 578 | mod tests { | |
| 579 | use super::*; | |
| 580 | ||
| 581 | #[test] | |
| Merge branch 'run-notes' into fast-push | 582 | fn a_refused_host_is_noted_but_never_where_the_run_is() { |
| 583 | let steps = |list: &[&str]| list.iter().map(|step| (*step).to_owned()).collect::<Vec<String>>(); | |
| 584 | assert_eq!(current_step(&steps(&["Running npm ci", "Blocked: sparrow.cloudflare.com (not an allowed domain)"])).as_deref(), Some("Running npm ci")); | |
| 585 | assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)"])), None); | |
| 586 | assert_eq!(current_step(&steps(&["Blocked: a.com (not an allowed domain)", "Typecheck"])).as_deref(), Some("Typecheck")); | |
| 587 | assert_eq!(current_step(&[]), None); | |
| 588 | } | |
| 589 | ||
| 590 | #[test] | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 591 | fn steps_are_one_short_line() { |
| 592 | assert_eq!(one_line(" Read\n src/lib.rs ", 40), "Read src/lib.rs"); | |
| 593 | let long = one_line(&"x".repeat(300), 10); | |
| 594 | assert_eq!(long.chars().count(), 10); | |
| 595 | assert!(long.ends_with('…')); | |
| 596 | } | |
| 597 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.