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 | //! Guardrails: which hosts a sandbox may reach (the network rules and |
| 2 | //! workflow-only domains) and how long each kind of run may take, as a | |
| 3 | //! workspace's defaults and each project's overrides. The runner service | |
| 4 | //! reads what a run gets (`run_guardrails`) and enforces it; this service | |
| 5 | //! keeps the settings and ends a run that reached a cap (`halt_run`, from | |
| Fixes from the page sweep | 6 | //! `report_run`). |
| 7 | //! | |
| 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 | //! Command rules, deny patterns and cost caps were for g1t's own agents: |
| 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 | //! levels saved with them still read, and they are dropped when saved again. |
| 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. | 10 | //! |
| Fixes from the page sweep | 11 | //! See `g1t_contracts::guardrails` for the rules and how levels merge. |
| 12 | ||
| 13 | use g1t_contracts::access::{self, Capability}; | |
| 14 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 15 | use g1t_contracts::agents::RunStatus; | |
| 16 | use g1t_contracts::guardrails::*; | |
| 17 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 18 | use g1t_contracts::time::rfc3339; | |
| 19 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id}; | |
| 20 | use g1t_kit::now_ms; | |
| 21 | use serde::Deserialize; | |
| 22 | use worker::Result; | |
| 23 | use worker::wasm_bindgen::JsValue; | |
| 24 | ||
| 25 | use crate::Work; | |
| 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. | 26 | use crate::lifecycle::{POLICY_ACTOR_ID, POLICY_ACTOR_NAME}; |
| Fixes from the page sweep | 27 | use crate::runs::member_of; |
| 28 | ||
| 29 | /// Statements that move a renamed workspace's guardrails to its new slug. | |
| 30 | /// Run with the new slug as `?1` and the old as `?2`. | |
| 31 | pub(crate) const RENAMED: &[&str] = &[ | |
| 32 | "UPDATE guardrails SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2", | |
| 33 | "UPDATE guardrails SET workspace = ?1 WHERE workspace = ?2", | |
| 34 | ]; | |
| 35 | ||
| 36 | /// A repository transferred: its own guardrails follow it to the new | |
| 37 | /// workspace (see `g1t_kit::transfer`; `?3` the workspace now, `?4` the one | |
| 38 | /// before, `?5` the repository's id). | |
| 39 | pub(crate) const TRANSFERRED: &[&str] = &[ | |
| 40 | "UPDATE guardrails SET workspace = ?3 WHERE scope <> 'workspace' AND scope_key = ?5 AND workspace = ?4", | |
| 41 | ]; | |
| 42 | ||
| 43 | #[derive(Deserialize)] | |
| 44 | struct SettingsRow { | |
| 45 | settings: String, | |
| 46 | updated_by: String, | |
| 47 | updated_at: String, | |
| 48 | } | |
| 49 | ||
| 50 | impl From<SettingsRow> for GuardrailSettings { | |
| 51 | fn from(row: SettingsRow) -> Self { | |
| 52 | let mut settings: GuardrailSettings = serde_json::from_str(&row.settings).unwrap_or_default(); | |
| 53 | settings.updated_by = Some(row.updated_by); | |
| 54 | settings.updated_at = Some(row.updated_at); | |
| 55 | settings | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | /// Whether `actor` is a verified person, not a token or an agent. | |
| 60 | fn is_person(actor: &User) -> bool { | |
| 61 | actor.verified && actor.kind == PrincipalKind::User | |
| 62 | } | |
| 63 | ||
| 64 | /// What a run stopped for looking like mining says, everywhere it shows. | |
| 65 | pub(crate) const ABUSE_MESSAGE: &str = "Stopped: unusual CPU use; contact support if this was a real job."; | |
| 66 | ||
| 67 | /// What a halted run is told, and what its pull request says. | |
| 68 | fn halt_message(halt: Halt) -> &'static str { | |
| 69 | match halt { | |
| 70 | Halt::Budget => "Stopped: it reached its cost cap.", | |
| 71 | Halt::Time => "Stopped: it reached its time cap.", | |
| 72 | Halt::Abuse => ABUSE_MESSAGE, | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | impl Work { | |
| 77 | async fn guardrail_level(&self, scope: &str, key: &str) -> Result<GuardrailSettings> { | |
| 78 | Ok(self | |
| 79 | .db | |
| 80 | .prepare("SELECT settings, updated_by, updated_at FROM guardrails WHERE scope = ? AND scope_key = ?") | |
| 81 | .bind(&[scope.into(), key.into()])? | |
| 82 | .first::<SettingsRow>(None) | |
| 83 | .await? | |
| 84 | .map(GuardrailSettings::from) | |
| 85 | .unwrap_or_default()) | |
| 86 | } | |
| 87 | ||
| 88 | /// The project at `path`, which must be in `workspace`. | |
| 89 | async fn guarded_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> { | |
| 90 | Ok(match self.repo(path, viewer).await? { | |
| 91 | Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo), | |
| 92 | Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."), | |
| 93 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 94 | }) | |
| 95 | } | |
| 96 | ||
| 97 | async fn guardrails_view(&self, workspace: &str, repo: Option<&Repo>) -> Result<GuardrailsView> { | |
| 98 | let level = self.guardrail_level("workspace", workspace).await?; | |
| 99 | let project = match repo { | |
| 100 | Some(repo) => Some(self.guardrail_level("project", &repo.id).await?), | |
| 101 | None => None, | |
| 102 | }; | |
| 103 | Ok(GuardrailsView::new(level, project)) | |
| 104 | } | |
| 105 | ||
| 106 | pub(crate) async fn get_guardrails(&self, a: GetGuardrailsArgs) -> Result<Outcome<GuardrailsView>> { | |
| 107 | let workspace = a.workspace.to_lowercase(); | |
| 108 | let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)); | |
| 109 | if !member && a.repo.is_none() { | |
| 110 | return Ok(Outcome::fail( | |
| 111 | FailureCode::Forbidden, | |
| 112 | "Guardrails are for members of the workspace.", | |
| 113 | )); | |
| 114 | } | |
| 115 | let repo = match &a.repo { | |
| 116 | Some(path) => match self.guarded_repo(path, &a.viewer, &workspace).await? { | |
| 117 | Outcome::Ok(repo) => Some(repo), | |
| 118 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 119 | }, | |
| 120 | None => None, | |
| 121 | }; | |
| 122 | // Members see them; so does anyone else who may change the | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 123 | // project's, such as an outside collaborator with Admin. |
| Fixes from the page sweep | 124 | if !member |
| 125 | && !repo | |
| 126 | .as_ref() | |
| 127 | .is_some_and(|repo| access::can(a.viewer.as_ref(), repo, Capability::ManageProtection)) | |
| 128 | { | |
| 129 | return Ok(Outcome::fail( | |
| 130 | FailureCode::Forbidden, | |
| 131 | "Guardrails are for members of the workspace.", | |
| 132 | )); | |
| 133 | } | |
| 134 | Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?)) | |
| 135 | } | |
| 136 | ||
| 137 | pub(crate) async fn update_guardrails(&self, a: UpdateGuardrailsArgs) -> Result<Outcome<GuardrailsView>> { | |
| 138 | let workspace = a.workspace.to_lowercase(); | |
| 139 | let viewer = Some(a.actor.clone()); | |
| 140 | let repo = match &a.repo { | |
| 141 | Some(path) => match self.guarded_repo(path, &viewer, &workspace).await? { | |
| 142 | Outcome::Ok(repo) => Some(repo), | |
| 143 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 144 | }, | |
| 145 | None => None, | |
| 146 | }; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 147 | // A project's guardrails go with its branch protection (Admin); |
| Fixes from the page sweep | 148 | // the defaults every project inherits are the owners'. |
| 149 | let allowed = is_person(&a.actor) | |
| 150 | && match &repo { | |
| 151 | Some(repo) => access::can(Some(&a.actor), repo, Capability::ManageProtection), | |
| 152 | None => a.actor.role_in(&workspace) == Some(Role::Owner), | |
| 153 | }; | |
| 154 | if !allowed { | |
| 155 | let message = match &repo { | |
| 156 | Some(repo) => access::needs( | |
| 157 | Capability::ManageProtection, | |
| 158 | &format!("{}/{}", repo.namespace, repo.name), | |
| 159 | ), | |
| 160 | None => "Only owners of the workspace can change its guardrails.".to_owned(), | |
| 161 | }; | |
| 162 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 163 | } | |
| 164 | let mut settings = match validate(a.settings) { | |
| 165 | Ok(settings) => settings, | |
| 166 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 167 | }; | |
| 168 | settings.updated_by = None; | |
| 169 | settings.updated_at = None; | |
| 170 | let (scope, key) = match &repo { | |
| 171 | Some(repo) => ("project", repo.id.clone()), | |
| 172 | None => ("workspace", workspace.clone()), | |
| 173 | }; | |
| 174 | let before = self.guardrail_level(scope, &key).await?; | |
| 175 | self.db | |
| 176 | .prepare( | |
| 177 | "INSERT INTO guardrails (scope, scope_key, workspace, settings, updated_by, updated_at) | |
| 178 | VALUES (?, ?, ?, ?, ?, ?) | |
| 179 | ON CONFLICT (scope, scope_key) DO UPDATE SET | |
| 180 | settings = excluded.settings, | |
| 181 | updated_by = excluded.updated_by, | |
| 182 | updated_at = excluded.updated_at", | |
| 183 | ) | |
| 184 | .bind(&[ | |
| 185 | scope.into(), | |
| 186 | key.into(), | |
| 187 | workspace.as_str().into(), | |
| 188 | serde_json::to_string(&settings)?.into(), | |
| 189 | a.actor.username.as_str().into(), | |
| 190 | rfc3339(now_ms()).into(), | |
| 191 | ])? | |
| 192 | .run() | |
| 193 | .await?; | |
| 194 | let full = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name)); | |
| 195 | self.audit_guardrails(&a.actor, &workspace, full, &guardrails_change(&before, &settings)).await; | |
| 196 | Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?)) | |
| 197 | } | |
| 198 | ||
| 199 | /// Records a change to guardrails in the workspace's audit log. Never | |
| 200 | /// fails the change: a log that cannot be written is logged. | |
| 201 | async fn audit_guardrails(&self, actor: &User, workspace: &str, repo: Option<String>, message: &str) { | |
| 202 | let entry = NewAuditEntry { | |
| 203 | actor: AuditActor::of(actor), | |
| 204 | action: "update_guardrails".to_owned(), | |
| 205 | surface: Surface::Web, | |
| 206 | target: AuditTarget { workspace: workspace.to_owned(), repo, ..AuditTarget::default() }, | |
| 207 | outcome: AuditOutcome::Allowed, | |
| 208 | rule: "guardrails".to_owned(), | |
| 209 | result: Some("ok".to_owned()), | |
| 210 | message: Some(message.to_owned()), | |
| 211 | request_id: new_id("req", now_ms()), | |
| 212 | }; | |
| 213 | let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await; | |
| 214 | if let Err(error) = recorded { | |
| 215 | worker::console_error!("guardrails change not recorded: {error}"); | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | /// What a run in `repo` gets. The runner is trusted: it names the | |
| 220 | /// repository it is starting a sandbox in. | |
| 221 | pub(crate) async fn run_guardrails(&self, a: RunGuardrailsArgs) -> Result<Outcome<Guardrails>> { | |
| 222 | let path = self.project_path(&a).await?; | |
| 223 | let workspace = path.namespace.to_lowercase(); | |
| 224 | let repo = match self.repo(&path, &member_of_service(&workspace)).await? { | |
| 225 | Outcome::Ok(repo) => repo, | |
| 226 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 227 | }; | |
| 228 | let level = self.guardrail_level("workspace", &workspace).await?; | |
| 229 | let project = self.guardrail_level("project", &repo.id).await?; | |
| 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. | 230 | Ok(Outcome::Ok(Guardrails::merge(&level, Some(&project)))) |
| Fixes from the page sweep | 231 | } |
| 232 | ||
| 233 | /// The project a run's guardrails come from, where it is now. By id | |
| 234 | /// when the runner has it, so a repository renamed or transferred | |
| 235 | /// since is still found; a pull request's working copy stands for the | |
| 236 | /// repository the pull request is to (a preview built from it gets | |
| 237 | /// that project's guardrails, not the `pulls` namespace's). Otherwise | |
| 238 | /// the path as given. | |
| 239 | async fn project_path(&self, a: &RunGuardrailsArgs) -> Result<RepoPath> { | |
| 240 | let id = match (&a.repo_id, working_copy_of(&a.repo)) { | |
| 241 | (Some(id), _) => Some(id.clone()), | |
| 242 | (None, Some(pull_id)) => { | |
| 243 | self.db | |
| 244 | .prepare("SELECT repo_id AS value FROM pulls WHERE id = ?1 AND fork_name = ?1") | |
| 245 | .bind(&[pull_id.into()])? | |
| 246 | .first::<String>(Some("value")) | |
| 247 | .await? | |
| 248 | } | |
| 249 | (None, None) => None, | |
| 250 | }; | |
| 251 | let Some(id) = id else { | |
| 252 | return Ok(a.repo.clone()); | |
| 253 | }; | |
| 254 | let current: Option<RepoPath> = | |
| 255 | g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id }).await?; | |
| 256 | Ok(current.unwrap_or_else(|| a.repo.clone())) | |
| 257 | } | |
| 258 | ||
| 259 | /// Ends a run that reached a cap of its guardrails, as stopped, and | |
| 260 | /// leaves a pull request it was working on for a person, as a stop by | |
| 261 | /// a person does. Called from `report_run` once its token is checked. | |
| 262 | #[allow(clippy::too_many_arguments)] | |
| 263 | pub(crate) async fn halt_run( | |
| 264 | &self, | |
| 265 | run_id: &str, | |
| 266 | repo_id: &str, | |
| 267 | pull_id: Option<&str>, | |
| 268 | number: Option<u32>, | |
| 269 | kind: &str, | |
| 270 | halt: Halt, | |
| 271 | detail: Option<String>, | |
| 272 | cost_usd: Option<f64>, | |
| 273 | ) -> Result<Outcome<RunStatus>> { | |
| 274 | let said = detail | |
| 275 | .map(|detail| crate::runs::one_line(&detail, 1000)) | |
| 276 | .filter(|detail| !detail.is_empty()) | |
| 277 | .unwrap_or_else(|| halt_message(halt).to_owned()); | |
| 278 | let now = rfc3339(now_ms()); | |
| 279 | let claimed = self | |
| 280 | .db | |
| 281 | .prepare( | |
| 282 | "UPDATE agent_runs SET status = 'stopped', halted = ?1, step = ?2, error = ?2, | |
| 283 | cost_usd = COALESCE(?3, cost_usd), started_at = COALESCE(started_at, ?4), | |
| 284 | finished_at = ?4, updated_at = ?4 | |
| 285 | WHERE id = ?5 AND finished_at IS NULL RETURNING id AS value", | |
| 286 | ) | |
| 287 | .bind(&[ | |
| 288 | halt.as_str().into(), | |
| 289 | said.as_str().into(), | |
| 290 | cost_usd | |
| 291 | .filter(|cost| cost.is_finite() && *cost >= 0.0) | |
| 292 | .map_or(JsValue::NULL, JsValue::from), | |
| 293 | now.as_str().into(), | |
| 294 | run_id.into(), | |
| 295 | ])? | |
| 296 | .first::<String>(Some("value")) | |
| 297 | .await?; | |
| 298 | if claimed.is_none() { | |
| 299 | return Ok(Outcome::Ok(RunStatus::Stopped)); | |
| 300 | } | |
| 301 | self.add_step(run_id, &now, &said)?.run().await?; | |
| 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. | 302 | // Said on its pull request, if it was for one. |
| 303 | if let (Some(_), Some(number)) = (pull_id, number) { | |
| 304 | let noted = match halt { | |
| 305 | Halt::Abuse => format!("stopped its {kind} run for unusual CPU use"), | |
| 306 | Halt::Budget => format!("stopped its {kind} run at its cost cap"), | |
| 307 | Halt::Time => format!("stopped its {kind} run at its time cap"), | |
| Fixes from the page sweep | 308 | }; |
| 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. | 309 | self.note(repo_id, number, (POLICY_ACTOR_ID, POLICY_ACTOR_NAME), ¬ed).await?; |
| Fixes from the page sweep | 310 | } |
| 311 | Ok(Outcome::Ok(RunStatus::Stopped)) | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | /// What a change to one level did, for the audit log: the workflow-only | |
| 316 | /// domains added and removed, each with what it is limited to, and | |
| 317 | /// whether anything else changed. | |
| 318 | fn guardrails_change(before: &GuardrailSettings, after: &GuardrailSettings) -> String { | |
| 319 | let describe = |entry: &WorkflowDomain| { | |
| 320 | let workflows = if entry.workflows.is_empty() { "any workflow".to_owned() } else { entry.workflows.join(", ") }; | |
| 321 | let environments = if entry.environments.is_empty() { "any environment".to_owned() } else { entry.environments.join(", ") }; | |
| 322 | format!("{} ({workflows}; {environments})", entry.domain) | |
| 323 | }; | |
| 324 | let added: Vec<String> = after.workflow_domains.iter().filter(|e| !before.workflow_domains.contains(e)).map(describe).collect(); | |
| 325 | let removed: Vec<String> = before.workflow_domains.iter().filter(|e| !after.workflow_domains.contains(e)).map(describe).collect(); | |
| 326 | let mut parts = Vec::new(); | |
| 327 | if !added.is_empty() { | |
| 328 | parts.push(format!("Workflow-only domains added: {}.", added.join("; "))); | |
| 329 | } | |
| 330 | if !removed.is_empty() { | |
| 331 | parts.push(format!("Workflow-only domains removed: {}.", removed.join("; "))); | |
| 332 | } | |
| 333 | let rest = |s: &GuardrailSettings| GuardrailSettings { workflow_domains: Vec::new(), updated_by: None, updated_at: None, ..s.clone() }; | |
| 334 | if rest(before) != rest(after) { | |
| 335 | parts.push("Other guardrails changed.".to_owned()); | |
| 336 | } | |
| 337 | if parts.is_empty() { "Guardrails saved unchanged.".to_owned() } else { parts.join(" ") } | |
| 338 | } | |
| 339 | ||
| 340 | /// The pull request id of a pull request's working copy | |
| 341 | /// (`pulls/<pull id>`, made by repos `fork_for_pull`), if `path` is one. | |
| 342 | fn working_copy_of(path: &RepoPath) -> Option<String> { | |
| 343 | (path.namespace.eq_ignore_ascii_case(WORKING_COPIES) && !path.name.is_empty()) | |
| 344 | .then(|| path.name.to_lowercase()) | |
| 345 | } | |
| 346 | ||
| 347 | /// Where repos keeps every pull request's working copy. | |
| 348 | const WORKING_COPIES: &str = "pulls"; | |
| 349 | ||
| 350 | /// A principal that can read any repository of `workspace`, for the | |
| 351 | /// runner's lookups. | |
| 352 | fn member_of_service(workspace: &str) -> Viewer { | |
| 353 | member_of( | |
| 354 | &User { | |
| 355 | id: "svc_runner".to_owned(), | |
| 356 | username: "g1t".to_owned(), | |
| 357 | ..User::default() | |
| 358 | }, | |
| 359 | workspace, | |
| 360 | ) | |
| 361 | } | |
| 362 | ||
| 363 | #[cfg(test)] | |
| 364 | mod tests { | |
| 365 | use super::*; | |
| 366 | ||
| 367 | fn path(namespace: &str, name: &str) -> RepoPath { | |
| 368 | RepoPath { | |
| 369 | namespace: namespace.to_owned(), | |
| 370 | name: name.to_owned(), | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | #[test] | |
| 375 | fn a_working_copy_names_its_pull_request() { | |
| 376 | assert_eq!( | |
| 377 | working_copy_of(&path("pulls", "pr_01m45bd1b2e359sayh78w977kv")).as_deref(), | |
| 378 | Some("pr_01m45bd1b2e359sayh78w977kv") | |
| 379 | ); | |
| 380 | assert_eq!(working_copy_of(&path("Pulls", "PR_7")).as_deref(), Some("pr_7")); | |
| 381 | assert_eq!(working_copy_of(&path("flagon-io", "automation-lab")), None); | |
| 382 | assert_eq!(working_copy_of(&path("pulls", "")), None); | |
| 383 | } | |
| 384 | ||
| 385 | #[test] | |
| 386 | fn run_guardrails_take_an_optional_repo_id() { | |
| 387 | let old: RunGuardrailsArgs = | |
| 388 | serde_json::from_str(r#"{"repo":{"namespace":"pulls","name":"pr_1"}}"#).unwrap(); | |
| 389 | assert!(old.repo_id.is_none()); | |
| 390 | let by_id: RunGuardrailsArgs = serde_json::from_str( | |
| 391 | r#"{"repo":{"namespace":"syntaqx","name":"automation-lab"},"repo_id":"rep_1"}"#, | |
| 392 | ) | |
| 393 | .unwrap(); | |
| 394 | assert_eq!(by_id.repo_id.as_deref(), Some("rep_1")); | |
| 395 | } | |
| 396 | ||
| 397 | #[test] | |
| 398 | fn a_change_to_workflow_domains_is_described_for_the_audit_log() { | |
| 399 | let deploy = WorkflowDomain { | |
| 400 | domain: "api.cloudflare.com".into(), | |
| 401 | workflows: vec!["deploy.yml".into()], | |
| 402 | environments: vec!["production".into()], | |
| 403 | }; | |
| 404 | let open = WorkflowDomain { domain: "*.example.com".into(), ..WorkflowDomain::default() }; | |
| 405 | let before = GuardrailSettings { workflow_domains: vec![open.clone()], ..GuardrailSettings::default() }; | |
| 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. | 406 | let after = GuardrailSettings { workflow_domains: vec![deploy], restrict_network: Some(false), ..GuardrailSettings::default() }; |
| Fixes from the page sweep | 407 | assert_eq!( |
| 408 | guardrails_change(&before, &after), | |
| 409 | "Workflow-only domains added: api.cloudflare.com (deploy.yml; production). Workflow-only domains removed: *.example.com (any workflow; any environment). Other guardrails changed." | |
| 410 | ); | |
| 411 | assert_eq!(guardrails_change(&before, &before), "Guardrails saved unchanged."); | |
| 412 | } | |
| 413 | ||
| 414 | #[test] | |
| 415 | fn renames_take_two_parameters() { | |
| 416 | for sql in RENAMED { | |
| 417 | assert!(sql.contains("?1") && sql.contains("?2"), "{sql}"); | |
| 418 | } | |
| 419 | } | |
| 420 | ||
| 421 | #[test] | |
| 422 | fn a_level_reads_back_with_who_changed_it() { | |
| 423 | let row = SettingsRow { | |
| 424 | settings: r#"{"budgetUsd":2.5,"domains":["example.com"]}"#.to_owned(), | |
| 425 | updated_by: "ada".to_owned(), | |
| 426 | updated_at: "2026-10-04T00:00:00Z".to_owned(), | |
| 427 | }; | |
| 428 | let settings = GuardrailSettings::from(row); | |
| 429 | assert_eq!(settings.domains, vec!["example.com"]); | |
| 430 | assert_eq!(settings.updated_by.as_deref(), Some("ada")); | |
| 431 | } | |
| 432 | ||
| 433 | #[test] | |
| 434 | fn a_corrupt_level_reads_as_inheriting_everything() { | |
| 435 | let row = SettingsRow { | |
| 436 | settings: "not json".to_owned(), | |
| 437 | updated_by: "ada".to_owned(), | |
| 438 | updated_at: "2026-10-04T00:00:00Z".to_owned(), | |
| 439 | }; | |
| 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. | 440 | let settings = GuardrailSettings::from(row); |
| 441 | assert!(settings.domains.is_empty() && settings.restrict_network.is_none()); | |
| Fixes from the page sweep | 442 | } |
| 443 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.