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.
Events stops publishing agent.asked, review.completed and memory.changed, drops confidence from pull events and no longer queues events for the context hub or workspace agents. Webhooks, Actions, repos and packages drop the checks that existed only for g1t's agent, while still showing past work by @g1t as before; a run's token is still held to its run. A "g1t" bypass in a ruleset now means g1t itself, not agents. No tables are dropped.
35 files+412−70933 files not counted0/35 viewed
| 310 | 310 | if below && approvals < rule.required_approvals.max(1) { | |
| 311 | 311 | let rated = match facts.confidence { | |
| 312 | 312 | Some(level) => format!("g1t rates this agent's change {} confidence", level.as_str()), | |
| 313 | − | None => "g1t has not rated this agent's change yet".to_owned(), | |
| 313 | + | None => "This is an agent's change and has no confidence rating".to_owned(), | |
| 314 | 314 | }; | |
| 315 | 315 | vec![Problem::new( | |
| 316 | 316 | format!( |
| 175 | 175 | fn names(entry: &BypassActor, who: &ActorFacts, workspace: &str) -> bool { | |
| 176 | 176 | let value = entry.value.trim(); | |
| 177 | 177 | match entry.kind { | |
| 178 | − | // g1t's agents act through run tokens (`Agent`), and g1t on its own | |
| 179 | − | // as `System`. | |
| 180 | − | ActorKind::G1t => who.kind.is_agent(), | |
| 178 | + | // g1t on its own, as `System`: the merge queue, security updates, | |
| 179 | + | // a merge its settings make. An agent acting through a token is | |
| 180 | + | // named only by that token. | |
| 181 | + | ActorKind::G1t => who.kind == Who::G1t, | |
| 181 | 182 | ActorKind::Token => { | |
| 182 | 183 | (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token) | |
| 183 | 184 | || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value) | |
| ⋯ | |||
| 415 | 416 | } | |
| 416 | 417 | ||
| 417 | 418 | #[test] | |
| 418 | − | fn agents_bypass_only_when_g1t_or_their_token_is_listed() { | |
| 419 | + | fn agents_bypass_only_when_their_token_is_listed() { | |
| 419 | 420 | let agent = ActorFacts { | |
| 420 | 421 | username: "g1t".into(), | |
| 421 | 422 | kind: Who::Agent, | |
| ⋯ | |||
| 426 | 427 | }; | |
| 427 | 428 | let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)]; | |
| 428 | 429 | assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role"); | |
| 429 | − | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always)); | |
| 430 | + | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), None, "g1t names g1t itself, not agents"); | |
| 430 | 431 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always)); | |
| 431 | 432 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None); | |
| 432 | 433 | let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() }; | |
| 195 | 195 | ||
| 196 | 196 | /// The repository at `path`, when `actor` may do `capability` in it: | |
| 197 | 197 | /// not found when they cannot read it, forbidden when their role falls | |
| 198 | − | /// short. Agents never may: people and tokens run and change workflows. | |
| 198 | + | /// short. | |
| 199 | 199 | async fn may(&self, actor: &User, path: &RepoPath, capability: Capability) -> Result<Outcome<Repo>> { | |
| 200 | − | if actor.kind == PrincipalKind::Agent { | |
| 201 | − | return Ok(fail(FailureCode::Forbidden, "An agent cannot do that. Ask a person.")); | |
| 202 | − | } | |
| 203 | 200 | let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else { | |
| 204 | 201 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 205 | 202 | }; |
| 23 | 23 | EnvironmentsArgs, MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES, PendingDeployment, PendingDeploymentsArgs, ReviewDeploymentsArgs, | |
| 24 | 24 | RunActionArgs, RunApproval, SetActionsSettingsArgs, SetEnvironmentArgs, WorkflowRun, | |
| 25 | 25 | }; | |
| 26 | − | use g1t_contracts::identity::AGENT_ID; | |
| 27 | 26 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 28 | 27 | use g1t_contracts::time::rfc3339; | |
| 29 | 28 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| ⋯ | |||
| 1080 | 1079 | /// repository's policy, whoever the pull request is for (its owner). | |
| 1081 | 1080 | pub(crate) async fn approval_needed(&self, repo: &Repo, owner: &User, ws: &User) -> Result<Option<String>> { | |
| 1082 | 1081 | // g1t's own work, nobody asked for, is the workspace's. | |
| 1083 | − | if owner.id == AGENT_ID || owner.kind == PrincipalKind::System { | |
| 1082 | + | if owner.kind == PrincipalKind::System { | |
| 1084 | 1083 | return Ok(None); | |
| 1085 | 1084 | } | |
| 1086 | 1085 | let policy = self.approval_policy(&repo.id).await?; | |
| 236 | 236 | /// Where `owner` points, if `actor` may see it (`manage` false) or | |
| 237 | 237 | /// change it (`manage` true): a workspace's runners are seen by its | |
| 238 | 238 | /// members and changed by its owners; a repository's own, by its | |
| 239 | − | /// admins. Agents and workspace tokens never change them, so a | |
| 240 | − | /// workflow's `G1T_TOKEN` cannot add a machine to run its own jobs. | |
| 239 | + | /// admins. Workspace tokens never change them, so a workflow's | |
| 240 | + | /// `G1T_TOKEN` cannot add a machine to run its own jobs. | |
| 241 | 241 | async fn runner_place(&self, actor: &User, owner: &RunnersOwner, manage: bool) -> Result<Outcome<Place>> { | |
| 242 | − | if actor.kind == PrincipalKind::Agent { | |
| 243 | − | return Ok(fail(FailureCode::Forbidden, "Agents cannot see or change self-hosted runners.")); | |
| 244 | − | } | |
| 245 | 242 | if manage && actor.kind == PrincipalKind::Workspace { | |
| 246 | 243 | return Ok(fail( | |
| 247 | 244 | FailureCode::Forbidden, |
| 227 | 227 | ||
| 228 | 228 | impl Actions { | |
| 229 | 229 | async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> { | |
| 230 | − | if actor.kind == PrincipalKind::Agent { | |
| 231 | − | return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables.")); | |
| 232 | − | } | |
| 233 | 230 | // A workspace's tokens, G1T_TOKEN among them, read the names but | |
| 234 | 231 | // never change them: a workflow must not rewrite what it runs with. | |
| 235 | 232 | if changing && actor.kind == PrincipalKind::Workspace { |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | 130 | /// Whether whoever a pull request is for is trusted without asking | |
| 131 | − | /// identity: g1t's agent in work nobody asked it for, or someone whose | |
| 132 | − | /// role here is known to allow pushing. | |
| 131 | + | /// identity: someone whose role here is known to allow pushing. | |
| 133 | 132 | fn trusted_outright(owner: &User, repo: &Repo) -> bool { | |
| 134 | − | owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push) | |
| 133 | + | access::can(Some(owner), repo, Capability::Push) | |
| 135 | 134 | } | |
| 136 | 135 | ||
| 137 | 136 | impl Actions { |
| 9 | 9 | //! | |
| 10 | 10 | //! | Event | Who | Reason | Severity | | |
| 11 | 11 | //! | --- | --- | --- | --- | | |
| 12 | − | //! | `agent.asked`, `pull.stalled` | the pull request's owner, and its issue's owner and assignees | agent | warning | | |
| 12 | + | //! | `pull.stalled` | the pull request's owner, and its issue's owner and assignees | agent | warning | | |
| 13 | 13 | //! | `pull.review_requested` | the reviewers asked, and the people each team asked tells | review_requested | warning | | |
| 14 | 14 | //! | `issue.assigned`, `pull.assigned` | the people newly assigned | assign | info | | |
| 15 | 15 | //! | `checks.completed`, failed or errored | the pull request's owner | ci_activity | error | | |
| 16 | 16 | //! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | ci_activity | error | | |
| 17 | 17 | //! | `deployment.failed` | the pull request's owner, or whoever pushed; watchers | ci_activity | error | | |
| 18 | 18 | //! | `deployment.succeeded` after a failure | the same | ci_activity | success | | |
| 19 | − | //! | `review.completed` by g1t | the pull request's owner | author | success, or info for changes asked | | |
| 20 | − | //! | `pull.ready` for a change g1t made | whoever asked g1t for it | author | success | | |
| 21 | 19 | //! | `pull.merged`, `pull.closed`, `issue.closed`, `issue.reopened` | everyone subscribed | state_change | success for a merge, else info | | |
| 22 | 20 | //! | `comment.created` | everyone mentioned, the people of teams mentioned, then everyone subscribed | mention, team_mention, or why they are subscribed | info (success for an approval) | | |
| 23 | 21 | //! | `comment.created` by a workspace's agent | the same, shown as "Margo (agent)"; a review also tells the owner, "(advisory)" | the same, or author | the same | | |
| ⋯ | |||
| 133 | 131 | }) | |
| 134 | 132 | }; | |
| 135 | 133 | match event.kind.as_str() { | |
| 136 | − | "agent.asked" | "pull.stalled" | "pull.merged" | "pull.closed" | "pull.ready" | "pull.opened" | |
| 134 | + | "pull.stalled" | "pull.merged" | "pull.closed" | "pull.opened" | |
| 137 | 135 | | "pull.review_requested" | "pull.assigned" | "issue.opened" | "issue.closed" | "issue.reopened" | |
| 138 | 136 | | "issue.assigned" => on(Some(number("number")?), None), | |
| 139 | 137 | "checks.completed" => match data["status"].as_str() { | |
| 140 | 138 | Some("failed" | "errored") => on(Some(number("number")?), None), | |
| 141 | − | _ => None, | |
| 142 | − | }, | |
| 143 | − | "review.completed" => match data["verdict"].as_str() { | |
| 144 | − | Some("approve" | "request_changes") => on(Some(number("number")?), None), | |
| 145 | 139 | _ => None, | |
| 146 | 140 | }, | |
| 147 | 141 | "workflow.completed" => match data["conclusion"].as_str() { | |
| ⋯ | |||
| 407 | 401 | ||
| 408 | 402 | /// Who is told of `event`, in `repo` (`owner/name`), given what it names | |
| 409 | 403 | /// and who follows it. `actor` is who caused it; outcomes nobody chose | |
| 410 | − | /// (checks, workflows, deployments, a review, an agent finishing or | |
| 411 | − | /// stopping) are told whoever caused them. | |
| 404 | + | /// (checks, workflows, deployments, g1t stopping) are told whoever caused them. | |
| 412 | 405 | pub fn notices(event: &Event, repo: &str, actor: &Actor, subject: Option<&InboxSubject>, audience: &Audience) -> Vec<Notice> { | |
| 413 | 406 | let nobody = Actor::default(); | |
| 414 | 407 | let data = &event.data; | |
| ⋯ | |||
| 421 | 414 | event.kind.as_str(), | |
| 422 | 415 | "checks.completed" | |
| 423 | 416 | | "workflow.completed" | |
| 424 | − | | "review.completed" | |
| 425 | − | | "pull.ready" | |
| 426 | − | | "agent.asked" | |
| 427 | 417 | | "pull.stalled" | |
| 428 | 418 | | "deployment.failed" | |
| 429 | 419 | | "deployment.succeeded" | |
| ⋯ | |||
| 439 | 429 | let who = actor.name().unwrap_or("g1t"); | |
| 440 | 430 | ||
| 441 | 431 | match (event.kind.as_str(), subject) { | |
| 442 | − | ("agent.asked" | "pull.stalled", Some(pull)) => { | |
| 443 | − | let (title, body) = if event.kind == "agent.asked" { | |
| 444 | − | (format!("An agent is waiting on {at}"), pull.title.clone()) | |
| 445 | − | } else { | |
| 446 | − | let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty()); | |
| 447 | − | (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned()) | |
| 448 | − | }; | |
| 432 | + | ("pull.stalled", Some(pull)) => { | |
| 433 | + | let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty()); | |
| 434 | + | let (title, body) = (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned()); | |
| 449 | 435 | told.tell_person(pull.owner(), Reason::Agent, Severity::Warning, &title, &body, true); | |
| 450 | 436 | if let Some(issue) = &pull.issue { | |
| 451 | 437 | told.tell_person(issue.owner(), Reason::Agent, Severity::Warning, &title, &body, true); | |
| ⋯ | |||
| 569 | 555 | } | |
| 570 | 556 | told.tell_watchers("deployments", severity, &title, &body); | |
| 571 | 557 | } | |
| 572 | − | ("review.completed", Some(pull)) => { | |
| 573 | − | let (title, severity) = match data["verdict"].as_str() { | |
| 574 | − | Some("approve") => (format!("g1t approved {at}"), Severity::Success), | |
| 575 | − | _ => (format!("g1t asked for changes on {at}"), Severity::Info), | |
| 576 | − | }; | |
| 577 | − | told.tell_person(pull.owner(), Reason::Author, severity, &title, &pull.title, true); | |
| 578 | − | } | |
| 579 | − | ("pull.ready", Some(pull)) => { | |
| 580 | − | // A change g1t made is ready: the agent's run is over. | |
| 581 | − | if let Some(owner) = pull.requested_by.as_ref().filter(|_| is_g1t_id(&pull.author.id) || is_g1t(&pull.author.username)) { | |
| 582 | − | let title = format!("g1t finished {at}"); | |
| 583 | − | told.tell_person(owner, Reason::Author, Severity::Success, &title, &pull.title, true); | |
| 584 | − | } | |
| 585 | − | } | |
| 586 | 558 | ("pull.merged" | "pull.closed" | "issue.closed" | "issue.reopened", Some(on)) => { | |
| 587 | 559 | let (verb, severity) = match event.kind.as_str() { | |
| 588 | 560 | "pull.merged" => ("merged", Severity::Success), | |
| ⋯ | |||
| 1445 | 1417 | /// from where it links, and the item's title and first line. | |
| 1446 | 1418 | pub fn live_notification(event_id: &str, notice: &Notice, url: &str, at: &str) -> serde_json::Value { | |
| 1447 | 1419 | let kind = match notice.reason { | |
| 1448 | − | Reason::Agent => "agent_waiting", | |
| 1449 | 1420 | Reason::ReviewRequested => "approval", | |
| 1450 | 1421 | Reason::Mention | Reason::TeamMention => "mention", | |
| 1451 | 1422 | // A critical or high alert, a refused push, a report or a bypass to | |
| ⋯ | |||
| 2170 | 2141 | Some(Wanted { repo_id: "rep_1".into(), number: Some(4), comment_id: None }) | |
| 2171 | 2142 | ); | |
| 2172 | 2143 | assert_eq!(asked("checks.completed", json!({ "repoId": "rep_1", "number": 4, "status": "passed" })), None); | |
| 2173 | − | assert_eq!(asked("review.completed", json!({ "repoId": "rep_1", "number": 4 })), None); | |
| 2144 | + | assert_eq!(asked("review.completed", json!({ "repoId": "rep_1", "number": 4, "verdict": "approve" })), None); | |
| 2145 | + | assert_eq!(asked("agent.asked", json!({ "repoId": "rep_1", "number": 4 })), None); | |
| 2174 | 2146 | assert_eq!(asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "success" })), None); | |
| 2175 | 2147 | assert_eq!( | |
| 2176 | 2148 | asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "failure" })), | |
| ⋯ | |||
| 2188 | 2160 | } | |
| 2189 | 2161 | ||
| 2190 | 2162 | #[test] | |
| 2191 | − | fn a_waiting_or_stopped_agent_needs_the_pull_requests_and_the_issues_people_first() { | |
| 2192 | − | let asked = event("agent.asked", Some("usr_agent"), json!({ "number": 7 })); | |
| 2193 | − | let notices_ = notices(&asked, "acme/rocket", &Actor::default(), Some(&pull()), &nobody()); | |
| 2163 | + | fn a_stopped_pull_request_needs_its_people_and_its_issues_people_first() { | |
| 2164 | + | let stalled = event("pull.stalled", None, json!({ "number": 7 })); | |
| 2165 | + | let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&pull()), &nobody()); | |
| 2194 | 2166 | assert_eq!( | |
| 2195 | 2167 | told(¬ices_), | |
| 2196 | 2168 | vec![ | |
| ⋯ | |||
| 2199 | 2171 | ("dee", Reason::Agent, Severity::Warning), | |
| 2200 | 2172 | ] | |
| 2201 | 2173 | ); | |
| 2202 | − | assert_eq!(notices_[0].title, "An agent is waiting on acme/rocket#7"); | |
| 2174 | + | assert_eq!(notices_[0].title, "g1t stopped on acme/rocket#7 and needs you"); | |
| 2203 | 2175 | assert_eq!(notices_[0].body, "Add the inbox"); | |
| 2204 | 2176 | // Stopping says why; even someone who unsubscribed hears of it. | |
| 2205 | 2177 | let stalled = event("pull.stalled", None, json!({ "number": 7, "detail": "Its checks could not be run." })); | |
| ⋯ | |||
| 2634 | 2606 | let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience); | |
| 2635 | 2607 | // cy was mentioned, which is asked of them; ana unsubscribed from the conversation. | |
| 2636 | 2608 | assert_eq!(told(¬ices_), vec![("cy", Reason::Mention, Severity::Info)]); | |
| 2637 | − | } | |
| 2638 | − | ||
| 2639 | − | #[test] | |
| 2640 | − | fn g1t_finishing_or_reviewing_tells_the_person_it_worked_for() { | |
| 2641 | − | // The agent acts as the person it works for: still an outcome they hear of. | |
| 2642 | − | let ready = event("pull.ready", Some("usr_ana"), json!({ "number": 7 })); | |
| 2643 | − | let notices_ = notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&g1t_pull()), &nobody()); | |
| 2644 | − | assert_eq!(told(¬ices_), vec![("ana", Reason::Author, Severity::Success)]); | |
| 2645 | − | assert_eq!(notices_[0].title, "g1t finished acme/rocket#7"); | |
| 2646 | − | // A person's draft marked ready is not news to them. | |
| 2647 | − | assert!(notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody()).is_empty()); | |
| 2648 | − | ||
| 2649 | − | let approve = event("review.completed", None, json!({ "number": 7, "verdict": "approve" })); | |
| 2650 | − | assert_eq!( | |
| 2651 | − | told(¬ices(&approve, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), | |
| 2652 | − | vec![("ana", Reason::Author, Severity::Success)] | |
| 2653 | − | ); | |
| 2654 | − | let changes = event("review.completed", None, json!({ "number": 7, "verdict": "request_changes" })); | |
| 2655 | − | assert_eq!( | |
| 2656 | − | told(¬ices(&changes, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), | |
| 2657 | − | vec![("ana", Reason::Author, Severity::Info)] | |
| 2658 | − | ); | |
| 2659 | 2609 | } | |
| 2660 | 2610 | ||
| 2661 | 2611 | #[test] | |
| ⋯ | |||
| 2808 | 2758 | }; | |
| 2809 | 2759 | let live = live_notification("evt_1", ¬ice, "/Acme/api/pull/4#c1", "2026-10-08T00:00:00Z"); | |
| 2810 | 2760 | assert_eq!(live["id"], "inbox:evt_1"); | |
| 2811 | − | assert_eq!(live["kind"], "agent_waiting"); | |
| 2761 | + | // A stop that needs a person is an ordinary inbox item. | |
| 2762 | + | assert_eq!(live["kind"], "inbox"); | |
| 2812 | 2763 | assert_eq!(live["workspace"], "acme"); | |
| 2813 | 2764 | assert_eq!(live["href"], "/Acme/api/pull/4#c1"); | |
| 2814 | 2765 | assert_eq!(live["actor"]["name"], "acme/api"); | |
| 33 | 33 | { "binding": "SUBSCRIBER_REPOS", "queue": "g1t-events-repos" }, | |
| 34 | 34 | { "binding": "SUBSCRIBER_BILLING", "queue": "g1t-events-billing" }, | |
| 35 | 35 | { "binding": "SUBSCRIBER_SECURITY", "queue": "g1t-events-security" }, | |
| 36 | − | { "binding": "SUBSCRIBER_CONTEXT", "queue": "g1t-events-context" }, | |
| 37 | 36 | { "binding": "SUBSCRIBER_SEARCH", "queue": "g1t-events-search" }, | |
| 38 | 37 | { "binding": "SUBSCRIBER_PACKAGES", "queue": "g1t-events-packages" }, | |
| 39 | 38 | // Pages that cite code a merge changed, and projects' docs folders | |
| 40 | 39 | // (services/artifacts/src/staleness.ts). Create it once: | |
| 41 | 40 | // npx wrangler queues create g1t-events-docs | |
| 42 | − | { "binding": "SUBSCRIBER_DOCS", "queue": "g1t-events-docs" }, | |
| 43 | − | // Agents' routines that run on events (services/agents/src/triggers.ts). | |
| 44 | − | { "binding": "SUBSCRIBER_AGENTS", "queue": "g1t-events-agents" } | |
| 41 | + | { "binding": "SUBSCRIBER_DOCS", "queue": "g1t-events-docs" } | |
| 45 | 42 | ], | |
| 46 | 43 | "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }] | |
| 47 | 44 | }, |
| 17 | 17 | //! selection; a workspace's own token is a member with Write unless an | |
| 18 | 18 | //! owner gave it Admin. A workflow job's token reaches a package only from | |
| 19 | 19 | //! the repository it is linked to (Write) or from a repository listed under | |
| 20 | − | //! the package's Manage Actions access, with that role. An agent's run | |
| 21 | − | //! token may push only where its run may push code. A deploy key never | |
| 20 | + | //! the package's Manage Actions access, with that role. A sandbox run's | |
| 21 | + | //! token (checks, the merge queue, a deployment) may push only where its | |
| 22 | + | //! run may push code. A deploy key never | |
| 22 | 23 | //! reaches packages. | |
| 23 | 24 | ||
| 24 | 25 | use g1t_contracts::access::{self, RepoRef, RepoRole}; | |
| ⋯ | |||
| 186 | 187 | } | |
| 187 | 188 | ||
| 188 | 189 | /// `user`'s role on the package, and the rule it comes from. A person (or | |
| 189 | − | /// an agent's run, as the person it works for) only; tokens are checked | |
| 190 | + | /// a sandbox run, as the person it works for) only; tokens are checked | |
| 190 | 191 | /// before. | |
| 191 | 192 | pub fn role_of(user: &User, target: &Target<'_>) -> (Option<PackageRole>, &'static str) { | |
| 192 | 193 | let public = target.is_public().then_some(PackageRole::Read); | |
| ⋯ | |||
| 292 | 293 | }; | |
| 293 | 294 | }; | |
| 294 | 295 | ||
| 295 | − | // An agent's run token: only where its run may read or push code, and | |
| 296 | + | // A sandbox run's token: only where its run may read or push code, and | |
| 296 | 297 | // then as the person it works for. | |
| 297 | − | if user.kind == PrincipalKind::Agent { | |
| 298 | + | if user.kind == PrincipalKind::Agent || user.acting.is_some() { | |
| 298 | 299 | let Some(scope) = user.acting.as_ref().map(|acting| acting.scope.clone()) else { | |
| 299 | − | return Decision::deny("agent", "This agent token cannot use packages."); | |
| 300 | + | return Decision::deny("agent", "This run's token cannot use packages."); | |
| 300 | 301 | }; | |
| 301 | 302 | if action.administers() { | |
| 302 | − | return Decision::deny("agent", "A g1t agent cannot delete packages or change their settings."); | |
| 303 | + | return Decision::deny("agent", "A run's token cannot delete packages or change their settings."); | |
| 303 | 304 | } | |
| 304 | 305 | let Some(repo) = target.repo else { | |
| 305 | − | return Decision::deny("agent", "A g1t agent can use only the packages of the repository it works on."); | |
| 306 | + | return Decision::deny("agent", "A run's token can use only the packages of the repository it runs in."); | |
| 306 | 307 | }; | |
| 307 | 308 | let path = RepoPath { namespace: target.workspace.to_owned(), name: repo.name.to_owned() }; | |
| 308 | 309 | let decision = credentials::decide_git(&scope, &path, action == Action::Push); | |
| ⋯ | |||
| 311 | 312 | } | |
| 312 | 313 | match credentials::as_person(&user) { | |
| 313 | 314 | Some(person) => user = person, | |
| 314 | − | None => return Decision::deny("agent", "This agent token cannot use packages."), | |
| 315 | + | None => return Decision::deny("agent", "This run's token cannot use packages."), | |
| 315 | 316 | } | |
| 316 | 317 | } | |
| 317 | 318 | ||
| ⋯ | |||
| 722 | 723 | repo: path("web"), | |
| 723 | 724 | operations: vec![], | |
| 724 | 725 | run: Some(RunBinding { | |
| 725 | − | kind: RunCredentialKind::Implement, | |
| 726 | + | kind: RunCredentialKind::Checks, | |
| 726 | 727 | usage: CredentialUse::Runner, | |
| 727 | 728 | run_id: None, | |
| 728 | 729 | number: None, | |
| 1453 | 1453 | Some(_) => not_found(), | |
| 1454 | 1454 | None => Outcome::fail(FailureCode::Unauthenticated, "Authentication required."), | |
| 1455 | 1455 | }; | |
| 1456 | − | // An agent's token works through the API only: its sandbox has its | |
| 1456 | + | // A run's token works through the API only: its sandbox has its | |
| 1457 | 1457 | // own way to push, to its own pull request. | |
| 1458 | 1458 | if a.viewer.as_ref().is_some_and(|user| user.kind == PrincipalKind::Agent) { | |
| 1459 | 1459 | return Ok(Outcome::fail( | |
| 1460 | 1460 | FailureCode::Forbidden, | |
| 1461 | − | "A g1t agent's token cannot be used with git.", | |
| 1461 | + | "A run's token cannot be used with git this way.", | |
| 1462 | 1462 | )); | |
| 1463 | 1463 | } | |
| 1464 | 1464 | if let (true, Some(user)) = (write, &a.viewer) |
| 144 | 144 | entry: None, | |
| 145 | 145 | }); | |
| 146 | 146 | }; | |
| 147 | − | let run_scope = user | |
| 148 | − | .acting | |
| 149 | − | .as_ref() | |
| 150 | − | .filter(|_| user.kind == PrincipalKind::Agent) | |
| 151 | − | .map(|acting| acting.scope.clone()); | |
| 147 | + | // A run credential carries its scope; whatever kind of principal | |
| 148 | + | // it resolves as, it is held to that scope. | |
| 149 | + | let run_scope = user.acting.as_ref().map(|acting| acting.scope.clone()); | |
| 152 | 150 | let entry = |target: AuditTarget, decision: &Decision| { | |
| 153 | 151 | NewAuditEntry::new( | |
| 154 | 152 | AuditActor::of(&user), |
| 212 | 212 | /// and the repository when they are a repository's. A repository's | |
| 213 | 213 | /// need the Admin role on it, to see as to change, since they carry | |
| 214 | 214 | /// its events out; the workspace's are its members' to see and its | |
| 215 | − | /// owners' to change. An agent's token changes neither. | |
| 215 | + | /// owners' to change. | |
| 216 | 216 | async fn allowed(&self, viewer: &Viewer, owner: &HookOwner, managing: bool) -> Result<Outcome<Option<Repo>>> { | |
| 217 | 217 | let Some(user) = viewer.as_ref() else { | |
| 218 | 218 | return Ok(fail(FailureCode::Forbidden, "Sign in to see webhooks.")); | |
| 219 | 219 | }; | |
| 220 | − | if managing && user.kind == PrincipalKind::Agent { | |
| 221 | − | return Ok(fail(FailureCode::Forbidden, "An agent cannot manage webhooks.")); | |
| 222 | − | } | |
| 223 | 220 | if owner.repo.is_some() { | |
| 224 | 221 | let Some(repo) = self.repository(owner, viewer).await? else { | |
| 225 | 222 | return Ok(fail(FailureCode::NotFound, "There is no such repository in this workspace.")); |
| 1 | − | //! A workspace's own agents (Margo, @margo) commenting on issues and pull | |
| 2 | − | //! requests, and reviewing pull requests, as themselves, on behalf of a | |
| 3 | − | //! person whose access caps them. The agents service calls these; nothing | |
| 4 | − | //! reaches them with a person's token. | |
| 5 | − | //! | |
| 6 | − | //! What an agent writes is stored with the agent as its author and the | |
| 7 | − | //! person it acted for beside it (migrations/0033_agent_comments.sql). Its | |
| 8 | − | //! review is advisory: the verdict is kept in `agent_verdict`, never in | |
| 9 | − | //! `verdict`, which every rule about approvals reads (required approvals | |
| 10 | − | //! in mergeability.rs and rulesets.rs through the `Verdicts` prefetch, | |
| 11 | − | //! a person's request for changes in lifecycle.rs `person_request`, | |
| 12 | − | //! confidence.rs, contributions.rs), and code owners skip it by name | |
| 13 | − | //! (codeowners.rs `latest_verdicts`). So it never satisfies a required | |
| 14 | − | //! approval or a code owner, and never blocks a merge. | |
| 15 | − | ||
| 16 | − | use g1t_contracts::credentials::Principal; | |
| 17 | − | use g1t_contracts::events::CommentCreated; | |
| 18 | − | use g1t_contracts::time::rfc3339; | |
| 19 | − | use g1t_contracts::work::*; | |
| 20 | − | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, new_id}; | |
| 21 | − | use g1t_kit::now_ms; | |
| 22 | − | use serde::Deserialize; | |
| 23 | − | use worker::Result; | |
| 24 | − | use worker::wasm_bindgen::JsValue; | |
| 25 | − | ||
| 26 | − | use crate::retired::writable; | |
| 27 | − | use crate::{MAX_ENTRY_CHARS, UNVERIFIED, Work}; | |
| 28 | − | ||
| 29 | − | /// What an agent may write where, before anything is read: why not, or none. | |
| 30 | − | fn refusal(agent: &AgentRef, body: &str, verdict: Option<AgentVerdict>) -> Option<&'static str> { | |
| 31 | − | if agent.id.trim().is_empty() || agent.handle.trim().is_empty() { | |
| 32 | − | return Some("Name the agent: its id and handle."); | |
| 33 | − | } | |
| 34 | − | // An approval speaks for itself; anything else has to say something. | |
| 35 | − | if body.is_empty() && verdict != Some(AgentVerdict::Approve) { | |
| 36 | − | return Some("A comment cannot be empty."); | |
| 37 | − | } | |
| 38 | − | if body.chars().count() > MAX_ENTRY_CHARS { | |
| 39 | − | return Some("That comment is too long."); | |
| 40 | − | } | |
| 41 | − | None | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | /// Why an agent may not review a pull request as it stands, or none. | |
| 45 | − | fn review_refusal(status: PullStatus) -> Option<&'static str> { | |
| 46 | − | match status { | |
| 47 | − | PullStatus::Open => None, | |
| 48 | − | PullStatus::Draft => Some("A draft is still being worked on; an agent reviews it once it is ready for review."), | |
| 49 | − | PullStatus::Merged | PullStatus::Closed => Some("Only an open pull request can be reviewed."), | |
| 50 | − | } | |
| 51 | − | } | |
| 52 | − | ||
| 53 | − | /// Whether an agent that wrote `written` times on one issue or pull request | |
| 54 | − | /// in the last hour may write again. | |
| 55 | − | fn within_limit(written: u32) -> bool { | |
| 56 | − | written < AGENT_COMMENTS_PER_WINDOW | |
| 57 | − | } | |
| 58 | − | ||
| 59 | − | #[derive(Deserialize)] | |
| 60 | − | struct Count { | |
| 61 | − | n: u32, | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | impl Work { | |
| 65 | − | /// `workspace_agent_comment`. | |
| 66 | − | pub(crate) async fn workspace_agent_comment(&self, a: WorkspaceAgentCommentArgs) -> Result<Outcome<Comment>> { | |
| 67 | − | self.agent_writes(a.repo, a.number, a.agent, a.acting_for, a.body, None).await | |
| 68 | − | } | |
| 69 | − | ||
| 70 | − | /// `workspace_agent_review`. | |
| 71 | − | pub(crate) async fn workspace_agent_review(&self, a: WorkspaceAgentReviewArgs) -> Result<Outcome<Comment>> { | |
| 72 | − | self.agent_writes(a.repo, a.number, a.agent, a.acting_for, a.body, Some(a.verdict)).await | |
| 73 | − | } | |
| 74 | − | ||
| 75 | − | /// A comment, or with `verdict` a review, by `agent` as itself on | |
| 76 | − | /// behalf of `acting_for`, with the checks a person's comment has. | |
| 77 | − | async fn agent_writes( | |
| 78 | − | &self, | |
| 79 | − | path: g1t_contracts::repos::RepoPath, | |
| 80 | − | number: u32, | |
| 81 | − | agent: AgentRef, | |
| 82 | − | acting_for: User, | |
| 83 | − | body: String, | |
| 84 | − | verdict: Option<AgentVerdict>, | |
| 85 | − | ) -> Result<Outcome<Comment>> { | |
| 86 | − | let body = body.trim(); | |
| 87 | − | if let Some(refused) = refusal(&agent, body, verdict) { | |
| 88 | − | return Ok(Outcome::fail(FailureCode::Invalid, refused)); | |
| 89 | − | } | |
| 90 | − | // The person it acts for, as if they wrote it themselves. | |
| 91 | − | if !acting_for.verified { | |
| 92 | − | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 93 | − | } | |
| 94 | − | let repo = match self.repo(&path, &Some(acting_for.clone())).await? { | |
| 95 | − | Outcome::Ok(repo) => repo, | |
| 96 | − | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 97 | − | }; | |
| 98 | − | if let Outcome::Fail(failure) = writable(&repo) { | |
| 99 | − | return Ok(Outcome::Fail(failure)); | |
| 100 | − | } | |
| 101 | − | let mut pull_id = None; | |
| 102 | − | let table = if self.issue(&repo.id, number).await?.is_some() { | |
| 103 | − | if verdict.is_some() { | |
| 104 | − | return Ok(Outcome::fail(FailureCode::Invalid, "Only a pull request can be reviewed.")); | |
| 105 | − | } | |
| 106 | − | "issues" | |
| 107 | − | } else if let Some(pull) = self.pull(&repo.id, number).await? { | |
| 108 | − | if verdict.is_some() | |
| 109 | − | && let Some(refused) = review_refusal(pull.status) | |
| 110 | − | { | |
| 111 | − | return Ok(Outcome::fail(FailureCode::Conflict, refused)); | |
| 112 | − | } | |
| 113 | − | // A command to g1t on a dependency update it opened is a | |
| 114 | − | // person's to give, never an agent's. | |
| 115 | − | if pull.author.is_system() && g1t_contracts::updates::update_command(body).is_some() { | |
| 116 | − | return Ok(Outcome::fail( | |
| 117 | − | FailureCode::Forbidden, | |
| 118 | − | "An agent cannot give commands on g1t's dependency updates; ask a person with the Write role.", | |
| 119 | − | )); | |
| 120 | − | } | |
| 121 | − | pull_id = Some(pull.id.clone()); | |
| 122 | − | "pulls" | |
| 123 | − | } else { | |
| 124 | − | return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request has that number.")); | |
| 125 | − | }; | |
| 126 | − | ||
| 127 | − | let now = now_ms(); | |
| 128 | − | let written = self | |
| 129 | − | .db | |
| 130 | − | .prepare( | |
| 131 | − | "SELECT count(*) AS n FROM comments | |
| 132 | − | WHERE agent_id = ? AND repo_id = ? AND number = ? AND created_at >= ?", | |
| 133 | − | ) | |
| 134 | − | .bind(&[ | |
| 135 | − | agent.id.as_str().into(), | |
| 136 | − | repo.id.as_str().into(), | |
| 137 | − | number.into(), | |
| 138 | − | rfc3339(now.saturating_sub(AGENT_COMMENT_WINDOW_MS)).into(), | |
| 139 | − | ])? | |
| 140 | − | .first::<Count>(None) | |
| 141 | − | .await? | |
| 142 | − | .map_or(0, |count| count.n); | |
| 143 | − | if !within_limit(written) { | |
| 144 | − | return Ok(Outcome::fail( | |
| 145 | − | FailureCode::Conflict, | |
| 146 | − | format!( | |
| 147 | − | "@{} has written {AGENT_COMMENTS_PER_WINDOW} times on #{number} in the last hour; it can write again later.", | |
| 148 | − | agent.handle | |
| 149 | − | ), | |
| 150 | − | )); | |
| 151 | − | } | |
| 152 | − | ||
| 153 | − | let comment = Comment { | |
| 154 | − | id: new_id("cmt", now), | |
| 155 | − | kind: CommentKind::Comment, | |
| 156 | − | author: User { | |
| 157 | − | id: agent.id.clone(), | |
| 158 | − | username: agent.handle.clone(), | |
| 159 | − | kind: PrincipalKind::Agent, | |
| 160 | − | ..User::default() | |
| 161 | − | }, | |
| 162 | − | body: body.to_owned(), | |
| 163 | − | path: None, | |
| 164 | − | line: None, | |
| 165 | − | verdict: verdict.and_then(AgentVerdict::verdict), | |
| 166 | − | created_at: rfc3339(now), | |
| 167 | − | edited_at: None, | |
| 168 | − | acting_for: Some(User { | |
| 169 | − | id: acting_for.id.clone(), | |
| 170 | − | username: acting_for.username.clone(), | |
| 171 | − | ..User::default() | |
| 172 | − | }), | |
| 173 | − | advisory: verdict.is_some(), | |
| 174 | − | agent: Some(agent.clone()), | |
| 175 | − | }; | |
| 176 | − | self.db | |
| 177 | − | .batch(vec![ | |
| 178 | − | self.db | |
| 179 | − | .prepare( | |
| 180 | − | "INSERT INTO comments | |
| 181 | − | (id, repo_id, number, author_id, author_name, body, created_at, | |
| 182 | − | agent_id, agent_handle, agent_name, agent_avatar_seed, | |
| 183 | − | acting_for_id, acting_for_name, agent_verdict) | |
| 184 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 185 | − | ) | |
| 186 | − | .bind(&[ | |
| 187 | − | comment.id.as_str().into(), | |
| 188 | − | repo.id.as_str().into(), | |
| 189 | − | number.into(), | |
| 190 | − | agent.id.as_str().into(), | |
| 191 | − | agent.handle.as_str().into(), | |
| 192 | − | body.into(), | |
| 193 | − | comment.created_at.as_str().into(), | |
| 194 | − | agent.id.as_str().into(), | |
| 195 | − | agent.handle.as_str().into(), | |
| 196 | − | agent.display_name.as_str().into(), | |
| 197 | − | agent.avatar_seed.as_str().into(), | |
| 198 | − | acting_for.id.as_str().into(), | |
| 199 | − | acting_for.username.as_str().into(), | |
| 200 | − | verdict.map_or(JsValue::NULL, |verdict| verdict.as_str().into()), | |
| 201 | − | ])?, | |
| 202 | − | self.db | |
| 203 | − | .prepare(format!("UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?")) | |
| 204 | − | .bind(&[comment.created_at.as_str().into(), repo.id.as_str().into(), number.into()])?, | |
| 205 | − | ]) | |
| 206 | − | .await?; | |
| 207 | − | // What an agent says never summons g1t (mentions.rs `may_summon`), | |
| 208 | − | // so no mention is recorded. The event is the person's doing, as | |
| 209 | − | // their own comment's would be, with the agent named on it. | |
| 210 | − | self.publish( | |
| 211 | − | "comment.created", | |
| 212 | − | &repo.id, | |
| 213 | − | &acting_for, | |
| 214 | − | CommentCreated { | |
| 215 | − | comment_id: comment.id.clone(), | |
| 216 | − | repo_id: repo.id.clone(), | |
| 217 | − | number, | |
| 218 | − | pull_id, | |
| 219 | − | verdict: comment.verdict, | |
| 220 | − | agent: Some(agent), | |
| 221 | − | acting_for: Some(Principal::from(&acting_for)), | |
| 222 | − | advisory: comment.advisory, | |
| 223 | − | }, | |
| 224 | − | ) | |
| 225 | − | .await?; | |
| 226 | − | Ok(Outcome::Ok(comment)) | |
| 227 | − | } | |
| 228 | − | } | |
| 229 | − | ||
| 230 | − | #[cfg(test)] | |
| 231 | − | mod tests { | |
| 232 | − | use super::*; | |
| 233 | − | ||
| 234 | − | fn margo() -> AgentRef { | |
| 235 | − | AgentRef { | |
| 236 | − | id: "agt_1".into(), | |
| 237 | − | handle: "margo".into(), | |
| 238 | − | display_name: "Margo".into(), | |
| 239 | − | avatar_seed: "margo".into(), | |
| 240 | − | } | |
| 241 | − | } | |
| 242 | − | ||
| 243 | − | #[test] | |
| 244 | − | fn an_agent_says_something_unless_it_approves() { | |
| 245 | − | assert_eq!(refusal(&margo(), "", None), Some("A comment cannot be empty.")); | |
| 246 | − | assert_eq!(refusal(&margo(), "", Some(AgentVerdict::Comment)), Some("A comment cannot be empty.")); | |
| 247 | − | assert_eq!(refusal(&margo(), "", Some(AgentVerdict::RequestChanges)), Some("A comment cannot be empty.")); | |
| 248 | − | assert_eq!(refusal(&margo(), "", Some(AgentVerdict::Approve)), None); | |
| 249 | − | assert_eq!(refusal(&margo(), "Looks right.", None), None); | |
| 250 | − | let nobody = AgentRef { handle: String::new(), ..margo() }; | |
| 251 | − | assert!(refusal(&nobody, "hi", None).is_some()); | |
| 252 | − | } | |
| 253 | − | ||
| 254 | − | #[test] | |
| 255 | − | fn an_agent_reviews_only_what_is_ready() { | |
| 256 | − | assert_eq!(review_refusal(PullStatus::Open), None); | |
| 257 | − | assert!(review_refusal(PullStatus::Draft).is_some()); | |
| 258 | − | assert!(review_refusal(PullStatus::Merged).is_some()); | |
| 259 | − | assert!(review_refusal(PullStatus::Closed).is_some()); | |
| 260 | − | } | |
| 261 | − | ||
| 262 | − | #[test] | |
| 263 | − | fn five_an_hour_on_one_pull_request() { | |
| 264 | − | assert!(within_limit(0)); | |
| 265 | − | assert!(within_limit(4)); | |
| 266 | − | assert!(!within_limit(5)); | |
| 267 | − | } | |
| 268 | − | ||
| 269 | − | #[test] | |
| 270 | − | fn the_wire_reads_snake_case_and_comments_read_camel_case() { | |
| 271 | − | let asked: WorkspaceAgentReviewArgs = serde_json::from_value(serde_json::json!({ | |
| 272 | − | "repo": { "namespace": "acme", "name": "web" }, | |
| 273 | − | "number": 7, | |
| 274 | − | "agent": { "id": "agt_1", "handle": "margo", "display_name": "Margo", "avatar_seed": "margo" }, | |
| 275 | − | "acting_for": { "id": "usr_1", "username": "ana" }, | |
| 276 | − | "verdict": "request_changes", | |
| 277 | − | "body": "Trim the name." | |
| 278 | − | })) | |
| 279 | − | .unwrap(); | |
| 280 | − | assert_eq!(asked.verdict, AgentVerdict::RequestChanges); | |
| 281 | − | assert_eq!(asked.agent, margo()); | |
| 282 | − | let shown = serde_json::to_value(&asked.agent).unwrap(); | |
| 283 | − | assert_eq!(shown["displayName"], "Margo"); | |
| 284 | − | assert_eq!(shown["avatarSeed"], "margo"); | |
| 285 | − | } | |
| 286 | − | ||
| 287 | − | #[test] | |
| 288 | − | fn an_agent_review_shows_its_verdict_but_is_advisory() { | |
| 289 | − | let row: crate::rows::CommentRow = serde_json::from_value(serde_json::json!({ | |
| 290 | − | "id": "cmt_1", "kind": "comment", "author_id": "agt_1", "author_name": "margo", | |
| 291 | − | "body": "Ship it.", "path": null, "line": null, "verdict": null, "created_at": "2026-10-09T00:00:00Z", | |
| 292 | − | "agent_id": "agt_1", "agent_handle": "margo", "agent_name": "Margo", "agent_avatar_seed": "m", | |
| 293 | − | "acting_for_id": "usr_1", "acting_for_name": "ana", "agent_verdict": "approve" | |
| 294 | − | })) | |
| 295 | − | .unwrap(); | |
| 296 | − | assert_eq!(row.answerable_id(), "usr_1"); | |
| 297 | − | assert!(row.has_verdict()); | |
| 298 | − | let comment = Comment::from(row); | |
| 299 | − | assert_eq!(comment.verdict, Some(Verdict::Approve)); | |
| 300 | − | assert!(comment.advisory); | |
| 301 | − | assert_eq!(comment.author.kind, PrincipalKind::Agent); | |
| 302 | − | assert_eq!(comment.answerable().username, "ana"); | |
| 303 | − | assert_eq!(comment.agent.unwrap().display_name, "Margo"); | |
| 304 | − | } | |
| 305 | − | } |
A large diff (1,041 lines) is not shown until you ask for it.
| 1 | − | //! What the runner needs to apply a workspace's plan caps (see | |
| 2 | − | //! `@g1t/contracts` `compute.ts`): how many agents it has at work, what an | |
| 3 | − | //! issue's agents have spent, a comment from g1t when it cannot | |
| 4 | − | //! start, and somewhere for runs to wait for a free slot. | |
| 5 | − | //! | |
| 6 | − | //! The decisions are the runner's; this keeps the counts and the queue. | |
| 7 | − | ||
| 8 | − | use g1t_contracts::agents::*; | |
| 9 | − | use g1t_contracts::events::CommentCreated; | |
| 10 | − | use g1t_contracts::repos::RepoPath; | |
| 11 | − | use g1t_contracts::time::rfc3339; | |
| 12 | − | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 13 | − | use g1t_kit::now_ms; | |
| 14 | − | use serde::Deserialize; | |
| 15 | − | use worker::Result; | |
| 16 | − | use worker::wasm_bindgen::JsValue; | |
| 17 | − | ||
| 18 | − | use crate::Work; | |
| 19 | − | use crate::checks::hash; | |
| 20 | − | use crate::reviews::{AGENT_ID, AGENT_NAME}; | |
| 21 | − | use crate::rows::{NumberRow, ValueRow}; | |
| 22 | − | use crate::runs::member_of; | |
| 23 | − | ||
| 24 | − | /// The longest comment g1t leaves for a run it could not start. | |
| 25 | − | const MAX_COMMENT_CHARS: usize = 2000; | |
| 26 | − | /// The most runs one workspace may have waiting at once. | |
| 27 | − | const MAX_WAITING: u32 = 50; | |
| 28 | − | ||
| 29 | − | #[derive(Deserialize)] | |
| 30 | − | struct SpendRow { | |
| 31 | − | spent: Option<f64>, | |
| 32 | − | } | |
| 33 | − | ||
| 34 | − | #[derive(Deserialize)] | |
| 35 | − | struct PullKey { | |
| 36 | − | id: String, | |
| 37 | − | issue_id: Option<String>, | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | #[derive(Deserialize)] | |
| 41 | − | struct WaitRow { | |
| 42 | − | id: String, | |
| 43 | − | workspace: String, | |
| 44 | − | kind: String, | |
| 45 | − | payload: String, | |
| 46 | − | created_at: String, | |
| 47 | − | } | |
| 48 | − | ||
| 49 | − | #[derive(Deserialize)] | |
| 50 | − | struct CostRow { | |
| 51 | − | cost_usd: Option<f64>, | |
| 52 | − | token_hash: String, | |
| 53 | − | } | |
| 54 | − | ||
| 55 | − | /// The kinds that count against the agents-at-once cap, as SQL. | |
| 56 | − | fn agent_kinds_sql() -> String { | |
| 57 | − | AGENT_KINDS | |
| 58 | − | .iter() | |
| 59 | − | .map(|kind| format!("'{}'", kind.as_str())) | |
| 60 | − | .collect::<Vec<_>>() | |
| 61 | − | .join(", ") | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | /// Dollars as millionths, rounded up. | |
| 65 | − | pub(crate) fn to_micros(usd: f64) -> i64 { | |
| 66 | − | if usd.is_finite() && usd > 0.0 { | |
| 67 | − | (usd * 1_000_000.0).ceil() as i64 | |
| 68 | − | } else { | |
| 69 | − | 0 | |
| 70 | − | } | |
| 71 | − | } | |
| 72 | − | ||
| 73 | − | impl Work { | |
| 74 | − | async fn repo_id_of(&self, path: &RepoPath) -> Result<Outcome<String>> { | |
| 75 | − | let service = User { | |
| 76 | − | id: "svc_runner".to_owned(), | |
| 77 | − | username: "g1t".to_owned(), | |
| 78 | − | ..User::default() | |
| 79 | − | }; | |
| 80 | − | Ok(match self.repo(path, &member_of(&service, &path.namespace)).await? { | |
| 81 | − | Outcome::Ok(repo) => Outcome::Ok(repo.id), | |
| 82 | − | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 83 | − | }) | |
| 84 | − | } | |
| 85 | − | ||
| 86 | − | pub(crate) async fn active_agents(&self, a: ActiveAgentsArgs) -> Result<u32> { | |
| 87 | − | // A run whose sandbox died unnoticed does not hold a slot forever. | |
| 88 | − | self.sweep_silent().await?; | |
| 89 | − | let count = self | |
| 90 | − | .db | |
| 91 | − | .prepare(format!( | |
| 92 | − | "SELECT count(*) AS n FROM agent_runs | |
| 93 | − | WHERE workspace = ? AND status IN ('queued', 'running') AND kind IN ({})", | |
| 94 | − | agent_kinds_sql() | |
| 95 | − | )) | |
| 96 | − | .bind(&[a.workspace.to_lowercase().into()])? | |
| 97 | − | .first::<NumberRow>(None) | |
| 98 | − | .await?; | |
| 99 | − | Ok(count.map_or(0, |row| row.n)) | |
| 100 | − | } | |
| 101 | − | ||
| 102 | − | pub(crate) async fn issue_spend(&self, a: IssueSpendArgs) -> Result<Outcome<IssueSpend>> { | |
| 103 | − | let repo_id = match self.repo_id_of(&a.repo).await? { | |
| 104 | − | Outcome::Ok(id) => id, | |
| 105 | − | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 106 | − | }; | |
| 107 | − | let pull = self | |
| 108 | − | .db | |
| 109 | − | .prepare("SELECT id, issue_id FROM pulls WHERE repo_id = ? AND number = ?") | |
| 110 | − | .bind(&[repo_id.as_str().into(), a.number.into()])? | |
| 111 | − | .first::<PullKey>(None) | |
| 112 | − | .await?; | |
| 113 | − | // The issue a pull request is for, or the pull request itself. | |
| 114 | − | let issue_id = match &pull { | |
| 115 | − | Some(pull) => pull.issue_id.clone(), | |
| 116 | − | None => self | |
| 117 | − | .db | |
| 118 | − | .prepare("SELECT id AS value FROM issues WHERE repo_id = ? AND number = ?") | |
| 119 | − | .bind(&[repo_id.as_str().into(), a.number.into()])? | |
| 120 | − | .first::<ValueRow>(None) | |
| 121 | − | .await? | |
| 122 | − | .map(|row| row.value), | |
| 123 | − | }; | |
| 124 | − | let (issue_number, spent) = match (&issue_id, &pull) { | |
| 125 | − | (Some(issue_id), _) => { | |
| 126 | − | let number = self | |
| 127 | − | .db | |
| 128 | − | .prepare("SELECT number AS n FROM issues WHERE id = ?") | |
| 129 | − | .bind(&[issue_id.as_str().into()])? | |
| 130 | − | .first::<NumberRow>(None) | |
| 131 | − | .await? | |
| 132 | − | .map_or(a.number, |row| row.n); | |
| 133 | − | let spent = self | |
| 134 | − | .db | |
| 135 | − | .prepare( | |
| 136 | − | "SELECT sum(cost_usd) AS spent FROM agent_runs | |
| 137 | − | WHERE pull_id IN (SELECT id FROM pulls WHERE issue_id = ?1) | |
| 138 | − | OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)", | |
| 139 | − | ) | |
| 140 | − | .bind(&[issue_id.as_str().into(), repo_id.as_str().into(), number.into()])? | |
| 141 | − | .first::<SpendRow>(None) | |
| 142 | − | .await?; | |
| 143 | − | (number, spent) | |
| 144 | − | } | |
| 145 | − | (None, Some(pull)) => { | |
| 146 | − | let spent = self | |
| 147 | − | .db | |
| 148 | − | .prepare( | |
| 149 | − | "SELECT sum(cost_usd) AS spent FROM agent_runs | |
| 150 | − | WHERE pull_id = ?1 OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)", | |
| 151 | − | ) | |
| 152 | − | .bind(&[pull.id.as_str().into(), repo_id.as_str().into(), a.number.into()])? | |
| 153 | − | .first::<SpendRow>(None) | |
| 154 | − | .await?; | |
| 155 | − | (a.number, spent) | |
| 156 | − | } | |
| 157 | − | (None, None) => { | |
| 158 | − | return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request by that number.")); | |
| 159 | − | } | |
| 160 | − | }; | |
| 161 | − | Ok(Outcome::Ok(IssueSpend { | |
| 162 | − | issue: issue_number, | |
| 163 | − | spent_micros: to_micros(spent.and_then(|row| row.spent).unwrap_or(0.0)), | |
| 164 | − | })) | |
| 165 | − | } | |
| 166 | − | ||
| 167 | − | pub(crate) async fn wait_for_slot(&self, a: WaitForSlotArgs) -> Result<bool> { | |
| 168 | − | // The claim is given back as if it had never been taken, so the | |
| 169 | − | // revision it counted does not count. | |
| 170 | − | let released = self | |
| 171 | − | .db | |
| 172 | − | .prepare( | |
| 173 | − | "UPDATE pulls SET | |
| 174 | − | revisions = CASE WHEN working_on = 'revision' AND revisions > 0 THEN revisions - 1 ELSE revisions END, | |
| 175 | − | working_on = NULL, working_until = NULL, stage_detail = ? | |
| 176 | − | WHERE id = ? AND status = 'open' | |
| 177 | − | RETURNING id AS value", | |
| 178 | − | ) | |
| 179 | − | .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])? | |
| 180 | − | .first::<ValueRow>(None) | |
| 181 | − | .await?; | |
| 182 | − | Ok(released.is_some()) | |
| 183 | − | } | |
| 184 | − | ||
| 185 | − | pub(crate) async fn agent_comment(&self, a: AgentCommentArgs) -> Result<bool> { | |
| 186 | − | let body: String = a.body.trim().chars().take(MAX_COMMENT_CHARS).collect(); | |
| 187 | − | if body.is_empty() { | |
| 188 | − | return Ok(false); | |
| 189 | − | } | |
| 190 | − | let repo_id = match self.repo_id_of(&a.repo).await? { | |
| 191 | − | Outcome::Ok(id) => id, | |
| 192 | − | Outcome::Fail(_) => return Ok(false), | |
| 193 | − | }; | |
| 194 | − | let pull_id = self | |
| 195 | − | .db | |
| 196 | − | .prepare("SELECT id AS value FROM pulls WHERE repo_id = ? AND number = ?") | |
| 197 | − | .bind(&[repo_id.as_str().into(), a.number.into()])? | |
| 198 | − | .first::<ValueRow>(None) | |
| 199 | − | .await? | |
| 200 | − | .map(|row| row.value); | |
| 201 | − | let table = if pull_id.is_some() { "pulls" } else { "issues" }; | |
| 202 | − | let now = now_ms(); | |
| 203 | − | let id = new_id("cmt", now); | |
| 204 | − | let at = rfc3339(now); | |
| 205 | − | self.db | |
| 206 | − | .batch(vec![ | |
| 207 | − | self.db | |
| 208 | − | .prepare( | |
| 209 | − | "INSERT INTO comments (id, repo_id, number, author_id, author_name, body, created_at) | |
| 210 | − | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 211 | − | ) | |
| 212 | − | .bind(&[ | |
| 213 | − | id.as_str().into(), | |
| 214 | − | repo_id.as_str().into(), | |
| 215 | − | a.number.into(), | |
| 216 | − | AGENT_ID.into(), | |
| 217 | − | AGENT_NAME.into(), | |
| 218 | − | body.as_str().into(), | |
| 219 | − | at.as_str().into(), | |
| 220 | − | ])?, | |
| 221 | − | self.db | |
| 222 | − | .prepare(format!("UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?")) | |
| 223 | − | .bind(&[at.as_str().into(), repo_id.as_str().into(), a.number.into()])?, | |
| 224 | − | ]) | |
| 225 | − | .await?; | |
| 226 | − | self.publish_as( | |
| 227 | − | "comment.created", | |
| 228 | − | &repo_id, | |
| 229 | − | Some(AGENT_ID.to_owned()), | |
| 230 | − | CommentCreated { | |
| 231 | − | comment_id: id, | |
| 232 | − | repo_id: repo_id.clone(), | |
| 233 | − | number: a.number, | |
| 234 | − | pull_id, | |
| 235 | − | verdict: None, | |
| 236 | − | ..CommentCreated::default() | |
| 237 | − | }, | |
| 238 | − | ) | |
| 239 | − | .await?; | |
| 240 | − | Ok(true) | |
| 241 | − | } | |
| 242 | − | ||
| 243 | − | pub(crate) async fn add_wait(&self, a: AddWaitArgs) -> Result<Outcome<bool>> { | |
| 244 | − | let workspace = a.workspace.to_lowercase(); | |
| 245 | − | let waiting = self | |
| 246 | − | .db | |
| 247 | − | .prepare("SELECT count(*) AS n FROM agent_waits WHERE workspace = ?") | |
| 248 | − | .bind(&[workspace.as_str().into()])? | |
| 249 | − | .first::<NumberRow>(None) | |
| 250 | − | .await? | |
| 251 | − | .map_or(0, |row| row.n); | |
| 252 | − | if waiting >= MAX_WAITING { | |
| 253 | − | return Ok(Outcome::fail( | |
| 254 | − | FailureCode::Conflict, | |
| 255 | − | format!("{MAX_WAITING} runs are already waiting for this workspace's agents. Try again when some have started."), | |
| 256 | − | )); | |
| 257 | − | } | |
| 258 | − | let now = now_ms(); | |
| 259 | − | self.db | |
| 260 | − | .prepare("INSERT INTO agent_waits (id, workspace, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)") | |
| 261 | − | .bind(&[ | |
| 262 | − | new_id("wait", now).into(), | |
| 263 | − | workspace.into(), | |
| 264 | − | a.kind.into(), | |
| 265 | − | serde_json::to_string(&a.payload)?.into(), | |
| 266 | − | rfc3339(now).into(), | |
| 267 | − | ])? | |
| 268 | − | .run() | |
| 269 | − | .await?; | |
| 270 | − | Ok(Outcome::Ok(true)) | |
| 271 | − | } | |
| 272 | − | ||
| 273 | − | pub(crate) async fn waiting_workspaces(&self, _a: WaitingWorkspacesArgs) -> Result<Vec<String>> { | |
| 274 | − | let rows = self | |
| 275 | − | .db | |
| 276 | − | .prepare( | |
| 277 | − | "SELECT workspace AS value FROM agent_waits | |
| 278 | − | GROUP BY workspace ORDER BY min(created_at) LIMIT 100", | |
| 279 | − | ) | |
| 280 | − | .all() | |
| 281 | − | .await? | |
| 282 | − | .results::<ValueRow>()?; | |
| 283 | − | Ok(rows.into_iter().map(|row| row.value).collect()) | |
| 284 | − | } | |
| 285 | − | ||
| 286 | − | pub(crate) async fn take_wait(&self, a: TakeWaitArgs) -> Result<Option<AgentWait>> { | |
| 287 | − | let row = self | |
| 288 | − | .db | |
| 289 | − | .prepare( | |
| 290 | − | "DELETE FROM agent_waits WHERE id = ( | |
| 291 | − | SELECT id FROM agent_waits WHERE workspace = ? ORDER BY created_at, id LIMIT 1) | |
| 292 | − | RETURNING id, workspace, kind, payload, created_at", | |
| 293 | − | ) | |
| 294 | − | .bind(&[a.workspace.to_lowercase().into()])? | |
| 295 | − | .first::<WaitRow>(None) | |
| 296 | − | .await?; | |
| 297 | − | Ok(row.map(|row| AgentWait { | |
| 298 | − | id: row.id, | |
| 299 | − | workspace: row.workspace, | |
| 300 | − | kind: row.kind, | |
| 301 | − | payload: serde_json::from_str(&row.payload).unwrap_or(serde_json::Value::Null), | |
| 302 | − | created_at: row.created_at, | |
| 303 | − | })) | |
| 304 | − | } | |
| 305 | − | ||
| 306 | − | pub(crate) async fn run_cost(&self, a: RunCostArgs) -> Result<Option<f64>> { | |
| 307 | − | let row = self | |
| 308 | − | .db | |
| 309 | − | .prepare("SELECT cost_usd, token_hash FROM agent_runs WHERE id = ?") | |
| 310 | − | .bind(&[JsValue::from(a.run_id.as_str())])? | |
| 311 | − | .first::<CostRow>(None) | |
| 312 | − | .await?; | |
| 313 | − | Ok(row | |
| 314 | − | .filter(|row| row.token_hash == hash(&a.token)) | |
| 315 | − | .and_then(|row| row.cost_usd)) | |
| 316 | − | } | |
| 317 | − | } | |
| 318 | − | ||
| 319 | − | #[cfg(test)] | |
| 320 | − | mod tests { | |
| 321 | − | use super::*; | |
| 322 | − | ||
| 323 | − | #[test] | |
| 324 | − | fn dollars_become_micros_rounded_up() { | |
| 325 | − | assert_eq!(to_micros(0.094), 94_000); | |
| 326 | − | assert_eq!(to_micros(0.0000001), 1); | |
| 327 | − | assert_eq!(to_micros(-1.0), 0); | |
| 328 | − | assert_eq!(to_micros(f64::NAN), 0); | |
| 329 | − | } | |
| 330 | − | ||
| 331 | − | #[test] | |
| 332 | − | fn only_agent_kinds_hold_a_slot() { | |
| 333 | − | let sql = agent_kinds_sql(); | |
| 334 | − | assert!(sql.contains("'implement'") && sql.contains("'plan'")); | |
| 335 | − | assert!(!sql.contains("'checks'") && !sql.contains("'queue'") && !sql.contains("'mergecheck'")); | |
| 336 | − | } | |
| 337 | − | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
A large diff (1,632 lines) is not shown until you ask for it.
Loading the diff…
A large diff (1,006 lines) is not shown until you ask for it.
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…