Merge main into mirroring: remotes migration becomes integrations 0007
main took integrations 0006 (session cap). The queue keeps main's one push per repository per batch, now to a repository's followers (remotes.rs); git.push carries causedByJob and the mirror fields.
| 7 | 7 | # migrate pending D1 migrations, before any code | |
| 8 | 8 | # core, edge, front the units of each stage, in jobs that share a build; | |
| 9 | 9 | # a stage starts only when the one before it succeeded | |
| 10 | + | # smoke sign-in, sign-up and the waitlist still work on g1t.sh | |
| 10 | 11 | # | |
| 11 | 12 | # Each run that deploys is one production deployment of g1t.sh, made by the | |
| 12 | 13 | # jobs that name `environment: production` (one per run, however many jobs): | |
| ⋯ | |||
| 252 | 253 | max-parallel: 4 | |
| 253 | 254 | matrix: ${{ fromJSON(needs.plan.outputs.front) }} | |
| 254 | 255 | steps: *deploy | |
| 256 | + | ||
| 257 | + | # Once everything has deployed: the ways in for someone new still work. | |
| 258 | + | # The pages a visitor lands on load, and the waitlist form reaches | |
| 259 | + | # identity, sent an address it refuses before keeping anything, so the | |
| 260 | + | # real waitlist is never touched. scripts/ops/smoke.mjs. | |
| 261 | + | smoke: | |
| 262 | + | name: Smoke | |
| 263 | + | needs: [plan, core, edge, front] | |
| 264 | + | if: ${{ !failure() && !cancelled() && inputs.dry_run != true && (needs.plan.outputs.has_core == 'true' || needs.plan.outputs.has_edge == 'true' || needs.plan.outputs.has_front == 'true') }} | |
| 265 | + | runs-on: ubuntu-latest | |
| 266 | + | timeout-minutes: 5 | |
| 267 | + | steps: | |
| 268 | + | - uses: actions/checkout@v5 | |
| 269 | + | - name: Sign-in, sign-up and the waitlist work | |
| 270 | + | run: node scripts/ops/smoke.mjs | |
| 126 | 126 | ||
| 127 | 127 | ## Layout | |
| 128 | 128 | ||
| 129 | − | | Path | What it is | | |
| 130 | − | | --- | --- | | |
| 131 | − | | `apps/web` | The site: server-rendered React on a Worker. Holds no data. | | |
| 132 | − | | `apps/docs` | The documentation site, with the API explorer. | | |
| 133 | − | | `apps/api` | REST API and MCP server. Rust. | | |
| 134 | − | | `services/identity` | Accounts, workspaces, sessions, keys and tokens. Rust. | | |
| 135 | − | | `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. Rust. | | |
| 136 | − | | `services/work` | Issues, pull requests, reviews, check runs and sessions. Rust. | | |
| 137 | − | | `services/events` | The event bus and its log. Rust. | | |
| 138 | − | | `services/search` | Site-wide search and Explore. Rust. | | |
| 139 | − | | `services/billing` | Usage, the price book, limits, invoices and payments. Rust. | | |
| 140 | − | | `services/actions` | GitHub Actions workflows, runs, caches and self-hosted runners. Rust. | | |
| 141 | − | | `services/security` | Push protection findings, history scanning and dependency upkeep. Rust. | | |
| 142 | − | | `services/integrations` | Model providers, alerts, trackers and the GitHub App. Rust. | | |
| 143 | − | | `services/webhooks` | Webhook deliveries. Rust. | | |
| 144 | − | | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. TypeScript. | | |
| 145 | − | | `services/projects` | Projects and the dependencies between them. TypeScript. | | |
| 146 | − | | `services/deployments` | Builds, previews and production on `g1t.page`. TypeScript. | | |
| 147 | − | | `services/pages` | Serves every app deployed on `g1t.page`, and custom domains. TypeScript. | | |
| 148 | − | | `services/models` | The model proxy at `models.g1t.sh`. TypeScript. | | |
| 149 | − | | `services/context` | The context hub: catalog, search and scorecards. TypeScript. | | |
| 150 | − | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. TypeScript. | | |
| 151 | − | | `apps/status` | `status.g1t.sh`. TypeScript. | | |
| 152 | − | | `apps/sudo` | g1t's own staff console. | | |
| 153 | − | | `crates/runner` | The program inside a sandbox: runs an agent, a workflow job or a merge queue build, and reports back. Rust. | | |
| 154 | − | | `crates/contracts` | Types and service interfaces for the Rust services. | | |
| 155 | − | | `crates/kit` | Plumbing shared by Rust services on Workers. | | |
| 156 | − | | `crates/actions` | Reads workflows and evaluates their expressions. Rust. | | |
| 157 | − | | `crates/scan` | Secret and lockfile scanning, shared by services. Rust. | | |
| 158 | − | | `crates/secrets` | Secrets at rest and signatures. Rust. | | |
| 159 | − | | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. | | |
| 160 | − | | `packages/contracts` | The same interfaces for TypeScript callers. | | |
| 161 | − | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | | |
| 162 | − | | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | | |
| 129 | + | | Path | What it is | Language | | |
| 130 | + | | --- | --- | --- | | |
| 131 | + | | `apps/web` | The site: server-rendered React on a Worker. Holds no data. | TypeScript | | |
| 132 | + | | `apps/docs` | The documentation site, with the API explorer. | TypeScript | | |
| 133 | + | | `apps/api` | REST API and MCP server. | Rust | | |
| 134 | + | | `services/identity` | Accounts, workspaces, sessions, keys and tokens. | Rust | | |
| 135 | + | | `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. | Rust | | |
| 136 | + | | `services/work` | Issues, pull requests, reviews, check runs and sessions. | Rust | | |
| 137 | + | | `services/events` | The event bus and its log. | Rust | | |
| 138 | + | | `services/search` | Site-wide search and Explore. | Rust | | |
| 139 | + | | `services/billing` | Usage, the price book, limits, invoices and payments. | Rust | | |
| 140 | + | | `services/actions` | GitHub Actions workflows, runs, caches and self-hosted runners. | Rust | | |
| 141 | + | | `services/security` | Push protection findings, history scanning and dependency upkeep. | Rust | | |
| 142 | + | | `services/integrations` | Model providers, alerts, trackers and the GitHub App. | Rust | | |
| 143 | + | | `services/webhooks` | Webhook deliveries. | Rust | | |
| 144 | + | | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. | TypeScript | | |
| 145 | + | | `services/projects` | Projects and the dependencies between them. | TypeScript | | |
| 146 | + | | `services/deployments` | Builds, previews and production on `g1t.page`. | TypeScript | | |
| 147 | + | | `services/pages` | Serves every app deployed on `g1t.page`, and custom domains. | TypeScript | | |
| 148 | + | | `services/models` | The model proxy at `models.g1t.sh`. | TypeScript | | |
| 149 | + | | `services/context` | The context hub: catalog, search and scorecards. | TypeScript | | |
| 150 | + | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. | TypeScript | | |
| 151 | + | | `apps/status` | The status page at `status.g1t.sh`. | TypeScript | | |
| 152 | + | | `apps/sudo` | g1t's own staff console. | TypeScript | | |
| 153 | + | | `crates/runner` | The program inside a sandbox: runs an agent, a workflow job or a merge queue build, and reports back. | Rust | | |
| 154 | + | | `crates/contracts` | Types and service interfaces for the Rust services. | Rust | | |
| 155 | + | | `crates/kit` | Plumbing shared by Rust services on Workers. | Rust | | |
| 156 | + | | `crates/actions` | Reads workflows and evaluates their expressions. | Rust | | |
| 157 | + | | `crates/scan` | Secret and lockfile scanning, shared by services. | Rust | | |
| 158 | + | | `crates/secrets` | Secrets at rest and signatures. | Rust | | |
| 159 | + | | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. | Rust | | |
| 160 | + | | `packages/contracts` | The same interfaces for TypeScript callers. | TypeScript | | |
| 161 | + | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | CSS | | |
| 162 | + | | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | JSON, Docker Compose | | |
| 163 | 163 | ||
| 164 | 164 | Each service is its own Worker, and each one that keeps data has its own | |
| 165 | 165 | database. They call each other through service bindings and react to each | |
| 166 | 166 | other through events. The core services (accounts, repositories, work, | |
| 167 | 167 | events, billing, Actions, security and the API) are written in Rust; the | |
| 168 | − | rest are the web apps and the Workers marked TypeScript above. | |
| 168 | + | web apps and the rest of the Workers in TypeScript. The Language column | |
| 169 | + | says which, part by part. | |
| 169 | 170 | ||
| 170 | 171 | ## Run your own | |
| 171 | 172 |
| 56 | 56 | name: String, | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | /// Artifacts older runners kept in KV expire 14 days after they were made, | |
| 60 | + | /// and none has been made there since artifacts moved to R2 on 2026-10-08: | |
| 61 | + | /// from 2026-10-22T00:00Z every one is gone, and KV is not asked (a list is | |
| 62 | + | /// the dearest thing KV does). Delete the KV artifact code after that date, | |
| 63 | + | /// with its twin in apps/web/app/lib/artifacts.server.ts. | |
| 64 | + | const LEGACY_KV_UNTIL_MS: u64 = 1_792_627_200_000; | |
| 65 | + | ||
| 66 | + | /// Whether artifacts kept in KV may still be there at `now`. | |
| 67 | + | fn legacy_kv(now: u64) -> bool { | |
| 68 | + | now < LEGACY_KV_UNTIL_MS | |
| 69 | + | } | |
| 70 | + | ||
| 59 | 71 | fn store(env: &Env) -> Result<KvStore> { | |
| 60 | 72 | env.kv("BLOBS") | |
| 61 | 73 | } | |
| ⋯ | |||
| 404 | 416 | // Artifacts older runners kept in KV, for the days they | |
| 405 | 417 | // are still there. | |
| 406 | 418 | let legacy_run = run_id.as_deref().unwrap_or(run); | |
| 407 | − | for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? { | |
| 408 | − | if !listed.iter().any(|a| a["name"] == meta.name.as_str()) { | |
| 409 | − | listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" })); | |
| 419 | + | if legacy_kv(g1t_kit::now_ms()) { | |
| 420 | + | for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? { | |
| 421 | + | if !listed.iter().any(|a| a["name"] == meta.name.as_str()) { | |
| 422 | + | listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" })); | |
| 423 | + | } | |
| 410 | 424 | } | |
| 411 | 425 | } | |
| 412 | 426 | crate::reply(&listed) | |
| ⋯ | |||
| 547 | 561 | match found { | |
| 548 | 562 | Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await, | |
| 549 | 563 | // One an older runner kept in KV. | |
| 550 | − | Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? { | |
| 564 | + | Outcome::Fail(_) if legacy_kv(g1t_kit::now_ms()) => match get(kv, &format!("a/{run}/{name}")).await? { | |
| 551 | 565 | Some(bytes) => Response::from_bytes(bytes), | |
| 552 | 566 | None => error(404, "No such artifact."), | |
| 553 | 567 | }, | |
| 568 | + | Outcome::Fail(_) => error(404, "No such artifact."), | |
| 554 | 569 | } | |
| 555 | 570 | } | |
| 556 | 571 | _ => error(404, "No such endpoint."), | |
| ⋯ | |||
| 612 | 627 | return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302); | |
| 613 | 628 | } | |
| 614 | 629 | // Kept in KV by an older runner. | |
| 630 | + | if !legacy_kv(g1t_kit::now_ms()) { | |
| 631 | + | return error(404, "No such artifact, or it has expired."); | |
| 632 | + | } | |
| 615 | 633 | match get(&store(env)?, &format!("a/{run}/{name}")).await? { | |
| 616 | 634 | Some(bytes) => { | |
| 617 | 635 | let mut response = Response::from_bytes(bytes)?; | |
| ⋯ | |||
| 624 | 642 | } | |
| 625 | 643 | } | |
| 626 | 644 | ||
| 645 | + | #[cfg(test)] | |
| 646 | + | mod tests { | |
| 647 | + | use super::*; | |
| 648 | + | ||
| 649 | + | #[test] | |
| 650 | + | fn kv_artifacts_are_not_asked_for_after_they_have_all_expired() { | |
| 651 | + | assert_eq!(g1t_contracts::time::rfc3339(LEGACY_KV_UNTIL_MS), "2026-10-22T00:00:00.000Z"); | |
| 652 | + | assert!(legacy_kv(LEGACY_KV_UNTIL_MS - 1)); | |
| 653 | + | assert!(!legacy_kv(LEGACY_KV_UNTIL_MS)); | |
| 654 | + | } | |
| 655 | + | } | |
| 14 | 14 | mod checks; | |
| 15 | 15 | mod deployments; | |
| 16 | 16 | mod deploy_keys; | |
| 17 | + | mod limits; | |
| 17 | 18 | mod logs; | |
| 18 | 19 | mod mirrors; | |
| 19 | 20 | mod mcp; | |
| ⋯ | |||
| 622 | 623 | return Ok(response); | |
| 623 | 624 | } | |
| 624 | 625 | ||
| 626 | + | // Per token, or per address without one (limits.rs). | |
| 627 | + | if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp).await? { | |
| 628 | + | return Ok(limited); | |
| 629 | + | } | |
| 625 | 630 | let viewer = match authenticate(&request, &services).await? { | |
| 626 | 631 | Ok(viewer) => viewer, | |
| 627 | − | Err(refused) => return Ok(refused), | |
| 632 | + | Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp).await?.unwrap_or(refused)), | |
| 628 | 633 | }; | |
| 629 | 634 | // A person who has not confirmed their email address: who they are, | |
| 630 | 635 | // their addresses, and confirming one, nothing else (REST or MCP). | |
| ⋯ | |||
| 947 | 952 | "authorization, content-type", | |
| 948 | 953 | )?; | |
| 949 | 954 | headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?; | |
| 950 | − | headers.set("access-control-expose-headers", "www-authenticate")?; | |
| 955 | + | headers.set("access-control-expose-headers", "www-authenticate, retry-after")?; | |
| 951 | 956 | Ok(response) | |
| 952 | 957 | } | |
| 1 | + | //! How often a client may call the API and the MCP server. | |
| 2 | + | //! | |
| 3 | + | //! A request with a token counts against API_TOKEN_LIMIT under a hash of | |
| 4 | + | //! the token (never the token itself); one without counts against | |
| 5 | + | //! API_ANONYMOUS_LIMIT under the client's address (`CF-Connecting-IP`), as | |
| 6 | + | //! does one whose token turns out wrong, so guessing is limited by address. | |
| 7 | + | //! REST and MCP count apart. The limits are in `RATE_LIMITS` | |
| 8 | + | //! (packages/contracts/src/rate-limits.ts) and the docs' rate limits page. | |
| 9 | + | //! | |
| 10 | + | //! Not counted: what sandboxes, runners and outside systems send with | |
| 11 | + | //! credentials of their own (Stripe, connections' hooks, job tokens, | |
| 12 | + | //! report routes), which are answered before this or listed in | |
| 13 | + | //! [`counts`]. Without the bindings (self-hosted) nothing is limited, and | |
| 14 | + | //! a binding that fails lets the request through. | |
| 15 | + | ||
| 16 | + | use g1t_kit::limits::{self, PERIOD_SECONDS}; | |
| 17 | + | use serde_json::json; | |
| 18 | + | use sha2::{Digest, Sha256}; | |
| 19 | + | use worker::{Env, Request, Response, Result}; | |
| 20 | + | ||
| 21 | + | pub const ANONYMOUS: &str = "API_ANONYMOUS_LIMIT"; | |
| 22 | + | pub const TOKEN: &str = "API_TOKEN_LIMIT"; | |
| 23 | + | ||
| 24 | + | /// Paths a sandbox or runner reports to with its own token in the body: | |
| 25 | + | /// many sandboxes share an address, and none of them is a person. | |
| 26 | + | const REPORTS: &[&str] = &[ | |
| 27 | + | "/mergechecks/", | |
| 28 | + | "/backups/", | |
| 29 | + | "/queue/", | |
| 30 | + | "/actions/jobs/", | |
| 31 | + | "/agent-runs/", | |
| 32 | + | "/checks/", | |
| 33 | + | "/runs/", | |
| 34 | + | "/plans/", | |
| 35 | + | "/reviews/", | |
| 36 | + | "/runners/", | |
| 37 | + | ]; | |
| 38 | + | ||
| 39 | + | /// Whether a request counts against a limit at all. | |
| 40 | + | pub fn counts(method: &str, path: &str, has_token: bool) -> bool { | |
| 41 | + | has_token || method != "POST" || !REPORTS.iter().any(|prefix| path.starts_with(prefix)) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// The binding a request counts against and its key there. | |
| 45 | + | pub fn key(token: Option<&str>, address: Option<&str>, on_mcp: bool) -> (&'static str, String) { | |
| 46 | + | let surface = if on_mcp { "mcp" } else { "rest" }; | |
| 47 | + | match token.filter(|token| !token.is_empty()) { | |
| 48 | + | Some(token) => (TOKEN, format!("{surface}:tok:{}", token_hash(token))), | |
| 49 | + | None => (ANONYMOUS, format!("{surface}:{}", limits::address_key(address))), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// The first 16 hex digits of the token's SHA-256. | |
| 54 | + | fn token_hash(token: &str) -> String { | |
| 55 | + | Sha256::digest(token.as_bytes())[..8].iter().map(|byte| format!("{byte:02x}")).collect() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// The bearer token of an `Authorization` header, if it has one. | |
| 59 | + | pub fn bearer(header: &str) -> Option<&str> { | |
| 60 | + | match header.split_once(' ') { | |
| 61 | + | Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => Some(token.trim()).filter(|t| !t.is_empty()), | |
| 62 | + | _ => None, | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// The 429 every limit answers, in the shape every error takes. | |
| 67 | + | pub fn too_many(signed_in: bool) -> Result<Response> { | |
| 68 | + | let message = if signed_in { | |
| 69 | + | "Too many requests with this token. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/" | |
| 70 | + | } else { | |
| 71 | + | "Too many requests from this address. Wait a minute, or use an access token for a higher limit: https://docs.g1t.sh/reference/rate-limits/" | |
| 72 | + | }; | |
| 73 | + | let mut response = Response::from_json(&json!({ "error": { "code": "rate_limited", "message": message } }))?.with_status(429); | |
| 74 | + | response.headers_mut().set("retry-after", &PERIOD_SECONDS.to_string())?; | |
| 75 | + | Ok(response) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// The 429 for a request past its limit, or `None` to go on. | |
| 79 | + | pub async fn limited(request: &Request, env: &Env, method: &str, path: &str, on_mcp: bool) -> Result<Option<Response>> { | |
| 80 | + | let header = request.headers().get("authorization")?.unwrap_or_default(); | |
| 81 | + | let token = bearer(&header); | |
| 82 | + | if !counts(method, path, token.is_some()) { | |
| 83 | + | return Ok(None); | |
| 84 | + | } | |
| 85 | + | let address = request.headers().get("cf-connecting-ip")?; | |
| 86 | + | let (binding, key) = key(token, address.as_deref(), on_mcp); | |
| 87 | + | if limits::check(env, binding, key).await.limited() { | |
| 88 | + | return too_many(token.is_some()).map(Some); | |
| 89 | + | } | |
| 90 | + | Ok(None) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// A request whose token was wrong also counts against its address. | |
| 94 | + | pub async fn wrong_token(request: &Request, env: &Env, on_mcp: bool) -> Result<Option<Response>> { | |
| 95 | + | let address = request.headers().get("cf-connecting-ip")?; | |
| 96 | + | let (binding, key) = key(None, address.as_deref(), on_mcp); | |
| 97 | + | if limits::check(env, binding, key).await.limited() { | |
| 98 | + | return too_many(false).map(Some); | |
| 99 | + | } | |
| 100 | + | Ok(None) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[cfg(test)] | |
| 104 | + | mod tests { | |
| 105 | + | use super::*; | |
| 106 | + | ||
| 107 | + | #[test] | |
| 108 | + | fn tokens_are_counted_by_their_hash_and_others_by_address() { | |
| 109 | + | let (binding, tok) = key(Some("g1t_secret"), Some("203.0.113.9"), false); | |
| 110 | + | assert_eq!(binding, TOKEN); | |
| 111 | + | assert!(tok.starts_with("rest:tok:") && tok.len() == "rest:tok:".len() + 16); | |
| 112 | + | assert!(!tok.contains("g1t_secret"), "the token never reaches the limiter"); | |
| 113 | + | assert_eq!(key(Some("g1t_secret"), None, false).1, tok, "the same token, the same key"); | |
| 114 | + | assert_ne!(key(Some("g1t_other"), None, false).1, tok); | |
| 115 | + | assert_eq!(key(None, Some("203.0.113.9"), false), (ANONYMOUS, "rest:ip:203.0.113.9".to_owned())); | |
| 116 | + | assert_eq!(key(Some(""), None, false), (ANONYMOUS, "rest:ip:unknown".to_owned())); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn rest_and_mcp_count_apart() { | |
| 121 | + | assert_eq!(key(None, Some("203.0.113.9"), true).1, "mcp:ip:203.0.113.9"); | |
| 122 | + | assert!(key(Some("g1t_secret"), None, true).1.starts_with("mcp:tok:")); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn sandbox_reports_are_not_counted_but_everything_else_is() { | |
| 127 | + | assert!(!counts("POST", "/checks/run_1", false)); | |
| 128 | + | assert!(!counts("POST", "/agent-runs/run_1/report", false)); | |
| 129 | + | assert!(!counts("POST", "/runs/run_1/usage", false)); | |
| 130 | + | assert!(counts("GET", "/repos/acme/rocket", false)); | |
| 131 | + | assert!(counts("POST", "/device/code", false)); | |
| 132 | + | assert!(counts("POST", "/checks/run_1", true), "with a bearer token it counts as that token"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[test] | |
| 136 | + | fn only_bearer_tokens_are_read() { | |
| 137 | + | assert_eq!(bearer("Bearer g1t_abc "), Some("g1t_abc")); | |
| 138 | + | assert_eq!(bearer("bearer g1t_abc"), Some("g1t_abc")); | |
| 139 | + | assert_eq!(bearer("Basic dXNlcjpwYXNz"), None); | |
| 140 | + | assert_eq!(bearer("Bearer "), None); | |
| 141 | + | assert_eq!(bearer(""), None); | |
| 142 | + | } | |
| 143 | + | } |
| 42 | 42 | // actions/cache entries (`c/`) and artifacts (`a/`), uploaded in parts. The actions | |
| 43 | 43 | // service lists them and decides what is kept (services/actions/src/cache.rs). | |
| 44 | 44 | "r2_buckets": [{ "binding": "ACTIONS_CACHE", "bucket_name": "g1t-actions-cache" }], | |
| 45 | − | "observability": { "enabled": true } | |
| 45 | + | // REST and MCP requests (src/limits.rs): per token, or per address | |
| 46 | + | // without one. Ids and limits: RATE_LIMITS in packages/contracts. | |
| 47 | + | "ratelimits": [ | |
| 48 | + | { "name": "API_ANONYMOUS_LIMIT", "namespace_id": "4401", "simple": { "limit": 60, "period": 60 } }, | |
| 49 | + | { "name": "API_TOKEN_LIMIT", "namespace_id": "4402", "simple": { "limit": 1000, "period": 60 } } | |
| 50 | + | ], | |
| 51 | + | // Logs of a tenth of requests: agents call it constantly. | |
| 52 | + | "observability": { "enabled": true, "head_sampling_rate": 0.1 } | |
| 46 | 53 | } |
| 160 | 160 | items: [ | |
| 161 | 161 | { label: 'API overview', slug: 'reference/api' }, | |
| 162 | 162 | { label: 'MCP tools', slug: 'reference/mcp' }, | |
| 163 | + | { label: 'Rate limits', slug: 'reference/rate-limits' }, | |
| 163 | 164 | { label: 'Try it in the explorer', link: '/api/reference/', attrs: { target: '_self' } }, | |
| 164 | 165 | { label: 'OpenAPI document', link: 'https://api.g1t.sh/openapi.json' }, | |
| 165 | 166 | { label: 'llms.txt', link: 'https://g1t.sh/llms.txt' }, |
| 98 | 98 | Why each of these is missing, and what to use instead, is on | |
| 99 | 99 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| 100 | 100 | ||
| 101 | + | ## Schedules | |
| 102 | + | ||
| 103 | + | A workflow with `on: schedule` runs on the default branch's latest commit, | |
| 104 | + | at each time its cron lines name, in UTC: | |
| 105 | + | ||
| 106 | + | ```yaml | |
| 107 | + | on: | |
| 108 | + | schedule: | |
| 109 | + | - cron: "0 9 * * mon" # Mondays at 09:00 UTC | |
| 110 | + | - cron: "*/30 * * * *" # every 30 minutes | |
| 111 | + | ``` | |
| 112 | + | ||
| 113 | + | Each line has five fields: minute, hour, day of month, month and day of | |
| 114 | + | the week. A field takes `*`, a number, a range `1-5`, a list `1,15` and a | |
| 115 | + | step `*/10`; days take `mon` to `sun`, and months `jan` to `dec`. | |
| 116 | + | ||
| 117 | + | | Rule | What happens | | |
| 118 | + | | --- | --- | | |
| 119 | + | | Every 5 minutes at most | A schedule more frequent than every 5 minutes, such as `* * * * *` or `*/2 * * * *`, runs every 5 minutes instead, on the five-minute marks (:00, :05, :10 and so on), at each mark that ends five minutes in which it would have run. A mark outside the hours, days or months the schedule names never runs: `* 9 * * *` runs from 09:00 to 09:55. | | |
| 120 | + | | No push for 60 days | Schedules pause in a repository that has had no push for 60 days. The next push to any branch resumes them. Other events and `workflow_dispatch` still start the workflow. | | |
| 121 | + | | Actions not paid for | When a scheduled run's job could not start because the workspace's plan, spend limit or the open-source pool does not cover it, that run fails and says why, and the workflow's schedule waits an hour before it tries again. | | |
| 122 | + | | Archived repository | Schedules wait until the repository is unarchived. | | |
| 123 | + | ||
| 101 | 124 | ## Actions and workflows from other repositories | |
| 102 | 125 | ||
| 103 | 126 | A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's | |
| ⋯ | |||
| 1083 | 1106 | or a comment made with it runs nothing, so a workflow cannot set itself | |
| 1084 | 1107 | off. `workflow_dispatch` and [`repository_dispatch`](#repository-dispatch) | |
| 1085 | 1108 | are the exceptions, for a workflow that means to start another. | |
| 1109 | + | - It **never puts g1t to work**. A comment it posts that mentions | |
| 1110 | + | `@g1t` starts nothing, and it cannot assign an issue or a plan to g1t, | |
| 1111 | + | queue one for it, hand it work or ask it for a review. Otherwise a | |
| 1112 | + | workflow that asks g1t to fix a failing check would run again on g1t's | |
| 1113 | + | push, and ask again, without end. A step that should put g1t to work | |
| 1114 | + | uses a token of a person's own, stored as a | |
| 1115 | + | [secret](/guides/secrets-and-variables/). | |
| 1086 | 1116 | ||
| 1087 | 1117 | `permissions:` goes at the top of the workflow, for every job, or on a job, | |
| 1088 | 1118 | which then ignores the workflow's. Once either is written, every permission | |
| 149 | 149 | ||
| 150 | 150 | Memory also fills itself. At the end of every run that changes code, the | |
| 151 | 151 | agent is asked what it learned; a person's correction in a review, a merged | |
| 152 | − | pull request's decision, and what a project's `AGENTS.md`, README and | |
| 153 | − | manifests say are captured too. These arrive as **candidates**, which no | |
| 152 | + | pull request's decision, and what a project's `AGENTS.md`, README, | |
| 153 | + | CONTRIBUTING, docs on how to work in it and manifests say are captured | |
| 154 | + | too ([which docs](/guides/context-hub/#which-docs-are-read-for-memory)). | |
| 155 | + | These arrive as **candidates**, which no | |
| 154 | 156 | agent is given until they are kept: at once when two independent sources | |
| 155 | 157 | say the same thing or a project's `AGENTS.md` or manifests state it, | |
| 156 | 158 | otherwise by a member in the **Review** list on **Agents → Memory** or the | |
| ⋯ | |||
| 190 | 192 | - **edit** one that has drifted, or change its kind; | |
| 191 | 193 | - **forget** one that no longer holds; | |
| 192 | 194 | - **keep**, **edit** or **dismiss** a candidate waiting for review. A | |
| 193 | − | dismissed candidate is never suggested again in the same words. | |
| 195 | + | dismissed candidate is never suggested again, in the same words or near enough to them. | |
| 194 | 196 | ||
| 195 | 197 | Each shows who added it, when, and when it was last given to an agent. A | |
| 196 | 198 | memory that has not been given to an agent for a long time is a good one to | |
| 26 | 26 | ||
| 27 | 27 | | Page | Address | What is on it | | |
| 28 | 28 | | --- | --- | --- | | |
| 29 | − | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. | | |
| 29 | + | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. | | |
| 30 | 30 | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 31 | 31 | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 32 | 32 | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| ⋯ | |||
| 1042 | 1042 | | Access tokens, SSH keys and applications | Your personal access tokens (classic and fine-grained), SSH keys, connected applications and sign-ins from a tool stop working and are removed, and so do the deploy keys you added to repositories. A workspace's own tokens are not affected, even ones you made. | | |
| 1043 | 1043 | | Workspaces, teams and repositories | You leave every workspace and team, and lose the roles you were given on single repositories. Repository invitations waiting for you are withdrawn, and invites you made that nobody used are revoked. | | |
| 1044 | 1044 | | Your profile | `g1t.sh/<username>` answers 404, and you drop out of search. Nobody can add you to a workspace, team or repository, and nothing more is emailed to you. | | |
| 1045 | − | | What you wrote | Stays where it is, under your username for now. | | |
| 1045 | + | | What you wrote | Stays where it is, under your username for now. Commits made with your confirmed or noreply addresses show as `ghost`, and as yours again if your account is restored. | | |
| 1046 | 1046 | | Your username | Held for your account. Nobody else can take it. | | |
| 1047 | 1047 | ||
| 1048 | 1048 | Within 30 days, support can restore it: write to support@g1t.sh from one | |
| 36 | 36 | | `README`, `AGENTS.md` (or `CLAUDE.md`), `CONTRIBUTING`, `docs/*.md`, `runbooks/*.md` | **Docs**, split into sections for search | | |
| 37 | 37 | | `.g1t/workflows/*`, `.github/workflows/*` | Whether the project's tests run in checks | | |
| 38 | 38 | | `owners:` in `.g1t/project.yml`, and `CODEOWNERS` | **Owners** | | |
| 39 | + | | `memory:` in `.g1t/project.yml` | Which docs are [read for memory](#which-docs-are-read-for-memory) | | |
| 39 | 40 | ||
| 40 | 41 | and joins them with what g1t already knows: | |
| 41 | 42 | ||
| ⋯ | |||
| 76 | 77 | | **Agent runs** | At the end of every run that changes code, the agent is asked what it learned that the next agent would need, with what showed it | fact, convention, decision or gotcha | | |
| 77 | 78 | | **Reviews** | A person's request for changes, or a comment that corrects the agent ("we use the shared client instead"), on a pull request | convention, quoting the comment | | |
| 78 | 79 | | **Merges** | A merged pull request's title, why (the first paragraph of its description) and the files it changed | decision | | |
| 79 | − | | **Docs and manifests** | Bullets in `AGENTS.md`; commands under a README's setup and testing sections; conventions sections; the package manager and test commands from manifests | fact, convention or gotcha | | |
| 80 | + | | **Docs and manifests** | Bullets in `AGENTS.md`; commands and bullets under the setup, testing and conventions sections of docs on how to work in the project; the package manager and test commands from manifests | fact, convention or gotcha | | |
| 80 | 81 | ||
| 81 | 82 | What is captured arrives as a **candidate**. No agent is given a candidate | |
| 82 | 83 | until it is **kept**: | |
| ⋯ | |||
| 87 | 88 | - **by a person**, in the Review queue. | |
| 88 | 89 | ||
| 89 | 90 | The same thing said again in different case, punctuation or spacing counts | |
| 90 | − | as the same memory, seen once more. A memory seen again from the same | |
| 91 | + | as the same memory, seen once more. So does the same thing in slightly | |
| 92 | + | different words: a sentence that grew (a list with one more item), or one | |
| 93 | + | that holds all of another's words. A memory seen again from the same | |
| 91 | 94 | source (the same run, the same file) is not counted twice. | |
| 92 | 95 | ||
| 96 | + | ### Which docs are read for memory | |
| 97 | + | ||
| 98 | + | Memory is for how to work in a project, so only docs that say how are | |
| 99 | + | read for it: | |
| 100 | + | ||
| 101 | + | | Read for memory | Not read for memory | | |
| 102 | + | | --- | --- | | |
| 103 | + | | `README`, `AGENTS.md` (or `CLAUDE.md`), `CONTRIBUTING`, `runbooks/*.md` | Plans and roadmaps (`PLAN.md`, `roadmap.md`) | | |
| 104 | + | | A doc in `docs/` whose name says how to work: `DEPLOYING.md`, `testing.md`, `architecture.md`, `setup.md`, `conventions.md`, `getting-started.md` | Changelogs, release notes, history, incidents, demos, research, notes, feedback | | |
| 105 | + | ||
| 106 | + | Within those docs: | |
| 107 | + | ||
| 108 | + | - Only the **setup, testing and conventions** sections are read (in | |
| 109 | + | `AGENTS.md`, every section). A section about plans, a roadmap, asks or | |
| 110 | + | feedback is skipped with everything under it, wherever it is. | |
| 111 | + | - A doc that reads as a plan or a report (most of its headings are plans, | |
| 112 | + | or its bullets are labelled "What we tried", "Ask" and the like) gives | |
| 113 | + | nothing, whatever its name. | |
| 114 | + | - A line that says what someone wants rather than how things are ("g1t | |
| 115 | + | will", "should", "TODO") is left out. In `AGENTS.md`, "should" states a | |
| 116 | + | rule, and is kept. | |
| 117 | + | - A bullet is taken whole, with the lines it wraps onto. A long one is cut | |
| 118 | + | only between sentences, to 300 characters; one whose first sentence is | |
| 119 | + | longer is left out rather than cut. | |
| 120 | + | - A bullet that tells you to do or never do something is a convention or | |
| 121 | + | a gotcha; anything else is a fact. Outside `AGENTS.md`, a bullet waits | |
| 122 | + | for review at 50 to 60% sure. | |
| 123 | + | ||
| 124 | + | To read another doc for memory, or never read one, say so in the | |
| 125 | + | project's `.g1t/project.yml`, by path or a whole folder: | |
| 126 | + | ||
| 127 | + | ```yaml | |
| 128 | + | memory: | |
| 129 | + | docs: | |
| 130 | + | - docs/PERFORMANCE.md | |
| 131 | + | skip: | |
| 132 | + | - README.md | |
| 133 | + | - docs/legacy/* | |
| 134 | + | ``` | |
| 135 | + | ||
| 136 | + | `skip` wins over `docs`. A change to these lists applies to each doc the | |
| 137 | + | next time it changes, or for every doc at once with **Rebuild**. | |
| 138 | + | ||
| 139 | + | Once g1t has read every file of a project as these rules read it, a | |
| 140 | + | candidate from its docs that is still waiting and that its docs no longer | |
| 141 | + | suggest (the doc changed, or the rules leave the line out) is removed from | |
| 142 | + | the queue. A candidate another source saw too, and every kept or dismissed | |
| 143 | + | memory, stays. A waiting candidate its doc now says in other words takes | |
| 144 | + | the new words. | |
| 145 | + | ||
| 93 | 146 | ### Review | |
| 94 | 147 | ||
| 95 | 148 | The **Memory** tab of the Context page lists every candidate in the | |
| ⋯ | |||
| 99 | 152 | ||
| 100 | 153 | - **Keep** gives it to every agent from their next run on. | |
| 101 | 154 | - **Edit** rewords it, or changes its kind, and keeps it. | |
| 102 | − | - **Dismiss** throws it away, and the same wording is never suggested | |
| 103 | − | again. | |
| 155 | + | - **Dismiss** throws it away, and the same thing, in the same words or | |
| 156 | + | near enough to them, is never suggested again. | |
| 104 | 157 | ||
| 105 | 158 | ### Never a secret | |
| 106 | 159 | ||
| 223 | 223 | ||
| 224 | 224 | When a newer version comes out for a dependency (or group) that already | |
| 225 | 225 | has an open pull request, g1t opens a new pull request and closes the | |
| 226 | − | older one with the comment "Superseded by #N.". | |
| 226 | + | older one with the comment "Closed: superseded by #N.". It deletes the | |
| 227 | + | older pull request's branch. | |
| 228 | + | ||
| 229 | + | ### Updates you make yourself | |
| 230 | + | ||
| 231 | + | You do not have to merge g1t's pull request to update a dependency. On | |
| 232 | + | every push to the default branch, g1t reads the lockfiles again. When | |
| 233 | + | every dependency an open version update raises is already at its new | |
| 234 | + | version or later, or is no longer a dependency, g1t closes the pull | |
| 235 | + | request with a comment that says so, for example: | |
| 236 | + | ||
| 237 | + | > Closed: `lodash` is already at 4.17.21 on `main`, so this update to | |
| 238 | + | > 4.17.21 is no longer needed. | |
| 239 | + | ||
| 240 | + | g1t then deletes the pull request's branch. While one dependency in a | |
| 241 | + | grouped pull request still needs it, the pull request stays open. This | |
| 242 | + | applies to updates into the default branch; one with a `target-branch` | |
| 243 | + | is left for you to close. | |
| 244 | + | ||
| 245 | + | ### Branches | |
| 246 | + | ||
| 247 | + | Each update pull request is made on a branch g1t creates (see | |
| 248 | + | [branch names](#pull-request-branch-name)). When the pull request merges | |
| 249 | + | or closes, whether g1t or a person closes it, g1t deletes that branch. If | |
| 250 | + | someone pushed to the branch after its last commit in the pull request, | |
| 251 | + | g1t leaves it alone. A branch left from an update pull request that is | |
| 252 | + | already closed is removed on a later push to the default branch. The | |
| 253 | + | branch of a pull request closed because code has to change is kept while | |
| 254 | + | g1t works on the issue for it. | |
| 255 | + | ||
| 256 | + | Deleting the branch means a closed update pull request cannot be reopened | |
| 257 | + | from the page. To make a version update again, comment `@g1t reopen` on | |
| 258 | + | it: g1t makes it again as a new pull request. | |
| 227 | 259 | ||
| 228 | 260 | ### Landing them | |
| 229 | 261 |
| 124 | 124 | are served under the binding name your config gives them. Cron triggers | |
| 125 | 125 | (`triggers.crons`) are not scheduled, so a `scheduled` handler never | |
| 126 | 126 | runs; the deployment says so in its warnings. | |
| 127 | + | - Each request your Worker answers may use up to 50 ms of CPU time | |
| 128 | + | (waiting on the network does not count) and make up to 50 requests of | |
| 129 | + | its own (`fetch` calls and the like). A request that goes over either | |
| 130 | + | is stopped and answered with a 503 page that says so. Static assets are | |
| 131 | + | served without running your Worker, and count toward neither. | |
| 127 | 132 | - `vars` are deployed as plain-text bindings (or JSON, for objects). Rows | |
| 128 | 133 | of the project's [secrets and variables](/guides/secrets-and-variables/) | |
| 129 | 134 | available to Deployments are bound too, and replace a `var` of the same |
| 142 | 142 | the compared branch. | |
| 143 | 143 | ||
| 144 | 144 | On **Files**, each file and folder shows the commit that last changed it and | |
| 145 | − | when, from up to 300 commits of the branch's history; one changed before | |
| 146 | − | that shows none. The branch menu at the top switches branch and keeps the | |
| 145 | + | when, from the branch's whole history. On a long history the first view | |
| 146 | + | can show some of them blank while g1t finishes reading it; a later view | |
| 147 | + | fills them in, and after a push only the new commits are read. The branch menu at the top switches branch and keeps the | |
| 147 | 148 | folder or file you are on. | |
| 148 | 149 | ||
| 149 | 150 | ## Pull request forks | |
| ⋯ | |||
| 183 | 184 | ||
| 184 | 185 | Each push sends only what the one before did not. | |
| 185 | 186 | ||
| 187 | + | ### Pushes of many branches or tags | |
| 188 | + | ||
| 189 | + | Every branch and tag in a push is stored. Each one is also announced as a | |
| 190 | + | `git.push` event, which starts workflows, mirrors the repository and | |
| 191 | + | calls webhooks, except in a push of many: | |
| 192 | + | ||
| 193 | + | | A push of | What is announced | | |
| 194 | + | | --- | --- | | |
| 195 | + | | Up to 3 tags | Each tag | | |
| 196 | + | | More than 3 tags (`git push --tags`, say) | None of the tags | | |
| 197 | + | | Up to 1,000 branches | Each branch | | |
| 198 | + | | More than 1,000 branches | Only the default branch, if it moved | | |
| 199 | + | ||
| 200 | + | To have tags start workflows, push them 3 or fewer at a time. | |
| 201 | + | ||
| 186 | 202 | ### When the store is busy | |
| 187 | 203 | ||
| 188 | 204 | If Cloudflare Artifacts is rate limiting g1t or not answering, g1t tries | |
| ⋯ | |||
| 203 | 219 | until the month turns. Counting starts on 2026-10-14. See | |
| 204 | 220 | [git operations](/guides/usage-and-billing/#git-operations). | |
| 205 | 221 | ||
| 222 | + | ### Request limits | |
| 223 | + | ||
| 224 | + | Git requests without credentials are limited to 120 a minute from each IP | |
| 225 | + | address, about 40 clones; with credentials, 1,200 a minute for each set of | |
| 226 | + | credentials. Anonymous clones of one repository that g1t has not cached | |
| 227 | + | are limited to 120 a minute, whoever makes them. Past a limit, git is | |
| 228 | + | answered `429` with a message saying to wait a minute. Clone with | |
| 229 | + | [credentials](#authentication) to count against your own limit. See | |
| 230 | + | [rate limits](/reference/rate-limits/). | |
| 231 | + | ||
| 206 | 232 | What these limits mean in practice, and what to do instead, is on | |
| 207 | 233 | [What g1t can't do yet](/about/limitations/#git). | |
| 208 | 234 | ||
| 275 | 275 | spend the same way it reports it for billing and stops the agent once | |
| 276 | 276 | the spend reaches the cap. The step in flight when it does can take the | |
| 277 | 277 | run a little past it. | |
| 278 | + | - g1t's model proxy holds the run to the same cap, whatever happens in the | |
| 279 | + | sandbox. It adds up what each of the run's model answers cost, and once | |
| 280 | + | the run has spent its cap it refuses the run's model requests with | |
| 281 | + | `402` and the error code `run_cap_reached`, which shows in the run's log. | |
| 282 | + | The proxy also takes at most 16 of a run's model requests at a time, | |
| 283 | + | and a run's model token reaches only `/v1/messages` (with | |
| 284 | + | `/v1/messages/count_tokens`) and `/v1/models`. The token stops working | |
| 285 | + | when the run ends. | |
| 278 | 286 | - The time cap is enforced twice: the harness stops the agent when it | |
| 279 | 287 | passes, and the sandbox itself is stopped three minutes after, whatever | |
| 280 | 288 | is running in it. |
| 299 | 299 | | Open | The pull request is open. | | |
| 300 | 300 | | Merged | The pull request merged. | | |
| 301 | 301 | | Closed | The pull request was closed without merging. | | |
| 302 | − | | Superseded | A newer security update for the same package replaced it, or the package is no longer vulnerable. | | |
| 302 | + | | Superseded | A newer security update for the same package replaced it, or the alerts it fixes were fixed another way or dismissed. | | |
| 303 | 303 | | Needs code changes | Raising the version was not enough; an agent is working on it. | | |
| 304 | 304 | | Failed | The update could not be made. | | |
| 305 | 305 | ||
| 306 | + | ### When an update is no longer needed | |
| 307 | + | ||
| 306 | 308 | A newer security update for the same package closes the older pull request | |
| 307 | − | as superseded. So does the package no longer being vulnerable. | |
| 309 | + | with the comment "Closed: superseded by #N, which upgrades `<package>` to | |
| 310 | + | `<version>`.". | |
| 311 | + | ||
| 312 | + | g1t also closes a security update once none of the alerts it fixes is | |
| 313 | + | open. An alert counts as one the update fixes when it is on the update's | |
| 314 | + | package, at a version below the one the update raises it to. Each of those | |
| 315 | + | alerts must be: | |
| 316 | + | ||
| 317 | + | - **Fixed on the default branch.** You raised the version another way, for | |
| 318 | + | example with an `overrides` entry in `package.json` or by updating the | |
| 319 | + | lockfile yourself, and pushed it. The next scan no longer finds the | |
| 320 | + | vulnerable version in the lockfiles. | |
| 321 | + | - **Dismissed.** g1t checks when you dismiss an alert, without waiting for a | |
| 322 | + | push. | |
| 323 | + | ||
| 324 | + | g1t comments why, closes the pull request and deletes its branch: | |
| 325 | + | ||
| 326 | + | > Closed: the alert this fixed is resolved on `main` (`sharp` 0.34.1 is no | |
| 327 | + | > longer in `package-lock.json`). | |
| 328 | + | ||
| 329 | + | > Closed: the alert this fixed was dismissed, so this update is no longer | |
| 330 | + | > needed. | |
| 331 | + | ||
| 332 | + | A grouped security update closes only when the alerts of every package in | |
| 333 | + | it are fixed or dismissed. If a package is still vulnerable at a higher | |
| 334 | + | version than the pull request reached, g1t closes the pull request and | |
| 335 | + | asks for a new one for the higher version in the same scan. A later scan never | |
| 336 | + | opens the closed pull request again. g1t opens a new one only if the | |
| 337 | + | package becomes vulnerable again, at a version that update would fix. | |
| 338 | + | ||
| 339 | + | When a security update pull request merges or closes, by g1t or by a | |
| 340 | + | person, g1t deletes its `g1t/security/…` branch, unless someone pushed to | |
| 341 | + | it after its last commit in the pull request. A branch left from an | |
| 342 | + | update pull request that was already closed is removed on a later push to | |
| 343 | + | the default branch. See | |
| 344 | + | [branches](/guides/dependency-updates/#branches). | |
| 308 | 345 | ||
| 309 | 346 | How each lockfile is changed: | |
| 310 | 347 |
| 852 | 852 | workflow job is recorded as failed with "Not started:" and the reason. | |
| 853 | 853 | Runs already under way finish, so usage can go slightly past a limit. | |
| 854 | 854 | ||
| 855 | + | ### When g1t pauses work for everyone | |
| 856 | + | ||
| 857 | + | If usage across all of g1t climbs far past normal, g1t can pause some | |
| 858 | + | kinds of work for every workspace while it looks into it. Each pause is | |
| 859 | + | separate, and runs already under way finish: | |
| 860 | + | ||
| 861 | + | | Paused | What you see | | |
| 862 | + | | --- | --- | | |
| 863 | + | | Compute | New agent runs, checks, workflow jobs and deploy builds are refused with `paused` and a message that g1t has paused them across the platform. Try again later. | | |
| 864 | + | | Schedules | Workflows on `schedule:` skip the minutes while it lasts; they are not run late. Issues waiting for an agent stay in the queue. | | |
| 865 | + | | Indexing | **Rebuild** in the [context hub](/guides/context-hub/) is refused, and new semantic search embeddings wait. Text search still answers, and search keeps up with new pushes. | | |
| 866 | + | | Renders | A link to g1t shows g1t's logo instead of the page's own card. | | |
| 867 | + | ||
| 868 | + | Nothing in your workspace changes, and nothing is charged while work | |
| 869 | + | waits. | |
| 870 | + | ||
| 855 | 871 | ## Security on every plan | |
| 856 | 872 | ||
| 857 | 873 | Every workspace, free or on the plan, has the [audit log](/guides/audit-log/), |
| 48 | 48 | ||
| 49 | 49 | `data` holds what the event is about: ids and numbers to fetch the rest with | |
| 50 | 50 | the [API](/reference/api/). `actor` is null for something g1t did by | |
| 51 | − | itself. | |
| 51 | + | itself. An event whose `data` would be larger than about 96 KB has its | |
| 52 | + | long text, such as a comment's or release's body, shortened, and | |
| 53 | + | `data.truncated` is `true`: fetch the whole text from the API. | |
| 52 | 54 | ||
| 53 | 55 | With these headers: | |
| 54 | 56 | ||
| ⋯ | |||
| 64 | 66 | ||
| 65 | 67 | | Event | When | | |
| 66 | 68 | | --- | --- | | |
| 67 | − | | `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`. | | |
| 69 | + | | `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`. A push of more than 3 tags, or more than 1,000 branches, sends fewer; see [pushes of many branches or tags](/guides/git/#pushes-of-many-branches-or-tags). | | |
| 68 | 70 | | `repo.created`, `repo.forked` | A repository was made, or forked for a pull request. | | |
| 69 | 71 | | `repo.updated` | Its description, website, topics, protection or visibility changed. | | |
| 70 | 72 | | `repo.visibility_changed` | It was made public or private. | | |
| 414 | 414 | (`ops@g1t.sh`), in URLs, in package scopes (`@g1t/platform`) and in longer | |
| 415 | 415 | names (`@g1t-bot`) are ignored. Matching ignores case. Agents mentioning | |
| 416 | 416 | `@g1t` start nothing, so | |
| 417 | − | agents cannot set each other to work this way. | |
| 417 | + | agents cannot set each other to work this way. Neither does a comment made | |
| 418 | + | with a workflow job's own token (`G1T_TOKEN`), so a workflow cannot set | |
| 419 | + | off g1t whose push sets off the workflow again; see | |
| 420 | + | [the job's token](/guides/actions/#the-jobs-token). | |
| 418 | 421 | ||
| 419 | 422 | **Who can.** People with the Write [role](/guides/access-and-roles/) or higher on the | |
| 420 | 423 | repository, members or not. Anyone else who mentions it gets a short reply | |
| ⋯ | |||
| 428 | 431 | ||
| 429 | 432 | Each comment starts one run at most; to ask again, write a new comment. | |
| 430 | 433 | ||
| 434 | + | A mention sends g1t back to a pull request it made even after it stopped | |
| 435 | + | there, or used up the repository's revisions. At most 10 mentions in a day | |
| 436 | + | send it back to the same pull request; past that, g1t replies that it | |
| 437 | + | has reached the most it takes, and the next one works a day after the | |
| 438 | + | first of those 10. | |
| 439 | + | ||
| 431 | 440 | ## The label rule | |
| 432 | 441 | ||
| 433 | 442 | Under a project's **Settings → Agents**, someone with the Maintain role or | |
| 566 | 566 | their own*. An agent's change landed without you when g1t merged it, by | |
| 567 | 567 | auto-merge or from the [merge queue](/guides/merge-queue/), with no person | |
| 568 | 568 | pressing merge. People's changes are their merged pull requests and the | |
| 569 | − | commits they pushed straight to the default branch. **Review N that need you** jumps to the | |
| 569 | + | commits they pushed straight to the default branch. A push is a person's | |
| 570 | + | by the account that signed in to make it, not by the name on its commits: | |
| 571 | + | pushes by g1t or a workflow job's token, and commits g1t wrote, are not | |
| 572 | + | counted as people's. **Review N that need you** jumps to the | |
| 570 | 573 | list, and **New issue** opens a new issue in the project you pick. | |
| 571 | 574 | ||
| 572 | 575 | | Across the top | What it counts | | |
| ⋯ | |||
| 669 | 672 | `example.com` is saved as `https://example.com`. Your email address is | |
| 670 | 673 | never shown. | |
| 671 | 674 | ||
| 675 | + | **Time zone.** Pick the time zone you are in, by city or region (such as | |
| 676 | + | `America/Denver`), and the [card over your name](#the-card-over-a-name) | |
| 677 | + | shows your local time, so people can tell whether it is a good moment to | |
| 678 | + | ask you something. If your browser's time zone differs from the one | |
| 679 | + | saved, the field offers **Use my browser's time zone**. Choose **Not | |
| 680 | + | shown** to clear it. | |
| 681 | + | ||
| 672 | 682 | **Who sees what.** A profile is public, but the work and workspaces on it | |
| 673 | 683 | are filtered for whoever is looking: | |
| 674 | 684 | ||
| ⋯ | |||
| 692 | 702 | | --- | --- | | |
| 693 | 703 | | Picture, name, username and pronouns | Always | | |
| 694 | 704 | | Bio and location | They filled them in | | |
| 705 | + | | Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) | | |
| 695 | 706 | | **Member of** | The same workspaces their profile shows you, at most three named | | |
| 696 | 707 | | **Committed to this repository in the past day**, **week** or **month** | You opened it inside a repository you can read, and their latest commit on its default branch is that recent | | |
| 697 | 708 | ||
| ⋯ | |||
| 729 | 740 | | One of your confirmed addresses, or your noreply address | You | | |
| 730 | 741 | | An address added to an account but not confirmed | The name in the commit | | |
| 731 | 742 | | An address no account has | The name in the commit, with a plain picture, no link and no card | | |
| 732 | − | | A deleted account's noreply address | `ghost` | | |
| 743 | + | | A deleted account's noreply address, or any of its confirmed addresses during the 30 days it can be restored | `ghost` | | |
| 733 | 744 | | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` | | |
| 734 | 745 | ||
| 735 | 746 | `Co-authored-by` trailers are matched the same way, and their pictures sit | |
| 142 | 142 | | 404 | `not_found` | It does not exist, or you cannot see it. A path that is not an endpoint answers this too. | | |
| 143 | 143 | | 409 | `conflict` | The request conflicts with the current state. | | |
| 144 | 144 | | 422 | `invalid` | The input is not valid. | | |
| 145 | + | | 429 | `rate_limited` | Too many requests in the last minute. Wait the seconds `Retry-After` says. See [rate limits](#rate-limits). | | |
| 145 | 146 | ||
| 146 | 147 | Branch on `code`, not on `message`: messages are written for people and | |
| 147 | 148 | may change. | |
| ⋯ | |||
| 163 | 164 | [Settings → Access tokens](https://g1t.sh/settings/tokens), or use another | |
| 164 | 165 | token. A `403` for any other reason has no `needed_scope`. | |
| 165 | 166 | ||
| 167 | + | ## Rate limits | |
| 168 | + | ||
| 169 | + | Each token may make 1,000 requests a minute. Requests without a token are | |
| 170 | + | limited to 60 a minute for each IP address. Past either, the API answers | |
| 171 | + | `429` with `rate_limited` and a `Retry-After` header saying how many | |
| 172 | + | seconds to wait: | |
| 173 | + | ||
| 174 | + | ```json | |
| 175 | + | { "error": { "code": "rate_limited", "message": "Too many requests with this token. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/" } } | |
| 176 | + | ``` | |
| 177 | + | ||
| 178 | + | Every limit, and how to stay under them, is on [rate limits](/reference/rate-limits/). | |
| 179 | + | ||
| 166 | 180 | ## Lists | |
| 167 | 181 | ||
| 168 | 182 | Lists come newest first, unless an endpoint says otherwise. Most return | |
| 203 | 203 | the REST API returns them. | |
| 204 | 204 | - Reading a public repository needs no sign-in through the API. Through MCP, | |
| 205 | 205 | every call needs to be signed in. | |
| 206 | + | - Each token may make 1,000 requests a minute to the MCP server, apart from | |
| 207 | + | its REST API calls. Past that, the request is answered `429` with a | |
| 208 | + | `Retry-After` header and a `rate_limited` error. See | |
| 209 | + | [rate limits](/reference/rate-limits/). | |
| 206 | 210 | ||
| 207 | 211 | The tables below list each action's required inputs. Optional inputs are | |
| 208 | 212 | in the tool's schema, which `tools/list` returns, and on the action's page |
| 1 | + | --- | |
| 2 | + | title: Rate limits | |
| 3 | + | description: How many requests the API, the MCP server, git over HTTPS and the site take a minute, what a limited request is answered with, and how to stay under the limits. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | g1t limits how many requests a client can make in a minute, so that one | |
| 7 | + | client cannot slow g1t down for everyone else or run up a repository | |
| 8 | + | owner's bill. The limits are well above what a person or an agent working | |
| 9 | + | normally reaches. Each counts requests over 60 seconds, approximately: a | |
| 10 | + | client can sometimes get a few more through before it is limited. | |
| 11 | + | ||
| 12 | + | ## Limits | |
| 13 | + | ||
| 14 | + | | Where | Counted by | Requests a minute | | |
| 15 | + | | --- | --- | --- | | |
| 16 | + | | REST API, with a token | Token | 1,000 | | |
| 17 | + | | REST API, without a token | Client IP address | 60 | | |
| 18 | + | | MCP server | Token | 1,000 | | |
| 19 | + | | Git over HTTPS, with credentials | Credentials | 1,200 | | |
| 20 | + | | Git over HTTPS, without credentials | Client IP address | 120 | | |
| 21 | + | | Anonymous clones of one repository that are not cached | Repository | 120 | | |
| 22 | + | | Pages on g1t.sh, signed in | Session | 1,200 | | |
| 23 | + | | Pages on g1t.sh, signed out | Client IP address | 600 | | |
| 24 | + | | Archive downloads, workflow run pages, logs and search, signed out | Client IP address | 30 | | |
| 25 | + | | Container and package registries | See [storage and pull limits](/guides/containers/#storage-and-pull-limits) | | | |
| 26 | + | ||
| 27 | + | The REST API and the MCP server count apart: calls to one do not use up | |
| 28 | + | the other's limit. A request with a token that is not valid counts against | |
| 29 | + | its IP address, as a request without a token does. On g1t.sh, every | |
| 30 | + | request from one IP address, signed in or not, also counts toward 3,000 a | |
| 31 | + | minute. | |
| 32 | + | ||
| 33 | + | Agents' sandboxes and workflow jobs report to g1t with their own | |
| 34 | + | credentials. Those reports are not rate limited. | |
| 35 | + | ||
| 36 | + | ## When you are limited | |
| 37 | + | ||
| 38 | + | A request past a limit is answered `429 Too Many Requests` with a | |
| 39 | + | `Retry-After` header: the number of seconds to wait before trying again. | |
| 40 | + | ||
| 41 | + | ```http | |
| 42 | + | HTTP/1.1 429 Too Many Requests | |
| 43 | + | Retry-After: 60 | |
| 44 | + | Content-Type: application/json | |
| 45 | + | ||
| 46 | + | { "error": { "code": "rate_limited", "message": "Too many requests with this token. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/" } } | |
| 47 | + | ``` | |
| 48 | + | ||
| 49 | + | | Where | Body | | |
| 50 | + | | --- | --- | | |
| 51 | + | | REST API and MCP server | JSON in the [error shape](/reference/api/#errors) every endpoint uses, with `code` `rate_limited`. Through MCP it comes back as the HTTP answer to the request, not as a tool result. | | |
| 52 | + | | Git over HTTPS | Plain text, which git prints after `remote:` or in its error. | | |
| 53 | + | | Pages on g1t.sh | Plain text. | | |
| 54 | + | ||
| 55 | + | Branch on the `429` status or the `rate_limited` code, never on the | |
| 56 | + | message. The API sends `Access-Control-Expose-Headers: retry-after`, so a | |
| 57 | + | browser app can read the header too. | |
| 58 | + | ||
| 59 | + | ## Staying under the limits | |
| 60 | + | ||
| 61 | + | - **Send a token.** Signed-in limits are much higher than anonymous ones, | |
| 62 | + | and they follow the token rather than the network you are on, so people | |
| 63 | + | sharing an office or a VPN do not share a limit. | |
| 64 | + | - **Clone with credentials.** A clone is about three git requests. Use | |
| 65 | + | [a token as the password](/guides/git/#authentication) to count against your own | |
| 66 | + | limit rather than your network's. | |
| 67 | + | - **Wait for `Retry-After`.** Retrying sooner is answered `429` again and | |
| 68 | + | counts against the limit. | |
| 69 | + | - **Cache what does not change.** A commit's contents never change: read | |
| 70 | + | a commit by its SHA once and keep it. | |
| 71 | + | - **Use webhooks instead of polling.** A [webhook](/guides/webhooks/) | |
| 72 | + | tells you when something changes, without a request a minute. | |
| 73 | + | ||
| 74 | + | Repeated anonymous clones of the same commit are answered from a cache and | |
| 75 | + | do not count against the repository's limit. If a limit gets in the way of | |
| 76 | + | something you need to do, [contact support](https://g1t.sh/support). | |
| 77 | + | ||
| 78 | + | ## Running g1t yourself | |
| 79 | + | ||
| 80 | + | An installation you run yourself has no rate limits. See | |
| 81 | + | [run g1t yourself](/guides/self-hosting/). |
| 8 | 8 | "directory": "./dist", | |
| 9 | 9 | "not_found_handling": "404-page" | |
| 10 | 10 | }, | |
| 11 | − | "routes": [{ "pattern": "docs.g1t.sh", "custom_domain": true }] | |
| 11 | + | "routes": [{ "pattern": "docs.g1t.sh", "custom_domain": true }], | |
| 12 | + | // Logs of a tenth of requests: files only, nothing to debug in each. | |
| 13 | + | "observability": { "enabled": true, "head_sampling_rate": 0.1 } | |
| 12 | 14 | } |
| 47 | 47 | fail, | |
| 48 | 48 | ok, | |
| 49 | 49 | } from "@g1t/contracts"; | |
| 50 | + | import { LIMIT_PERIOD_SECONDS, type RateLimitBinding, clientAddress, isLimited, secretKey } from "@g1t/contracts/rate-limits"; | |
| 50 | 51 | import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2"; | |
| 51 | 52 | import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2"; | |
| 52 | 53 | import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2"; | |
| ⋯ | |||
| 186 | 187 | * it, deploys are not announced and detection does not hold off for them. | |
| 187 | 188 | */ | |
| 188 | 189 | STATUS_DEPLOY_TOKEN?: string; | |
| 190 | + | /** | |
| 191 | + | * Asking for a subscription, per client address and per email address | |
| 192 | + | * (RATE_LIMITS in packages/contracts). Without them, only the resend | |
| 193 | + | * window (RESEND_AFTER_MS) holds back repeated emails to one address. | |
| 194 | + | */ | |
| 195 | + | STATUS_SUBSCRIBE_LIMIT?: RateLimitBinding; | |
| 196 | + | STATUS_EMAIL_LIMIT?: RateLimitBinding; | |
| 189 | 197 | } | |
| 190 | 198 | ||
| 191 | 199 | /** How long the edge keeps a page or the JSON. */ | |
| ⋯ | |||
| 539 | 547 | ||
| 540 | 548 | if (path === "/subscribe" && post) { | |
| 541 | 549 | if (!emailOn(env)) return message("Email updates are not available", "Follow the Atom or JSON feed instead.", 503); | |
| 550 | + | // Each request can send an email: limited per client, then per address. | |
| 551 | + | const tooMany = () => { | |
| 552 | + | const answer = message("Too many requests", "Wait a minute and try again.", 429); | |
| 553 | + | answer.headers.set("retry-after", String(LIMIT_PERIOD_SECONDS)); | |
| 554 | + | return answer; | |
| 555 | + | }; | |
| 556 | + | if (await isLimited(env.STATUS_SUBSCRIBE_LIMIT, `ip:${clientAddress(request)}`)) return tooMany(); | |
| 542 | 557 | const data = await form(request); | |
| 543 | 558 | if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way."); | |
| 544 | 559 | const email = normalizeEmail(data.get("email")); | |
| 545 | 560 | if (!email) return message("That is not an email address", "Go back and check it.", 400); | |
| 561 | + | if (await isLimited(env.STATUS_EMAIL_LIMIT, await secretKey("email", email))) return tooMany(); | |
| 546 | 562 | const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key)); | |
| 547 | 563 | const token = newToken(); | |
| 548 | 564 | const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS); | |
| 69 | 69 | "STATUS_FROM": "g1t status <noreply@g1t.sh>", | |
| 70 | 70 | "OG_IMAGE": "https://og.g1t.sh/image?path=%2Fstatus&v=2" | |
| 71 | 71 | }, | |
| 72 | − | "observability": { "enabled": true } | |
| 72 | + | // Asking for a subscription sends an email: limited per client address | |
| 73 | + | // and per email address (src/index.ts). Ids and limits: RATE_LIMITS in | |
| 74 | + | // packages/contracts. | |
| 75 | + | "ratelimits": [ | |
| 76 | + | { "name": "STATUS_SUBSCRIBE_LIMIT", "namespace_id": "4601", "simple": { "limit": 3, "period": 60 } }, | |
| 77 | + | { "name": "STATUS_EMAIL_LIMIT", "namespace_id": "4602", "simple": { "limit": 2, "period": 60 } } | |
| 78 | + | ], | |
| 79 | + | // Every log kept: few requests, and the checks' failures are what it is for. | |
| 80 | + | "observability": { "enabled": true, "head_sampling_rate": 1 } | |
| 73 | 81 | } |
| 4 | 4 | */ | |
| 5 | 5 | import { data, redirect } from "react-router"; | |
| 6 | 6 | ||
| 7 | − | import { parseCostSettings, parseMapping, parseRange } from "./costs"; | |
| 7 | + | import { parseCostSettings, parseMapping, parsePauseLevel, parseRange } from "./costs"; | |
| 8 | 8 | import { admin } from "./services.server"; | |
| 9 | 9 | import { settle } from "./settle"; | |
| 10 | 10 | import { requireStaff } from "./staff"; | |
| ⋯ | |||
| 17 | 17 | mapping: "Mapping saved. It applies from the next run; read the bill now to see it.", | |
| 18 | 18 | removed: "Mapping removed.", | |
| 19 | 19 | lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.", | |
| 20 | + | paused: "Paused across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.", | |
| 21 | + | resumed: "Resumed across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.", | |
| 20 | 22 | }; | |
| 21 | 23 | ||
| 22 | 24 | export type CostsActionResult = { error: string; section: string; values?: Record<string, string> }; | |
| ⋯ | |||
| 25 | 27 | requireStaff(context as Parameters<typeof requireStaff>[0]); | |
| 26 | 28 | const url = new URL(request.url); | |
| 27 | 29 | const range = parseRange(url.searchParams.get("days")); | |
| 28 | − | const report = await settle(admin.costs(range)); | |
| 30 | + | const [report, guard] = await Promise.all([settle(admin.costs(range)), settle(admin.platformGuard())]); | |
| 29 | 31 | const done = url.searchParams.get("done"); | |
| 30 | 32 | return { | |
| 31 | 33 | range, | |
| 32 | 34 | bucket: url.searchParams.get("product"), | |
| 33 | 35 | report: report.ok ? report.value : null, | |
| 34 | 36 | error: report.ok ? null : report.error, | |
| 37 | + | // The platform pause and usage watch (billing's platform.rs). | |
| 38 | + | guard: guard.ok ? guard.value : null, | |
| 39 | + | guardError: guard.ok ? null : guard.error, | |
| 35 | 40 | done: done ? (DONE[done] ?? null) : null, | |
| 36 | 41 | }; | |
| 37 | 42 | } | |
| ⋯ | |||
| 62 | 67 | if (!result.value.ok) return fail("lift", result.value.error.message); | |
| 63 | 68 | throw back("lifted", "#spend"); | |
| 64 | 69 | } | |
| 70 | + | if (intent === "pause" || intent === "resume") { | |
| 71 | + | const level = parsePauseLevel(form.get("level")); | |
| 72 | + | if (!level) return fail("platform", "Pick compute, schedules, indexing or renders."); | |
| 73 | + | const note = String(form.get("note") ?? "").trim().slice(0, 500); | |
| 74 | + | if (note.length < 5) return fail(`pause-${level}`, "Say why, for whoever looks next."); | |
| 75 | + | const result = await settle(admin.setPause(level, intent === "pause", note, staff.email)); | |
| 76 | + | if (!result.ok) return fail(`pause-${level}`, `Billing did not answer: ${result.error}`); | |
| 77 | + | if (!result.value.ok) return fail(`pause-${level}`, result.value.error.message); | |
| 78 | + | throw back(intent === "pause" ? "paused" : "resumed", "#platform"); | |
| 79 | + | } | |
| 65 | 80 | if (intent === "decide") { | |
| 66 | 81 | const id = String(form.get("id") ?? ""); | |
| 67 | 82 | const decision = form.get("decision") === "approve" ? "approve" : "reject"; | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { CostDay, SpendCaps } from "@g1t/contracts"; | |
| 4 | + | import type { CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | + | count, | |
| 7 | 8 | daySeries, | |
| 8 | 9 | daysBetween, | |
| 9 | 10 | marginOnPrice, | |
| ⋯ | |||
| 12 | 13 | parseBucket, | |
| 13 | 14 | parseCostSettings, | |
| 14 | 15 | parseMapping, | |
| 16 | + | parsePauseLevel, | |
| 17 | + | pauseBanner, | |
| 15 | 18 | parseRange, | |
| 16 | 19 | percentLabel, | |
| 17 | 20 | proposalOutcome, | |
| 18 | 21 | spendBanner, | |
| 19 | 22 | spendRows, | |
| 20 | 23 | subscriptionsOver, | |
| 24 | + | thresholdShare, | |
| 21 | 25 | unitDollars, | |
| 22 | 26 | versionCells, | |
| 23 | 27 | whoPaid, | |
| ⋯ | |||
| 200 | 204 | assert.equal(proposalOutcome({ status: "superseded", decidedBy: null, effectiveAt: null }), "replaced by a later measurement"); | |
| 201 | 205 | assert.equal(proposalOutcome({ status: "open", decidedBy: null, effectiveAt: null }), null); | |
| 202 | 206 | }); | |
| 207 | + | ||
| 208 | + | test("the pause banner names every paused level, and who paused it when it was not a person", () => { | |
| 209 | + | const level = (name: "compute" | "schedules" | "indexing" | "renders", paused: boolean, auto = false) => ({ | |
| 210 | + | level: name, | |
| 211 | + | paused, | |
| 212 | + | note: null, | |
| 213 | + | set_by: null, | |
| 214 | + | set_at: null, | |
| 215 | + | auto, | |
| 216 | + | }); | |
| 217 | + | const guard = (levels: ReturnType<typeof level>[]): PlatformGuard => ({ | |
| 218 | + | levels, | |
| 219 | + | hour: null, | |
| 220 | + | last_hour: [], | |
| 221 | + | month: "2026-10", | |
| 222 | + | month_to_date: [], | |
| 223 | + | breaches: [], | |
| 224 | + | can_read: true, | |
| 225 | + | auto_pause: ["schedules", "indexing"], | |
| 226 | + | }); | |
| 227 | + | assert.equal(pauseBanner(guard([level("compute", false), level("renders", false)])), null); | |
| 228 | + | assert.equal(pauseBanner(guard([level("schedules", true, true)])), "Paused across g1t: schedules (by the usage watcher)."); | |
| 229 | + | assert.equal( | |
| 230 | + | pauseBanner(guard([level("compute", true), level("schedules", true), level("indexing", true, true)])), | |
| 231 | + | "Paused across g1t: compute, schedules and indexing (by the usage watcher).", | |
| 232 | + | ); | |
| 233 | + | assert.equal(parsePauseLevel("renders"), "renders"); | |
| 234 | + | assert.equal(parsePauseLevel("everything"), null); | |
| 235 | + | assert.equal(count(240_000), "240k"); | |
| 236 | + | assert.equal(count(2_000_000_000), "2.0B"); | |
| 237 | + | assert.equal(thresholdShare(240_000, 200_000), 120); | |
| 238 | + | assert.equal(thresholdShare(5, 0), null); | |
| 239 | + | }); | |
| 2 | 2 | * Costs & margin: the arithmetic behind the page, apart from the SVG and | |
| 3 | 3 | * the Workers runtime so it can be tested under Node. Money is in micros. | |
| 4 | 4 | */ | |
| 5 | − | import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts"; | |
| 5 | + | import type { CostDay, CostMappingInput, CostSettings, PauseLevel, PlatformGuard, SpendCaps } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { parseDollars, usd } from "./money.ts"; | |
| 8 | 8 | ||
| ⋯ | |||
| 266 | 266 | if (capMicros <= 0) return 0; | |
| 267 | 267 | return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100)); | |
| 268 | 268 | } | |
| 269 | + | ||
| 270 | + | // --- Platform pause and usage watch (billing's platform.rs) ----------------- | |
| 271 | + | ||
| 272 | + | /** What each level of the platform pause stops, for the page and the banner. */ | |
| 273 | + | export const PAUSE_LEVELS: { level: PauseLevel; title: string; stops: string }[] = [ | |
| 274 | + | { level: "compute", title: "Compute", stops: "New agent runs, checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." }, | |
| 275 | + | { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs, and the runner's sweep that starts queued agents." }, | |
| 276 | + | { level: "indexing", title: "Indexing", stops: "Context embeddings and backfills, and search's backfills (they go on from where they were when resumed)." }, | |
| 277 | + | { level: "renders", title: "Renders", stops: "Social card images: a cache miss gets the brand card or the static logo." }, | |
| 278 | + | ]; | |
| 279 | + | ||
| 280 | + | /** Whether a form's level is one of the four. */ | |
| 281 | + | export function parsePauseLevel(value: unknown): PauseLevel | null { | |
| 282 | + | const level = String(value ?? ""); | |
| 283 | + | return PAUSE_LEVELS.some((l) => l.level === level) ? (level as PauseLevel) : null; | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /** The red bar on every sudo page while any level is paused. Null when none is. */ | |
| 287 | + | export function pauseBanner(guard: PlatformGuard): string | null { | |
| 288 | + | const paused = guard.levels.filter((l) => l.paused); | |
| 289 | + | if (paused.length === 0) return null; | |
| 290 | + | const names = paused.map((l) => (l.auto ? `${l.level} (by the usage watcher)` : l.level)); | |
| 291 | + | const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; | |
| 292 | + | return `Paused across g1t: ${list}.`; | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | /** `1.2M`, `240k`, `2.0B`: a count in a few characters. */ | |
| 296 | + | export function count(value: number): string { | |
| 297 | + | const n = Math.abs(value); | |
| 298 | + | if (n >= 1e9) return `${(value / 1e9).toFixed(1)}B`; | |
| 299 | + | if (n >= 1e6) return `${(value / 1e6).toFixed(1)}M`; | |
| 300 | + | if (n >= 1e3) return `${Math.round(value / 1e3)}k`; | |
| 301 | + | return `${Math.round(value)}`; | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /** An hour's value as a share of its threshold, rounded; null with no threshold. */ | |
| 305 | + | export function thresholdShare(value: number, threshold: number): number | null { | |
| 306 | + | return threshold > 0 ? Math.round((value / threshold) * 100) : null; | |
| 307 | + | } | |
| 7 | 7 | import { MobileBar, Sidebar } from "./components/shell"; | |
| 8 | 8 | import { ButtonLink } from "./components/ui"; | |
| 9 | 9 | import type { NavCounts } from "./lib/nav"; | |
| 10 | − | import { spendBanner } from "./lib/costs"; | |
| 10 | + | import { pauseBanner, spendBanner } from "./lib/costs"; | |
| 11 | 11 | import { admin, identity, statusAdmin } from "./lib/services.server"; | |
| 12 | 12 | import { settle } from "./lib/settle"; | |
| 13 | 13 | import { requireStaff, zoneContext } from "./lib/staff"; | |
| ⋯ | |||
| 27 | 27 | export async function loader({ context }: Route.LoaderArgs) { | |
| 28 | 28 | const { email } = requireStaff(context); | |
| 29 | 29 | // The sidebar's counts: a service that does not answer shows none. | |
| 30 | − | const [waitlist, incidents, alerts, caps] = await Promise.all([ | |
| 30 | + | const [waitlist, incidents, alerts, caps, guard] = await Promise.all([ | |
| 31 | 31 | settle(identity.waitlistPending()), | |
| 32 | 32 | settle(statusAdmin.openCount()), | |
| 33 | 33 | settle(admin.costAlerts()), | |
| 34 | 34 | settle(admin.spendCaps()), | |
| 35 | + | settle(admin.platformGuard()), | |
| 35 | 36 | ]); | |
| 36 | 37 | const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 }; | |
| 37 | 38 | // Every page says times in this zone (components/ui.tsx `When`). | |
| ⋯ | |||
| 44 | 45 | // g1t's own spend (billing's budget): the daily breaker open, or a comped | |
| 45 | 46 | // account's monthly budget used up. Red until it clears or staff act. | |
| 46 | 47 | const spend = caps.ok ? spendBanner(caps.value) : null; | |
| 47 | − | return { email, counts, zone, zoneChosen: chosen, margin, spend }; | |
| 48 | + | // A platform pause (billing's platform.rs): red on every page while any | |
| 49 | + | // level is paused, by staff or by the usage watcher. | |
| 50 | + | const paused = guard.ok ? pauseBanner(guard.value) : null; | |
| 51 | + | return { email, counts, zone, zoneChosen: chosen, margin, spend, paused }; | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | 54 | export function Layout({ children }: { children: React.ReactNode }) { | |
| ⋯ | |||
| 79 | 83 | </a> | |
| 80 | 84 | </div> | |
| 81 | 85 | )} | |
| 86 | + | {root?.paused && ( | |
| 87 | + | <div role="alert" className="border-b border-danger/40 bg-danger/12 px-4 py-2 text-sm text-danger"> | |
| 88 | + | <span className="font-medium">Platform pause:</span> {root.paused}{" "} | |
| 89 | + | <a href="/costs#platform" className="underline underline-offset-2"> | |
| 90 | + | Platform pause | |
| 91 | + | </a> | |
| 92 | + | </div> | |
| 93 | + | )} | |
| 82 | 94 | {children} | |
| 83 | 95 | </div> | |
| 84 | 96 | {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */} | |
| 1 | 1 | import type { ReactNode } from "react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { CostsReport } from "@g1t/contracts"; | |
| 4 | + | import type { CostsReport, PlatformGuard, PlatformMetric } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/costs"; | |
| 7 | 7 | import { DaysChart } from "~/components/costs"; | |
| 8 | 8 | import { CostsHeader, chip, costsHref } from "~/components/costs-header"; | |
| 9 | 9 | import { Badge, Button, Field, Input, Notice, Section, Stat, When } from "~/components/ui"; | |
| 10 | − | import { capPercent, daySeries, marginOnPrice, marginTone, parseBucket, percentLabel, spendRows, subscriptionsOver, whoPaid } from "~/lib/costs"; | |
| 10 | + | import { PAUSE_LEVELS, capPercent, count, daySeries, marginOnPrice, marginTone, parseBucket, percentLabel, spendRows, subscriptionsOver, thresholdShare, whoPaid } from "~/lib/costs"; | |
| 11 | 11 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; | |
| 12 | 12 | import { usd } from "~/lib/money"; | |
| 13 | 13 | ||
| ⋯ | |||
| 34 | 34 | <div className="mt-5"> | |
| 35 | 35 | <Notice tone="warn">Billing did not answer for costs: {error}</Notice> | |
| 36 | 36 | </div> | |
| 37 | + | <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} /> | |
| 37 | 38 | </main> | |
| 38 | 39 | ); | |
| 39 | 40 | } | |
| ⋯ | |||
| 50 | 51 | ||
| 51 | 52 | <SpendSection caps={report.caps} range={range} error={failed?.section === "lift" ? failed.error : null} /> | |
| 52 | 53 | ||
| 54 | + | <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} /> | |
| 55 | + | ||
| 53 | 56 | <Section | |
| 54 | 57 | className="mt-6" | |
| 55 | 58 | title={product ? `${product.title}, by day` : "By day"} | |
| ⋯ | |||
| 504 | 507 | ); | |
| 505 | 508 | } | |
| 506 | 509 | ||
| 510 | + | ||
| 511 | + | /** | |
| 512 | + | * The platform pause and the hourly usage watch (billing's platform.rs, | |
| 513 | + | * docs/SPEND-GUARDRAILS.md): four levels staff can pause across g1t, what | |
| 514 | + | * Cloudflare counted in the last hour against each threshold, and the | |
| 515 | + | * last day's breaches. | |
| 516 | + | */ | |
| 517 | + | function PlatformSection({ | |
| 518 | + | guard, | |
| 519 | + | unavailable, | |
| 520 | + | failed, | |
| 521 | + | }: { | |
| 522 | + | guard: PlatformGuard | null; | |
| 523 | + | unavailable: string | null; | |
| 524 | + | failed: CostsActionResult | null; | |
| 525 | + | }) { | |
| 526 | + | return ( | |
| 527 | + | <Section | |
| 528 | + | className="mt-6" | |
| 529 | + | id="platform" | |
| 530 | + | title="Platform pause" | |
| 531 | + | description="What Cloudflare counted for all of g1t, read at a quarter past each hour: Workers, D1, Queues, Durable Objects, KV and Artifacts, each against an hourly threshold (PLATFORM_HOURLY_* in billing's wrangler.jsonc). A breach emails staff once per metric every 6 hours. Five times a threshold pauses what that metric feeds, of the levels AUTO_PAUSE names. Any level can be paused here by hand; every service sees a change within 30 seconds." | |
| 532 | + | > | |
| 533 | + | {!guard ? ( | |
| 534 | + | <Notice tone="warn">Billing did not answer for the platform pause: {unavailable}</Notice> | |
| 535 | + | ) : ( | |
| 536 | + | <> | |
| 537 | + | {!guard.can_read && ( | |
| 538 | + | <div className="mb-4"> | |
| 539 | + | <Notice tone="warn">Billing has no Cloudflare token with Account Analytics Read, so the hourly watch reads nothing. The pause still works.</Notice> | |
| 540 | + | </div> | |
| 541 | + | )} | |
| 542 | + | {(guard.blind ?? []).length > 0 && ( | |
| 543 | + | <div className="mb-4"> | |
| 544 | + | <Notice tone="warn"> | |
| 545 | + | The watcher can't see: {(guard.blind ?? []).map((b) => b.key).join(", ")} | |
| 546 | + | {guard.last_run ? ` (the run for ${guard.last_run})` : ""}. Their metrics are not watched until it is fixed; three runs in a row email | |
| 547 | + | staff. | |
| 548 | + | <ul className="mt-2 space-y-1 text-xs"> | |
| 549 | + | {(guard.blind ?? []).map((b) => ( | |
| 550 | + | <li key={b.key}> | |
| 551 | + | <code>{b.dataset}</code> ({b.key}): {b.error} | |
| 552 | + | </li> | |
| 553 | + | ))} | |
| 554 | + | </ul> | |
| 555 | + | </Notice> | |
| 556 | + | </div> | |
| 557 | + | )} | |
| 558 | + | {guard.empty && ( | |
| 559 | + | <div className="mb-4"> | |
| 560 | + | <Notice tone="warn"> | |
| 561 | + | Every dataset answered with no rows{guard.last_run ? ` in the run for ${guard.last_run}` : ""}, the month so far included: likely the wrong | |
| 562 | + | account or a token that cannot see its analytics. The watcher sees nothing. | |
| 563 | + | </Notice> | |
| 564 | + | </div> | |
| 565 | + | )} | |
| 566 | + | {failed?.section === "platform" && ( | |
| 567 | + | <div className="mb-4"> | |
| 568 | + | <Notice tone="error">{failed.error}</Notice> | |
| 569 | + | </div> | |
| 570 | + | )} | |
| 571 | + | <div className="grid gap-3 lg:grid-cols-2"> | |
| 572 | + | {PAUSE_LEVELS.map(({ level, title, stops }) => { | |
| 573 | + | const state = guard.levels.find((l) => l.level === level); | |
| 574 | + | const paused = state?.paused ?? false; | |
| 575 | + | const error = failed?.section === `pause-${level}` ? failed.error : null; | |
| 576 | + | return ( | |
| 577 | + | <div key={level} className={`rounded-lg border p-4 ${paused ? "border-danger/50 bg-danger/8" : "border-line"}`}> | |
| 578 | + | <div className="flex items-center justify-between gap-2"> | |
| 579 | + | <span className="font-medium">{title}</span> | |
| 580 | + | {paused ? <Badge tone="danger">Paused</Badge> : <Badge tone="mint">Running</Badge>} | |
| 581 | + | </div> | |
| 582 | + | <p className="mt-1 text-xs text-muted">{stops}</p> | |
| 583 | + | {state?.set_at && ( | |
| 584 | + | <p className="mt-2 text-xs text-faint"> | |
| 585 | + | {paused ? "Paused" : "Resumed"} <When at={state.set_at} time /> by {state.auto ? "the usage watcher" : state.set_by} | |
| 586 | + | {state.note ? `: “${state.note}”` : ""} | |
| 587 | + | </p> | |
| 588 | + | )} | |
| 589 | + | <form method="post" action="#platform" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 590 | + | <input type="hidden" name="intent" value={paused ? "resume" : "pause"} /> | |
| 591 | + | <input type="hidden" name="level" value={level} /> | |
| 592 | + | <Field label={paused ? "Why resume it" : "Why pause it"} hint="Recorded in the audit log."> | |
| 593 | + | <Input name="note" required minLength={5} maxLength={500} placeholder={paused ? "e.g. Fixed the loop in search" : "e.g. KV lists runaway"} /> | |
| 594 | + | </Field> | |
| 595 | + | <Button type="submit" variant={paused ? "primary" : "danger"}> | |
| 596 | + | {paused ? "Resume" : "Pause"} | |
| 597 | + | </Button> | |
| 598 | + | </form> | |
| 599 | + | {error && ( | |
| 600 | + | <div className="mt-2"> | |
| 601 | + | <Notice tone="error">{error}</Notice> | |
| 602 | + | </div> | |
| 603 | + | )} | |
| 604 | + | </div> | |
| 605 | + | ); | |
| 606 | + | })} | |
| 607 | + | </div> | |
| 608 | + | <p className="mt-3 text-xs text-muted"> | |
| 609 | + | A severe breach may pause: {guard.auto_pause.length ? guard.auto_pause.join(", ") : "nothing (AUTO_PAUSE is empty)"}. | |
| 610 | + | </p> | |
| 611 | + | ||
| 612 | + | <h3 className="mt-6 text-sm font-medium">Breaches in the last 24 hours</h3> | |
| 613 | + | {guard.breaches.length === 0 ? ( | |
| 614 | + | <p className="mt-2 text-sm text-muted">None.</p> | |
| 615 | + | ) : ( | |
| 616 | + | <ul className="mt-2 space-y-2 text-sm"> | |
| 617 | + | {guard.breaches.map((b) => ( | |
| 618 | + | <li key={b.id} className="rounded-md border border-line px-3 py-2"> | |
| 619 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 620 | + | <Badge tone={b.severe ? "danger" : "warn"}>{b.severe ? "Severe" : b.rule === "spike" ? "Spike" : "Over threshold"}</Badge> | |
| 621 | + | <span className="text-xs text-faint"> | |
| 622 | + | <When at={b.opened_at} time /> | |
| 623 | + | {b.emailed_at ? ", emailed" : ""} | |
| 624 | + | </span> | |
| 625 | + | </div> | |
| 626 | + | <p className="mt-1 text-fg-soft">{b.detail}</p> | |
| 627 | + | </li> | |
| 628 | + | ))} | |
| 629 | + | </ul> | |
| 630 | + | )} | |
| 631 | + | ||
| 632 | + | <UsageTable title={guard.hour ? `The hour from ${guard.hour}` : "The last hour"} metrics={guard.last_hour} hourly /> | |
| 633 | + | <UsageTable title={`${guard.month}, so far`} metrics={guard.month_to_date} hourly={false} /> | |
| 634 | + | <p className="mt-3 text-xs text-muted"> | |
| 635 | + | Ids are Cloudflare's. <code>node scripts/ops/platform-usage.mjs</code> names them and shows the last 24 hours per script, queue, database and | |
| 636 | + | namespace. | |
| 637 | + | </p> | |
| 638 | + | </> | |
| 639 | + | )} | |
| 640 | + | </Section> | |
| 641 | + | ); | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | function UsageTable({ title, metrics, hourly }: { title: string; metrics: PlatformMetric[]; hourly: boolean }) { | |
| 645 | + | return ( | |
| 646 | + | <> | |
| 647 | + | <h3 className="mt-6 text-sm font-medium">{title}</h3> | |
| 648 | + | {metrics.length === 0 ? ( | |
| 649 | + | <p className="mt-2 text-sm text-muted">Nothing read yet.</p> | |
| 650 | + | ) : ( | |
| 651 | + | <div className="-mx-4 mt-2 overflow-x-auto sm:-mx-5"> | |
| 652 | + | <table className="w-full min-w-[36rem] text-sm"> | |
| 653 | + | <thead> | |
| 654 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 655 | + | <th className="px-4 py-2 font-medium sm:px-5">Metric</th> | |
| 656 | + | <th className="px-4 py-2 text-right font-medium">Count</th> | |
| 657 | + | {hourly && <th className="px-4 py-2 text-right font-medium">Of threshold</th>} | |
| 658 | + | <th className="px-4 py-2 font-medium sm:pr-5">Most from</th> | |
| 659 | + | </tr> | |
| 660 | + | </thead> | |
| 661 | + | <tbody> | |
| 662 | + | {metrics.map((m) => { | |
| 663 | + | const share = thresholdShare(m.value, m.threshold); | |
| 664 | + | return ( | |
| 665 | + | <tr key={m.metric} className="border-b border-line last:border-0"> | |
| 666 | + | <td className="px-4 py-2.5 sm:px-5">{m.title}</td> | |
| 667 | + | <td className="tabular px-4 py-2.5 text-right">{count(m.value)}</td> | |
| 668 | + | {hourly && ( | |
| 669 | + | <td className={`tabular px-4 py-2.5 text-right ${share != null && share > 100 ? "text-danger" : "text-muted"}`}> | |
| 670 | + | {share == null ? "—" : `${share}% of ${count(m.threshold)}`} | |
| 671 | + | </td> | |
| 672 | + | )} | |
| 673 | + | <td className="max-w-[16rem] truncate px-4 py-2.5 text-xs text-faint sm:pr-5"> | |
| 674 | + | {m.top_name ? `${m.top_name} (${count(m.top_value ?? 0)})` : "—"} | |
| 675 | + | </td> | |
| 676 | + | </tr> | |
| 677 | + | ); | |
| 678 | + | })} | |
| 679 | + | </tbody> | |
| 680 | + | </table> | |
| 681 | + | </div> | |
| 682 | + | )} | |
| 683 | + | </> | |
| 684 | + | ); | |
| 685 | + | } | |
| 42 | 42 | // The Access application needs a Service Auth policy including it. | |
| 43 | 43 | "STAFF_SERVICE_TOKENS": "434297ede60761875e84742d0486cf27.access=claude" | |
| 44 | 44 | }, | |
| 45 | − | "observability": { "enabled": true }, | |
| 45 | + | // Every log kept: staff only, few requests. | |
| 46 | + | "observability": { "enabled": true, "head_sampling_rate": 1 }, | |
| 46 | 47 | "upload_source_maps": true | |
| 47 | 48 | } |
| 1 | + | import { type ComponentProps, createContext, useContext } from "react"; | |
| 2 | + | ||
| 3 | + | /** True inside the app shell, whose `<main id="content">` already holds the page. */ | |
| 4 | + | export const InMain = createContext(false); | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * A page's main landmark: `<main>` where nothing above is one (signed out), | |
| 8 | + | * a plain `<div>` inside the shell, so a page never has two. | |
| 9 | + | */ | |
| 10 | + | export function PageMain(props: ComponentProps<"main">) { | |
| 11 | + | return useContext(InMain) ? <div {...props} /> : <main {...props} />; | |
| 12 | + | } |
| 5 | 5 | import { withNext } from "../lib/next"; | |
| 6 | 6 | import { useSignUpCopy } from "../lib/registration"; | |
| 7 | 7 | import { missingKind, notFoundCopy } from "../lib/not-found"; | |
| 8 | + | import { PageMain } from "./landmark"; | |
| 8 | 9 | import { Pixel404 } from "./logo"; | |
| 9 | 10 | import { ButtonLink, SubmitButton } from "./ui"; | |
| 10 | 11 | ||
| ⋯ | |||
| 29 | 30 | const here = pathname + search; | |
| 30 | 31 | const signUp = useSignUpCopy(); | |
| 31 | 32 | return ( | |
| 32 | − | <main className="mx-auto flex max-w-lg flex-col items-center px-4 py-20 text-center sm:py-28"> | |
| 33 | + | <PageMain className="mx-auto flex max-w-lg flex-col items-center px-4 py-20 text-center sm:py-28"> | |
| 33 | 34 | <Pixel404 className="text-[3.5rem] sm:text-[4.5rem]" /> | |
| 34 | 35 | <h1 className="mt-10 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1> | |
| 35 | 36 | <p className="mt-2 text-balance text-muted">{copy.body}</p> | |
| ⋯ | |||
| 82 | 83 | </SubmitButton> | |
| 83 | 84 | </Form> | |
| 84 | 85 | )} | |
| 85 | − | </main> | |
| 86 | + | </PageMain> | |
| 86 | 87 | ); | |
| 87 | 88 | } | |
| 1 | 1 | import { Check } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 2 | + | import { useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone"; | |
| 7 | 8 | import { SubmitButton, usePending } from "./ui"; | |
| 9 | + | import { Combobox } from "./ui/combobox"; | |
| 8 | 10 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | 11 | import { Input } from "./ui/input"; | |
| 10 | 12 | import { Textarea } from "./ui/textarea"; | |
| ⋯ | |||
| 26 | 28 | }) { | |
| 27 | 29 | const busy = usePending({ intent: "profile" }); | |
| 28 | 30 | const [bio, setBio] = useState(profile?.bio ?? ""); | |
| 31 | + | const [timezone, setTimezone] = useState(profile?.timezone ?? ""); | |
| 32 | + | // The browser's zone is only known once the page runs in it. | |
| 33 | + | const [browserZone, setBrowserZone] = useState<string | null>(null); | |
| 34 | + | useEffect(() => setBrowserZone(browserTimeZone()), []); | |
| 35 | + | const zones = useMemo(() => { | |
| 36 | + | const now = Date.now(); | |
| 37 | + | return [ | |
| 38 | + | { value: "", label: "Not shown" }, | |
| 39 | + | ...timeZoneNames(profile?.timezone).map((zone) => ({ | |
| 40 | + | value: zone, | |
| 41 | + | label: timeZoneLabel(zone), | |
| 42 | + | description: utcOffset(zone, now) ?? undefined, | |
| 43 | + | keywords: [zone], | |
| 44 | + | })), | |
| 45 | + | ]; | |
| 46 | + | }, [profile?.timezone]); | |
| 29 | 47 | return ( | |
| 30 | 48 | <section id="profile" className="scroll-mt-20"> | |
| 31 | 49 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| ⋯ | |||
| 97 | 115 | /> | |
| 98 | 116 | <FieldDescription>An https:// address.</FieldDescription> | |
| 99 | 117 | </Field> | |
| 118 | + | <Field> | |
| 119 | + | <FieldLabel htmlFor="profile-timezone">Time zone</FieldLabel> | |
| 120 | + | <Combobox | |
| 121 | + | id="profile-timezone" | |
| 122 | + | name="timezone" | |
| 123 | + | value={timezone} | |
| 124 | + | onValueChange={setTimezone} | |
| 125 | + | options={zones} | |
| 126 | + | placeholder="Not shown" | |
| 127 | + | searchPlaceholder="Find a city or region" | |
| 128 | + | emptyText="No time zone by that name." | |
| 129 | + | /> | |
| 130 | + | <FieldDescription> | |
| 131 | + | The card over your name shows your local time. | |
| 132 | + | {browserZone && browserZone !== timezone && ( | |
| 133 | + | <> | |
| 134 | + | {" "} | |
| 135 | + | <button | |
| 136 | + | type="button" | |
| 137 | + | onClick={() => setTimezone(browserZone)} | |
| 138 | + | className="text-accent underline-offset-4 hover:underline" | |
| 139 | + | > | |
| 140 | + | Use my browser's time zone ({timeZoneLabel(browserZone)}) | |
| 141 | + | </button> | |
| 142 | + | </> | |
| 143 | + | )} | |
| 144 | + | </FieldDescription> | |
| 145 | + | </Field> | |
| 100 | 146 | <div className="flex flex-wrap items-center gap-3 sm:col-span-2"> | |
| 101 | 147 | <SubmitButton pending="Saving…" match={{ intent: "profile" }}> | |
| 102 | 148 | Save profile | |
| 4 | 4 | ||
| 5 | 5 | import { type Abilities, type InboxCounts, type Membership, type Spike, type User, mayCreateTeams } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { InMain } from "./landmark"; | |
| 7 | 8 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| 8 | 9 | import { AgentButton, InboxBell } from "./inbox"; | |
| 9 | 10 | import { PinButton } from "./pin-button"; | |
| ⋯ | |||
| 1920 | 1921 | </header> | |
| 1921 | 1922 | {banner} | |
| 1922 | 1923 | <main id="content" tabIndex={-1} {...leaving} className={`min-w-0 grow outline-none ${leaving.className}`}> | |
| 1923 | − | {children} | |
| 1924 | + | <InMain.Provider value={true}>{children}</InMain.Provider> | |
| 1924 | 1925 | </main> | |
| 1925 | 1926 | </div> | |
| 1926 | 1927 | <CommandPalette | |
| 1 | − | import { Building2, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 1 | + | import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 2 | 2 | import { type ReactElement, type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Link, useParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard"; | |
| 6 | 6 | import { G1T_MENTION_HREF } from "../lib/markdown-plugins"; | |
| 7 | + | import { localTime } from "../lib/time-zone"; | |
| 7 | 8 | import { Avatar } from "./ui"; | |
| 8 | 9 | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; | |
| 9 | 10 | import { Skeleton } from "./ui/skeleton"; | |
| ⋯ | |||
| 86 | 87 | ||
| 87 | 88 | function PersonCard({ card }: { card: UserCardData }) { | |
| 88 | 89 | const profile = `/u/${card.username}`; | |
| 90 | + | // Cards are only drawn in the browser, so this is the viewer's clock. | |
| 91 | + | const time = localTime(card.timezone, Date.now()); | |
| 89 | 92 | return ( | |
| 90 | 93 | <div className="space-y-3"> | |
| 91 | 94 | <div className="flex items-start gap-3"> | |
| ⋯ | |||
| 107 | 110 | </div> | |
| 108 | 111 | </div> | |
| 109 | 112 | {card.bio && <p className="leading-relaxed text-fg/90 wrap-anywhere">{card.bio}</p>} | |
| 110 | − | {(card.location || card.workspaces.length > 0 || card.committed) && ( | |
| 113 | + | {(card.location || time || card.workspaces.length > 0 || card.committed) && ( | |
| 111 | 114 | <ul className="space-y-1.5 text-[0.8125rem] text-muted"> | |
| 112 | 115 | {card.location && ( | |
| 113 | 116 | <Line icon={<MapPin size={14} />}> | |
| 114 | 117 | <span className="wrap-anywhere">{card.location}</span> | |
| 115 | 118 | </Line> | |
| 116 | 119 | )} | |
| 120 | + | {time && <Line icon={<Clock size={14} />}>{time} local time</Line>} | |
| 117 | 121 | {card.workspaces.length > 0 && ( | |
| 118 | 122 | <Line icon={<Building2 size={14} />}> | |
| 119 | 123 | Member of{" "} | |
| 10 | 10 | | --- | --- | | |
| 11 | 11 | | Username and email address | To identify you, sign you in, and reach you about your account. Your username is public; your email address is not. | | |
| 12 | 12 | | Password | To sign you in. We store only a salted hash of it (PBKDF2-SHA256), never the password itself. | | |
| 13 | − | | Profile: name, bio, location, website and pronouns, if you add them | Shown on your public profile. All optional. | | |
| 13 | + | | Profile: name, bio, location, website, pronouns and time zone, if you add them | Shown on your public profile, and your local time on the card over your name. All optional. | | |
| 14 | 14 | | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. | | |
| 15 | 15 | | Sessions, access tokens, SSH keys, and apps you've approved through sign-in with g1t | To keep you signed in and let your tools act for you. Session and token secrets are stored only as hashes. | | |
| 16 | 16 | | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. | |
| 2 | 2 | ||
| 3 | 3 | import type { RepoPath, Viewer } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | + | import { LEGACY_KV_UNTIL } from "./artifacts"; | |
| 5 | 6 | import { actions } from "./services.server"; | |
| 6 | 7 | ||
| 7 | 8 | /** | |
| 8 | 9 | * A run's artifacts, for its page. The actions service lists them (their | |
| 9 | 10 | * bytes are in R2, downloaded through the API's signed links); artifacts an | |
| 10 | 11 | * older runner kept in KV (`a/{run}/{name}`, its bytes in chunks `…#0`, | |
| 11 | − | * `…#1`) are listed too until KV expires them. | |
| 12 | + | * `…#1`) are listed too until KV expires them, for runs the caller has | |
| 13 | + | * already been shown (`legacyArtifactsWorthAsking` in artifacts.ts). | |
| 12 | 14 | */ | |
| 13 | 15 | export type ArtifactRow = { | |
| 14 | 16 | /** The artifact's number; null for one kept in KV. */ | |
| ⋯ | |||
| 35 | 37 | })); | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 40 | + | /** A run's artifacts the actions service keeps, which checks who may see them. */ | |
| 38 | 41 | export async function listArtifacts(repo: RepoPath, viewer: Viewer, run: string): Promise<ArtifactRow[]> { | |
| 39 | − | const [kept, legacy] = await Promise.all([ | |
| 40 | − | actions.artifacts(repo, viewer, { run, per_page: 100 }), | |
| 41 | − | kvArtifacts(run).catch(() => []), | |
| 42 | − | ]); | |
| 42 | + | const kept = await actions.artifacts(repo, viewer, { run, per_page: 100 }); | |
| 43 | 43 | const rows: ArtifactRow[] = kept.ok | |
| 44 | 44 | ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at })) | |
| 45 | 45 | : []; | |
| 46 | + | return rows.sort((a, b) => a.name.localeCompare(b.name)); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** | |
| 50 | + | * `rows` with the artifacts an older runner kept in KV for `run` added. | |
| 51 | + | * Only for a run the viewer was allowed to see. Delete after | |
| 52 | + | * 2026-10-22T00:00Z (`LEGACY_KV_UNTIL`). | |
| 53 | + | */ | |
| 54 | + | export async function withLegacyArtifacts(rows: ArtifactRow[], run: string): Promise<ArtifactRow[]> { | |
| 55 | + | if (Date.now() >= LEGACY_KV_UNTIL) return rows; | |
| 56 | + | const legacy = await kvArtifacts(run).catch(() => []); | |
| 57 | + | const all = [...rows]; | |
| 46 | 58 | for (const row of legacy) { | |
| 47 | − | if (!rows.some((r) => r.name === row.name)) rows.push(row); | |
| 59 | + | if (!all.some((r) => r.name === row.name)) all.push(row); | |
| 48 | 60 | } | |
| 49 | − | return rows.sort((a, b) => a.name.localeCompare(b.name)); | |
| 61 | + | return all.sort((a, b) => a.name.localeCompare(b.name)); | |
| 50 | 62 | } | |
| 51 | 63 | ||
| 52 | 64 | export async function readArtifact(run: string, name: string): Promise<Uint8Array | null> { | |
| 65 | + | // Every artifact kept in KV has expired (artifacts.ts). | |
| 66 | + | if (Date.now() >= LEGACY_KV_UNTIL) return null; | |
| 53 | 67 | const base = `a/${run}/${name}`; | |
| 54 | 68 | const meta = await env.BLOBS.get<Meta>(base, "json"); | |
| 55 | 69 | if (!meta) return null; | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { expiresIn, formatBytes } from "./artifacts.ts"; | |
| 4 | + | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "./artifacts.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("sizes read as KB, MB or GB", () => { | |
| 7 | 7 | assert.equal(formatBytes(10), "1 KB"); | |
| ⋯ | |||
| 18 | 18 | assert.equal(expiresIn("2026-10-08T11:00:00Z", now), "expired"); | |
| 19 | 19 | assert.equal(expiresIn(null, now), ""); | |
| 20 | 20 | }); | |
| 21 | + | ||
| 22 | + | test("KV is asked for an old, finished run's artifacts only until they have expired", () => { | |
| 23 | + | const old = { createdAt: "2026-10-07T12:00:00Z", status: "completed" }; | |
| 24 | + | const now = Date.parse("2026-10-10T00:00:00Z"); | |
| 25 | + | assert.ok(legacyArtifactsWorthAsking(old, now)); | |
| 26 | + | // Still going: its artifacts are in R2, and its page refreshes. | |
| 27 | + | assert.ok(!legacyArtifactsWorthAsking({ ...old, status: "in_progress" }, now)); | |
| 28 | + | // Made after the move to R2. | |
| 29 | + | assert.ok(!legacyArtifactsWorthAsking({ ...old, createdAt: "2026-10-09T08:00:00Z" }, now)); | |
| 30 | + | // Every KV artifact has expired. | |
| 31 | + | assert.ok(!legacyArtifactsWorthAsking(old, Date.parse("2026-10-22T00:00:00Z"))); | |
| 32 | + | }); | |
| 16 | 16 | if (days === 1) return "expires tomorrow"; | |
| 17 | 17 | return `expires in ${days} days`; | |
| 18 | 18 | } | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * Artifacts older runners kept in Workers KV. None has been made there | |
| 22 | + | * since artifacts moved to R2 on 2026-10-08, and KV expires each 14 days | |
| 23 | + | * after it was made: from 2026-10-22T00:00Z every one is gone. Delete the | |
| 24 | + | * KV artifact code (here, artifacts.server.ts, and apps/api/src/blobs.rs) | |
| 25 | + | * after that date. | |
| 26 | + | */ | |
| 27 | + | export const LEGACY_KV_UNTIL = Date.parse("2026-10-22T00:00:00Z"); | |
| 28 | + | /** Runs made from this time on kept their artifacts in R2 only. */ | |
| 29 | + | export const LEGACY_KV_BEFORE = Date.parse("2026-10-09T00:00:00Z"); | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Whether a run's page asks KV for artifacts an older runner kept there: | |
| 33 | + | * only for a finished run made before the move, and only until they have | |
| 34 | + | * all expired. A KV list is the dearest thing KV does, and a run still | |
| 35 | + | * going refreshes its page every few seconds. | |
| 36 | + | */ | |
| 37 | + | export function legacyArtifactsWorthAsking(run: { createdAt: string; status: string }, now: number = Date.now()): boolean { | |
| 38 | + | return now < LEGACY_KV_UNTIL && run.status === "completed" && Date.parse(run.createdAt) < LEGACY_KV_BEFORE; | |
| 39 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync } from "node:fs"; | |
| 3 | + | import { test } from "node:test"; | |
| 4 | + | ||
| 5 | + | import { | |
| 6 | + | LIMIT_PERIOD_SECONDS, | |
| 7 | + | RATE_LIMITS, | |
| 8 | + | type RateLimitBinding, | |
| 9 | + | checkLimit, | |
| 10 | + | isLimited, | |
| 11 | + | secretKey, | |
| 12 | + | } from "@g1t/contracts/rate-limits"; | |
| 13 | + | ||
| 14 | + | import { gitLimited, heavy, pageLimited, sessionCookie, unlimited } from "./front-door-limits.ts"; | |
| 15 | + | ||
| 16 | + | /** A binding that lets `allow` requests through per key, recording each key it was asked. */ | |
| 17 | + | function binding(allow: number): RateLimitBinding & { keys: string[] } { | |
| 18 | + | const counts = new Map<string, number>(); | |
| 19 | + | const keys: string[] = []; | |
| 20 | + | return { | |
| 21 | + | keys, | |
| 22 | + | async limit({ key }) { | |
| 23 | + | keys.push(key); | |
| 24 | + | const count = (counts.get(key) ?? 0) + 1; | |
| 25 | + | counts.set(key, count); | |
| 26 | + | return { success: count <= allow }; | |
| 27 | + | }, | |
| 28 | + | }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const broken: RateLimitBinding = { | |
| 32 | + | async limit() { | |
| 33 | + | throw new Error("the binding is down"); | |
| 34 | + | }, | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | function request(path: string, headers: Record<string, string> = {}): Request { | |
| 38 | + | return new Request(`https://g1t.sh${path}`, { headers: { "cf-connecting-ip": "203.0.113.9", ...headers } }); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | test("a limit lets requests through until it is reached", async () => { | |
| 42 | + | const limit = binding(2); | |
| 43 | + | assert.equal(await checkLimit(limit, "ip:1"), "allowed"); | |
| 44 | + | assert.equal(await checkLimit(limit, "ip:1"), "allowed"); | |
| 45 | + | assert.equal(await checkLimit(limit, "ip:1"), "limited"); | |
| 46 | + | assert.equal(await checkLimit(limit, "ip:2"), "allowed", "each key counts apart"); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("a missing or failing binding fails open", async () => { | |
| 50 | + | const logged = console.error; | |
| 51 | + | console.error = () => {}; | |
| 52 | + | try { | |
| 53 | + | assert.equal(await checkLimit(undefined, "ip:1"), "unavailable"); | |
| 54 | + | assert.equal(await checkLimit(broken, "ip:1"), "unavailable"); | |
| 55 | + | assert.equal(await isLimited(broken, "ip:1"), false); | |
| 56 | + | assert.equal(await gitLimited({ GIT_ANONYMOUS_LIMIT: broken }, request("/acme/rocket.git/info/refs")), null); | |
| 57 | + | assert.equal(await pageLimited({ WEB_ADDRESS_LIMIT: broken, WEB_ANONYMOUS_LIMIT: broken }, request("/acme/rocket"), "/acme/rocket"), null); | |
| 58 | + | } finally { | |
| 59 | + | console.error = logged; | |
| 60 | + | } | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("secrets are keyed by a short hash, never as they are", async () => { | |
| 64 | + | const key = await secretKey("session", "s3cret-session"); | |
| 65 | + | assert.match(key, /^session:[0-9a-f]{16}$/); | |
| 66 | + | assert.equal(await secretKey("session", "s3cret-session"), key); | |
| 67 | + | assert.notEqual(await secretKey("session", "another"), key); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("git without credentials is limited by address, with a message git shows", async () => { | |
| 71 | + | const env = { GIT_ANONYMOUS_LIMIT: binding(1), GIT_SIGNED_LIMIT: binding(1) }; | |
| 72 | + | const clone = () => request("/acme/rocket.git/info/refs"); | |
| 73 | + | assert.equal(await gitLimited(env, clone()), null); | |
| 74 | + | const refused = await gitLimited(env, clone()); | |
| 75 | + | assert.equal(refused?.status, 429); | |
| 76 | + | assert.equal(refused?.headers.get("retry-after"), String(LIMIT_PERIOD_SECONDS)); | |
| 77 | + | assert.match(refused?.headers.get("content-type") ?? "", /^text\/plain/); | |
| 78 | + | assert.match((await refused?.text()) ?? "", /Too many git requests/); | |
| 79 | + | assert.deepEqual(env.GIT_ANONYMOUS_LIMIT.keys, ["ip:203.0.113.9", "ip:203.0.113.9"]); | |
| 80 | + | }); | |
| 81 | + | ||
| 82 | + | test("git with credentials counts by a hash of them, apart from the address", async () => { | |
| 83 | + | const env = { GIT_ANONYMOUS_LIMIT: binding(0), GIT_SIGNED_LIMIT: binding(5) }; | |
| 84 | + | const authorization = `Basic ${btoa("ada:g1t_token")}`; | |
| 85 | + | assert.equal(await gitLimited(env, request("/acme/rocket.git/git-upload-pack", { authorization })), null); | |
| 86 | + | assert.equal(env.GIT_ANONYMOUS_LIMIT.keys.length, 0); | |
| 87 | + | assert.match(env.GIT_SIGNED_LIMIT.keys[0]!, /^git:[0-9a-f]{16}$/); | |
| 88 | + | assert.ok(!env.GIT_SIGNED_LIMIT.keys[0]!.includes("g1t_token")); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("signed-out pages count by address, and costly ones against a tighter limit too", async () => { | |
| 92 | + | const env = { WEB_ADDRESS_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(100), WEB_HEAVY_LIMIT: binding(1) }; | |
| 93 | + | assert.equal(await pageLimited(env, request("/acme/rocket"), "/acme/rocket"), null); | |
| 94 | + | assert.equal(env.WEB_HEAVY_LIMIT.keys.length, 0); | |
| 95 | + | const archive = "/acme/rocket/archive/main.zip"; | |
| 96 | + | assert.equal(await pageLimited(env, request(archive), archive), null); | |
| 97 | + | const refused = await pageLimited(env, request(archive), archive); | |
| 98 | + | assert.equal(refused?.status, 429); | |
| 99 | + | assert.match((await refused?.text()) ?? "", /Signed-in accounts have a higher limit/); | |
| 100 | + | assert.equal(await pageLimited(env, request("/acme/rocket/issues"), "/acme/rocket/issues"), null, "other pages go on"); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | test("signed-in requests count by session, and every request by address", async () => { | |
| 104 | + | const env = { WEB_ADDRESS_LIMIT: binding(1), WEB_SESSION_LIMIT: binding(100), WEB_ANONYMOUS_LIMIT: binding(0) }; | |
| 105 | + | const signedIn = () => request("/acme/rocket/archive/main.zip", { cookie: "theme=dark; g1t_session=abc123" }); | |
| 106 | + | assert.equal(await pageLimited(env, signedIn(), "/acme/rocket/archive/main.zip"), null); | |
| 107 | + | assert.equal(env.WEB_ANONYMOUS_LIMIT.keys.length, 0); | |
| 108 | + | assert.match(env.WEB_SESSION_LIMIT.keys[0]!, /^session:[0-9a-f]{16}$/); | |
| 109 | + | // Made-up cookies still meet the ceiling per address. | |
| 110 | + | const refused = await pageLimited(env, request("/", { cookie: "g1t_session=made-up" }), "/"); | |
| 111 | + | assert.equal(refused?.status, 429); | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | test("files the Worker serves itself are never limited", async () => { | |
| 115 | + | const env = { WEB_ADDRESS_LIMIT: binding(0), WEB_ANONYMOUS_LIMIT: binding(0) }; | |
| 116 | + | for (const path of ["/assets/app-1a2b.js", "/fonts/hanken.woff2", "/favicon.ico", "/robots.txt", "/llms.txt", "/sitemap.xml"]) { | |
| 117 | + | assert.ok(unlimited(path), path); | |
| 118 | + | assert.equal(await pageLimited(env, request(path), path), null, path); | |
| 119 | + | } | |
| 120 | + | assert.ok(!unlimited("/acme/rocket/blob/main/logo.png"), "a file in a repository is a page"); | |
| 121 | + | }); | |
| 122 | + | ||
| 123 | + | test("costly pages are recognised", () => { | |
| 124 | + | for (const path of [ | |
| 125 | + | "/search", | |
| 126 | + | "/search.data", | |
| 127 | + | "/acme/rocket/archive/main.zip", | |
| 128 | + | "/acme/rocket/actions/runs/run_1", | |
| 129 | + | "/acme/rocket/actions/runs/run_1.data", | |
| 130 | + | "/acme/rocket/actions/runs/run_1/logs.zip", | |
| 131 | + | "/acme/rocket/actions/runs/run_1/artifacts/dist", | |
| 132 | + | "/acme/rocket/actions/jobs/job_1/log.txt", | |
| 133 | + | ]) { | |
| 134 | + | assert.ok(heavy(path), path); | |
| 135 | + | } | |
| 136 | + | for (const path of ["/", "/acme/rocket", "/acme/rocket/actions", "/acme/rocket/issues/1", "/acme/search"]) { | |
| 137 | + | assert.ok(!heavy(path), path); | |
| 138 | + | } | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("the session cookie is read from among others", () => { | |
| 142 | + | assert.equal(sessionCookie("theme=dark; g1t_session=abc; x=1"), "abc"); | |
| 143 | + | assert.equal(sessionCookie("g1t_session=abc"), "abc"); | |
| 144 | + | assert.equal(sessionCookie("not_g1t_session=abc"), null); | |
| 145 | + | assert.equal(sessionCookie(null), null); | |
| 146 | + | }); | |
| 147 | + | ||
| 148 | + | /** A wrangler.jsonc as JSON: comments and trailing commas out, strings kept. */ | |
| 149 | + | function readJsonc(path: string): { ratelimits?: { name: string; namespace_id: string; simple: { limit: number; period: number } }[] } { | |
| 150 | + | const text = readFileSync(new URL(path, import.meta.url), "utf8") | |
| 151 | + | .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, string: string | undefined) => string ?? "") | |
| 152 | + | .replace(/,(\s*[}\]])/g, "$1"); | |
| 153 | + | return JSON.parse(text); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | test("every wrangler.jsonc declares the rate limits RATE_LIMITS lists, and only those", () => { | |
| 157 | + | const ids = new Set<number>(); | |
| 158 | + | const workers = new Set(Object.values(RATE_LIMITS).map((spec) => spec.worker)); | |
| 159 | + | for (const worker of workers) { | |
| 160 | + | const declared = readJsonc(`../../../../${worker}/wrangler.jsonc`).ratelimits ?? []; | |
| 161 | + | const listed = Object.entries(RATE_LIMITS).filter(([, spec]) => spec.worker === worker); | |
| 162 | + | assert.deepEqual( | |
| 163 | + | declared.map((binding) => binding.name).sort(), | |
| 164 | + | listed.map(([name]) => name).sort(), | |
| 165 | + | `${worker}'s bindings`, | |
| 166 | + | ); | |
| 167 | + | for (const [name, spec] of listed) { | |
| 168 | + | const binding = declared.find((b) => b.name === name)!; | |
| 169 | + | assert.equal(Number(binding.namespace_id), spec.namespaceId, `${name}'s namespace id`); | |
| 170 | + | assert.equal(binding.simple.limit, spec.limit, `${name}'s limit`); | |
| 171 | + | assert.equal(binding.simple.period, LIMIT_PERIOD_SECONDS, `${name}'s period`); | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | for (const spec of Object.values(RATE_LIMITS)) { | |
| 175 | + | assert.ok(!ids.has(spec.namespaceId), `namespace id ${spec.namespaceId} is used once`); | |
| 176 | + | ids.add(spec.namespaceId); | |
| 177 | + | } | |
| 178 | + | }); |
| 1 | + | /** | |
| 2 | + | * The front door's rate limits (workers/app.ts), per request before it is | |
| 3 | + | * answered. The bindings and their limits are in `RATE_LIMITS` | |
| 4 | + | * (packages/contracts/src/rate-limits.ts); docs/RATE-LIMITS.md says why. | |
| 5 | + | * | |
| 6 | + | * - Git over HTTPS: without credentials, by address; with them, by a hash | |
| 7 | + | * of the credential, much higher. A clone is about three requests. | |
| 8 | + | * - Pages: every request that reaches the Worker counts against a ceiling | |
| 9 | + | * per address. Signed out, by address, with a tighter limit on what is | |
| 10 | + | * costly to answer (archives, run pages, logs, search). Signed in, by a | |
| 11 | + | * hash of the session cookie, higher: the session is not checked here, | |
| 12 | + | * which would cost a call to identity, and the ceiling per address keeps | |
| 13 | + | * made-up cookies from getting round the signed-out limit. | |
| 14 | + | * | |
| 15 | + | * Static assets never reach the Worker (the assets binding answers them), | |
| 16 | + | * and the few files it serves itself are left out here too. Every limit | |
| 17 | + | * fails open. | |
| 18 | + | */ | |
| 19 | + | import { | |
| 20 | + | type RateLimitBinding, | |
| 21 | + | checkLimit, | |
| 22 | + | clientAddress, | |
| 23 | + | secretKey, | |
| 24 | + | tooManyRequests, | |
| 25 | + | } from "@g1t/contracts/rate-limits"; | |
| 26 | + | ||
| 27 | + | export type FrontDoorLimits = { | |
| 28 | + | WEB_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 29 | + | WEB_HEAVY_LIMIT?: RateLimitBinding; | |
| 30 | + | WEB_SESSION_LIMIT?: RateLimitBinding; | |
| 31 | + | WEB_ADDRESS_LIMIT?: RateLimitBinding; | |
| 32 | + | GIT_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 33 | + | GIT_SIGNED_LIMIT?: RateLimitBinding; | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | /** Files the Worker serves that are never limited: build output, fonts, and top-level files such as robots.txt. */ | |
| 37 | + | const UNLIMITED = /^\/(?:assets\/|fonts\/|favicon|[^/]+\.(?:ico|png|svg|txt|xml|webmanifest)$)/; | |
| 38 | + | ||
| 39 | + | /** What is costly to answer for a signed-out visitor: a repository's archives, a run's page, logs and artifacts, and search. */ | |
| 40 | + | const HEAVY = | |
| 41 | + | /^\/(?:search(?:\.data)?$|[^/]+\/[^/]+\/(?:archive\/|actions\/runs\/[^/]+(?:\.data|\/logs\.zip|\/artifacts\/[^/]+)?$|actions\/jobs\/[^/]+\/log))/; | |
| 42 | + | ||
| 43 | + | export function unlimited(pathname: string): boolean { | |
| 44 | + | return UNLIMITED.test(pathname); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export function heavy(pathname: string): boolean { | |
| 48 | + | return HEAVY.test(pathname); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /** The session cookie's value, or null when signed out. */ | |
| 52 | + | export function sessionCookie(cookie: string | null): string | null { | |
| 53 | + | const match = /(?:^|;\s*)g1t_session=([^;]+)/.exec(cookie ?? ""); | |
| 54 | + | return match?.[1] ?? null; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | const GIT_MESSAGE_ANONYMOUS = | |
| 58 | + | "Too many git requests from your network. Wait a minute and try again, or use credentials for a higher limit: https://docs.g1t.sh/reference/rate-limits/\n"; | |
| 59 | + | const GIT_MESSAGE_SIGNED = "Too many git requests with these credentials. Wait a minute and try again: https://docs.g1t.sh/reference/rate-limits/\n"; | |
| 60 | + | const PAGE_MESSAGE = "Too many requests from your network. Wait a minute and try again.\n"; | |
| 61 | + | ||
| 62 | + | /** | |
| 63 | + | * The 429 for a git request past its limit, or null to go on. Git shows a | |
| 64 | + | * plain-text answer's body to the person running it. | |
| 65 | + | */ | |
| 66 | + | export async function gitLimited(env: FrontDoorLimits, request: Request): Promise<Response | null> { | |
| 67 | + | const credentials = request.headers.get("authorization"); | |
| 68 | + | if (credentials) { | |
| 69 | + | const verdict = await checkLimit(env.GIT_SIGNED_LIMIT, await secretKey("git", credentials)); | |
| 70 | + | return verdict === "limited" ? tooManyRequests(GIT_MESSAGE_SIGNED) : null; | |
| 71 | + | } | |
| 72 | + | const verdict = await checkLimit(env.GIT_ANONYMOUS_LIMIT, `ip:${clientAddress(request)}`); | |
| 73 | + | return verdict === "limited" ? tooManyRequests(GIT_MESSAGE_ANONYMOUS) : null; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** The 429 for a page or data request past its limit, or null to go on. */ | |
| 77 | + | export async function pageLimited(env: FrontDoorLimits, request: Request, pathname: string): Promise<Response | null> { | |
| 78 | + | if (unlimited(pathname)) return null; | |
| 79 | + | const address = `ip:${clientAddress(request)}`; | |
| 80 | + | const session = sessionCookie(request.headers.get("cookie")); | |
| 81 | + | const checks: Promise<string>[] = [checkLimit(env.WEB_ADDRESS_LIMIT, address)]; | |
| 82 | + | if (session) { | |
| 83 | + | checks.push(secretKey("session", session).then((key) => checkLimit(env.WEB_SESSION_LIMIT, key))); | |
| 84 | + | } else { | |
| 85 | + | checks.push(checkLimit(env.WEB_ANONYMOUS_LIMIT, address)); | |
| 86 | + | if (heavy(pathname)) checks.push(checkLimit(env.WEB_HEAVY_LIMIT, address)); | |
| 87 | + | } | |
| 88 | + | const verdicts = await Promise.all(checks); | |
| 89 | + | if (!verdicts.includes("limited")) return null; | |
| 90 | + | return tooManyRequests(session ? PAGE_MESSAGE : `${PAGE_MESSAGE.trimEnd()} Signed-in accounts have a higher limit.\n`); | |
| 91 | + | } |
| 15 | 15 | location: "London", | |
| 16 | 16 | website: "https://ada.example", | |
| 17 | 17 | pronouns: "she/her", | |
| 18 | + | timezone: "Europe/London", | |
| 18 | 19 | avatar: "cafe", | |
| 19 | 20 | createdAt: "2026-01-01T00:00:00Z", | |
| 20 | 21 | }; | |
| ⋯ | |||
| 97 | 98 | assert.equal(hidden?.kind === "user" && hidden.committed, null); | |
| 98 | 99 | }); | |
| 99 | 100 | ||
| 101 | + | test("the card carries the time zone a profile gives, and none when it gives none", async () => { | |
| 102 | + | const card = await buildCard("ada", me, null, sources(), NOW); | |
| 103 | + | assert.equal(card?.kind === "user" && card.timezone, "Europe/London"); | |
| 104 | + | const without = await buildCard("ada", me, null, sources({ profile: async () => ({ ...ada, timezone: null }) }), NOW); | |
| 105 | + | assert.equal(without?.kind === "user" && without.timezone, null); | |
| 106 | + | }); | |
| 107 | + | ||
| 100 | 108 | test("a failing service leaves its part out, not the card", async () => { | |
| 101 | 109 | const card = await buildCard( | |
| 102 | 110 | "ada", | |
| 20 | 20 | pronouns: string | null; | |
| 21 | 21 | bio: string | null; | |
| 22 | 22 | location: string | null; | |
| 23 | + | /** The IANA time zone they gave, such as `America/Denver`; the card shows their local time from it. */ | |
| 24 | + | timezone: string | null; | |
| 23 | 25 | avatar: string | null; | |
| 24 | 26 | /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */ | |
| 25 | 27 | workspaces: { slug: string; name: string; avatar: string | null }[]; | |
| ⋯ | |||
| 112 | 114 | pronouns: profile.pronouns, | |
| 113 | 115 | bio: profile.bio, | |
| 114 | 116 | location: profile.location, | |
| 117 | + | timezone: profile.timezone ?? null, | |
| 115 | 118 | avatar: profile.avatar, | |
| 116 | 119 | workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })), | |
| 117 | 120 | more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES), | |
| 3 | 3 | * answer not yet kept walks history, which can take seconds on a large or | |
| 4 | 4 | * busy repository; the page goes out without it then, the column empty, | |
| 5 | 5 | * while the walk finishes in the background (waitUntil) so the next view | |
| 6 | − | * has it from the cache. The page's stream never waits on it past its own | |
| 7 | − | * timeout. | |
| 6 | + | * has it from the cache. A history too long for one walk is read over | |
| 7 | + | * several views: the repos service keeps each walk's progress and goes on | |
| 8 | + | * from it (services/repos/src/last_commits.rs). The page's stream never | |
| 9 | + | * waits on it past its own timeout. | |
| 8 | 10 | */ | |
| 9 | 11 | import { waitUntil } from "cloudflare:workers"; | |
| 10 | 12 |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { distinctFacts } from "./memory-facts.ts"; | |
| 4 | + | import { distinctFacts, sameFact } from "./memory-facts.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("a fact remembered twice is shown once, the first kept", () => { | |
| 7 | 7 | const facts = [ | |
| ⋯ | |||
| 13 | 13 | assert.deepEqual(distinctFacts(facts).map((fact) => fact.id), ["a", "b"]); | |
| 14 | 14 | assert.deepEqual(distinctFacts([]), []); | |
| 15 | 15 | }); | |
| 16 | + | ||
| 17 | + | test("near-duplicates collapse: a list that grew, a sentence cut short", () => { | |
| 18 | + | const facts = [ | |
| 19 | + | { id: "a", text: "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing); `cargo test` runs its tests." }, | |
| 20 | + | { id: "b", text: "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing, services/work); `cargo test` runs its tests." }, | |
| 21 | + | { id: "c", text: "Roles. Viewer, commenter, planner and approver map onto" }, | |
| 22 | + | { id: "d", text: "Roles. Viewer, commenter, planner and approver map onto the five repository roles." }, | |
| 23 | + | { id: "e", text: "Use npm to install." }, | |
| 24 | + | { id: "f", text: "Use pnpm to install." }, | |
| 25 | + | ]; | |
| 26 | + | assert.deepEqual(distinctFacts(facts).map((fact) => fact.id), ["a", "c", "e", "f"]); | |
| 27 | + | assert.ok(!sameFact("Run cargo test in the crate you changed.", "Run npm test in the app you changed.")); | |
| 28 | + | assert.ok(!sameFact("use pnpm", "use pnpm in the web app and npm in the docs, which predates it")); | |
| 29 | + | }); | |
| 1 | + | /** How alike two facts' words must be (shared over all, as sets) to be one fact. */ | |
| 2 | + | const SAME_WORDS = 0.85; | |
| 3 | + | /** The fewest distinct words a fact needs before it can be found inside another. */ | |
| 4 | + | const CONTAINED_MIN_WORDS = 6; | |
| 5 | + | ||
| 6 | + | /** A fact's words, lowercase, one space apart: case, punctuation and spacing aside. */ | |
| 7 | + | function folded(text: string): string { | |
| 8 | + | return text | |
| 9 | + | .toLowerCase() | |
| 10 | + | .split(/[^\p{L}\p{N}]+/u) | |
| 11 | + | .filter(Boolean) | |
| 12 | + | .join(" "); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * Whether two facts say the same thing, as the work service decides it | |
| 17 | + | * (`same_memory`): the same words; one's words, in order, inside the | |
| 18 | + | * other's; all of one's words (at least six) among the other's; or most of | |
| 19 | + | * their words shared. | |
| 20 | + | */ | |
| 21 | + | export function sameFact(a: string, b: string): boolean { | |
| 22 | + | const [x, y] = [folded(a), folded(b)]; | |
| 23 | + | if (!x || !y) return false; | |
| 24 | + | if (x === y) return true; | |
| 25 | + | const [short, long] = x.length <= y.length ? [x, y] : [y, x]; | |
| 26 | + | if (short.split(" ").length >= 4 && ` ${long} `.includes(` ${short} `)) return true; | |
| 27 | + | const shortWords = new Set(short.split(" ")); | |
| 28 | + | const longWords = new Set(long.split(" ")); | |
| 29 | + | let shared = 0; | |
| 30 | + | for (const word of shortWords) if (longWords.has(word)) shared++; | |
| 31 | + | if (shortWords.size >= CONTAINED_MIN_WORDS && shared === shortWords.size) return true; | |
| 32 | + | const all = new Set([...shortWords, ...longWords]).size; | |
| 33 | + | return all > 0 && shared / all >= SAME_WORDS; | |
| 34 | + | } | |
| 35 | + | ||
| 1 | 36 | /** | |
| 2 | − | * The same fact remembered twice (two runs that learned it, or one saved | |
| 3 | − | * again) is shown once: the first of them in the order given, so a pinned | |
| 4 | − | * one or the newest wins. Facts match when their words do, whatever the | |
| 5 | − | * case, spacing or a closing full stop. | |
| 37 | + | * The same fact remembered twice (two runs that learned it, one saved | |
| 38 | + | * again, or a sentence that grew since) is shown once: the first of them in | |
| 39 | + | * the order given, so a pinned one or the newest wins. Facts match when | |
| 40 | + | * their words do, whatever the case, spacing or punctuation, or when they | |
| 41 | + | * are near enough to be one (see `sameFact`). | |
| 6 | 42 | */ | |
| 7 | 43 | export function distinctFacts<T extends { text: string }>(facts: T[]): T[] { | |
| 8 | − | const seen = new Set<string>(); | |
| 9 | − | return facts.filter((fact) => { | |
| 10 | − | const key = fact.text.trim().toLowerCase().replace(/\s+/g, " ").replace(/[.!]+$/, ""); | |
| 11 | − | if (seen.has(key)) return false; | |
| 12 | − | seen.add(key); | |
| 13 | − | return true; | |
| 14 | − | }); | |
| 44 | + | const shown: T[] = []; | |
| 45 | + | for (const fact of facts) { | |
| 46 | + | if (!shown.some((other) => sameFact(other.text, fact.text))) shown.push(fact); | |
| 47 | + | } | |
| 48 | + | return shown; | |
| 15 | 49 | } |
| 4 | 4 | import { | |
| 5 | 5 | type Merged, | |
| 6 | 6 | placePushes, | |
| 7 | + | historyCovers, | |
| 7 | 8 | change, | |
| 8 | 9 | checksFact, | |
| 9 | 10 | confidenceAsk, | |
| ⋯ | |||
| 21 | 22 | sortRows, | |
| 22 | 23 | stallReason, | |
| 23 | 24 | summaryLine, | |
| 25 | + | pushedByPerson, | |
| 24 | 26 | pushedCommits, | |
| 25 | 27 | waitingRows, | |
| 26 | 28 | weekOf, | |
| ⋯ | |||
| 175 | 177 | files: [], | |
| 176 | 178 | }); | |
| 177 | 179 | ||
| 178 | − | test("a push to the default branch counts a person's own commits, not merges or agents'", () => { | |
| 179 | − | const c = (hash: string, author: string, parents = 1) => ({ hash, author: { name: author }, parents: Array(parents).fill("p") }); | |
| 180 | − | const history = [c("e", "Chase"), c("d", "g1t"), c("m", "g1t", 2), c("b", "Chase"), c("a", "Chase")]; | |
| 181 | − | assert.deepEqual(pushedCommits(history, "a").map((x) => x.hash), ["e", "b"]); | |
| 180 | + | test("a push to the default branch counts a person's own commits, not merges or g1t's", () => { | |
| 181 | + | const c = (hash: string, email: string, parents = 1) => ({ hash, author: { name: "g1t", email }, parents: Array(parents).fill("p") }); | |
| 182 | + | // Every commit is named "g1t": the name decides nothing, the address does. | |
| 183 | + | const history = [c("e", "chase@example.com"), c("d", "agent@g1t.sh"), c("m", "chase@example.com", 2), c("b", "chase@example.com"), c("a", "chase@example.com")]; | |
| 184 | + | const byG1t = (commit: { author: { email: string } }) => commit.author.email === "agent@g1t.sh"; | |
| 185 | + | assert.deepEqual(pushedCommits(history, "a", byG1t).map((x) => x.hash), ["e", "b"]); | |
| 182 | 186 | // Where it pointed before was not read: everything read counts. | |
| 183 | − | assert.deepEqual(pushedCommits(history, "zz").map((x) => x.hash), ["e", "b", "a"]); | |
| 187 | + | assert.deepEqual(pushedCommits(history, "zz", byG1t).map((x) => x.hash), ["e", "b", "a"]); | |
| 184 | 188 | }); | |
| 185 | 189 | ||
| 190 | + | test("who pushed is the account that signed in, not the name on the commits", () => { | |
| 191 | + | assert.ok(pushedByPerson({ actor: "usr_chase", data: {} })); | |
| 192 | + | assert.ok(pushedByPerson({ actor: null, data: {} })); | |
| 193 | + | assert.ok(!pushedByPerson({ actor: "usr_g1t_agent", data: {} })); | |
| 194 | + | assert.ok(!pushedByPerson({ actor: "g1t_policy", data: {} })); | |
| 195 | + | // A workflow job's own token is not a person either. | |
| 196 | + | assert.ok(!pushedByPerson({ actor: "usr_chase", data: { causedByJob: "run_1" } })); | |
| 197 | + | ||
| 198 | + | const c = (hash: string, name: string) => ({ hash, parents: ["p"], author: { name, email: `${hash}@example.com` } }); | |
| 199 | + | // A person whose git name is "g1t" pushed m1; g1t pushed w1 under a person's name. | |
| 200 | + | const history = [c("w1", "Chase Pierce"), c("m1", "g1t"), c("old", "Chase Pierce")]; | |
| 201 | + | const pushes = [ | |
| 202 | + | { time: "2026-10-07T12:00:00Z", actor: "usr_g1t_agent", data: { after: "w1", before: "m1" } }, | |
| 203 | + | { time: "2026-10-05T12:00:00Z", actor: "usr_chase", data: { after: "m1", before: "old" } }, | |
| 204 | + | ]; | |
| 205 | + | assert.deepEqual(placePushes(history, pushes), [{ hash: "m1", at: "2026-10-05T12:00:00Z" }]); | |
| 206 | + | }); | |
| 207 | + | ||
| 208 | + | test("a short read of the branch is enough when it reaches the oldest push", () => { | |
| 209 | + | const h = (...hashes: string[]) => hashes.map((hash) => ({ hash })); | |
| 210 | + | const pushes = [ | |
| 211 | + | { time: "2026-10-07T12:00:00Z", data: { after: "c1", before: "c2" } }, | |
| 212 | + | { time: "2026-10-05T12:00:00Z", data: { after: "c2", before: "c3" } }, | |
| 213 | + | ]; | |
| 214 | + | // Holds the oldest push's before. | |
| 215 | + | assert.ok(historyCovers(h("c1", "c2", "c3"), pushes, 3)); | |
| 216 | + | // Full, and the oldest push reaches past it: read further. | |
| 217 | + | assert.ok(!historyCovers(h("c1", "c2", "x"), pushes, 3)); | |
| 218 | + | // Shorter than asked: the whole branch. | |
| 219 | + | assert.ok(historyCovers(h("c1", "c2"), pushes, 3)); | |
| 220 | + | // The oldest push made the branch: only the whole branch will do. | |
| 221 | + | assert.ok(!historyCovers(h("c1", "c2", "c3"), [{ time: "", data: { after: "c1" } }], 3)); | |
| 222 | + | }); | |
| 223 | + | ||
| 186 | 224 | test("a change landed without a person when g1t merged it", () => { | |
| 187 | 225 | assert.ok(landedByAgents({ mergedBy: "g1t" })); | |
| 188 | 226 | assert.ok(landedByAgents({ mergedBy: "g1t" })); | |
| ⋯ | |||
| 373 | 411 | }); | |
| 374 | 412 | ||
| 375 | 413 | test("each push to the default branch places the commits it brought, at its time, from one read of the history", () => { | |
| 376 | − | const c = (hash: string, parents = ["p"], author = "Chase Pierce") => ({ hash, parents, author: { name: author } }); | |
| 414 | + | const c = (hash: string, parents = ["p"], email = "chase@example.com") => ({ hash, parents, author: { name: "Chase Pierce", email } }); | |
| 377 | 415 | // Newest first: a Wednesday push of two, a Monday push of one, a merge, and g1t's own commit. | |
| 378 | − | const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t"), c("old")]; | |
| 416 | + | const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t@users.noreply.g1t.sh"), c("old")]; | |
| 379 | 417 | const pushes = [ | |
| 380 | − | { time: "2026-10-07T12:00:00Z", data: { after: "w2", before: "m1" } }, | |
| 381 | − | { time: "2026-10-05T12:00:00Z", data: { after: "m1", before: "old" } }, | |
| 382 | − | { time: "2026-10-01T12:00:00Z", data: { after: "gone", before: "older" } }, | |
| 418 | + | { time: "2026-10-07T12:00:00Z", actor: "usr_chase", data: { after: "w2", before: "m1" } }, | |
| 419 | + | { time: "2026-10-05T12:00:00Z", actor: "usr_chase", data: { after: "m1", before: "old" } }, | |
| 420 | + | { time: "2026-10-01T12:00:00Z", actor: "usr_chase", data: { after: "gone", before: "older" } }, | |
| 383 | 421 | ]; | |
| 384 | − | assert.deepEqual(placePushes(history, pushes), [ | |
| 422 | + | const byG1t = (commit: { author: { email: string } }) => commit.author.email.endsWith("@users.noreply.g1t.sh"); | |
| 423 | + | assert.deepEqual(placePushes(history, pushes, byG1t), [ | |
| 385 | 424 | { hash: "m1", at: "2026-10-05T12:00:00Z" }, | |
| 386 | 425 | { hash: "w2", at: "2026-10-07T12:00:00Z" }, | |
| 387 | 426 | { hash: "w1", at: "2026-10-07T12:00:00Z" }, | |
| 432 | 432 | ||
| 433 | 433 | // --- Landed ----------------------------------------------------------------- | |
| 434 | 434 | ||
| 435 | − | /** A merged pull request, as the week counts it. */ | |
| 436 | 435 | /** | |
| 437 | − | * The commits a push to the default branch brought, from the history at its | |
| 438 | − | * `after` (newest first) back to its `before`: people's own, not merges (a | |
| 439 | − | * pull request landing) and not agents'. A push whose `before` is not in | |
| 440 | − | * what was read gives what was read. | |
| 436 | + | * The accounts g1t itself acts as on the event log: its agent, and the | |
| 437 | + | * policy that merges and pushes for it. An event's `actor` is an account | |
| 438 | + | * id, so this is what tells g1t apart, whatever name a commit carries. | |
| 441 | 439 | */ | |
| 442 | − | export function pushedCommits<C extends { hash: string; parents: string[]; author: { name: string } }>( | |
| 440 | + | export const G1T_ACCOUNT_IDS: ReadonlySet<string> = new Set(["usr_g1t_agent", "g1t_policy"]); | |
| 441 | + | ||
| 442 | + | /** A `git.push` from the log, as far as placing its commits needs it. */ | |
| 443 | + | export type PushRecord = { | |
| 444 | + | time: string; | |
| 445 | + | /** The account that pushed; null when the log does not say. */ | |
| 446 | + | actor?: string | null; | |
| 447 | + | data: { after: string; before?: string; causedByJob?: string }; | |
| 448 | + | }; | |
| 449 | + | ||
| 450 | + | /** | |
| 451 | + | * Whether a person pushed: not g1t or one of its agents (by the account | |
| 452 | + | * that signed in to push), and not a workflow job's own token. | |
| 453 | + | */ | |
| 454 | + | export function pushedByPerson(push: Pick<PushRecord, "actor" | "data">): boolean { | |
| 455 | + | if (push.data.causedByJob) return false; | |
| 456 | + | return !(push.actor && G1T_ACCOUNT_IDS.has(push.actor)); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /** | |
| 460 | + | * The commits a person's push to the default branch brought, from the | |
| 461 | + | * history at its `after` (newest first) back to its `before`: their own, | |
| 462 | + | * not merges (a pull request landing) and not g1t's (`byG1t`: a commit | |
| 463 | + | * g1t wrote, told by its author address, which a person may fast-forward | |
| 464 | + | * onto the branch). A push whose `before` is not in what was read gives | |
| 465 | + | * what was read. Who pushed is the caller's to decide (`pushedByPerson`); | |
| 466 | + | * a commit's author name says nothing about it. | |
| 467 | + | */ | |
| 468 | + | export function pushedCommits<C extends { hash: string; parents: string[] }>( | |
| 443 | 469 | history: C[], | |
| 444 | 470 | before: string | undefined, | |
| 471 | + | byG1t: (commit: C) => boolean = () => false, | |
| 445 | 472 | ): C[] { | |
| 446 | 473 | const end = before ? history.findIndex((commit) => commit.hash === before) : -1; | |
| 447 | 474 | const brought = end === -1 ? history : history.slice(0, end); | |
| 448 | − | return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name)); | |
| 475 | + | return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit)); | |
| 449 | 476 | } | |
| 450 | 477 | ||
| 451 | 478 | /** | |
| 452 | − | * Each commit of `history` (newest first) a push in `pushes` (newest | |
| 453 | − | * first) brought, at that push's time. A commit pushed twice (after a | |
| 454 | − | * force push, say) counts once, at its first landing; a push whose `after` | |
| 455 | − | * is no longer in the history (rewritten) brings nothing. | |
| 479 | + | * Each commit of `history` (newest first) a person's push in `pushes` | |
| 480 | + | * (newest first) brought, at that push's time. A commit pushed twice | |
| 481 | + | * (after a force push, say) counts once, at its first landing; a push | |
| 482 | + | * whose `after` is no longer in the history (rewritten) brings nothing, | |
| 483 | + | * and g1t's own pushes bring nothing here: pull requests count those. | |
| 456 | 484 | */ | |
| 457 | − | export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>( | |
| 485 | + | export function placePushes<C extends { hash: string; parents: string[] }>( | |
| 458 | 486 | history: C[], | |
| 459 | − | pushes: { time: string; data: { after: string; before?: string } }[], | |
| 487 | + | pushes: PushRecord[], | |
| 488 | + | byG1t: (commit: C) => boolean = () => false, | |
| 460 | 489 | ): { hash: string; at: string }[] { | |
| 461 | 490 | const index = new Map(history.map((commit, i) => [commit.hash, i])); | |
| 462 | − | const seen = new Map<string, string>(); | |
| 491 | + | // Each commit's first landing: the time, or null when g1t landed it. | |
| 492 | + | const seen = new Map<string, string | null>(); | |
| 463 | 493 | for (const push of [...pushes].reverse()) { | |
| 464 | 494 | const start = index.get(push.data.after); | |
| 465 | 495 | if (start === undefined) continue; | |
| 466 | 496 | const end = push.data.before ? index.get(push.data.before) : undefined; | |
| 467 | − | for (const commit of pushedCommits(history.slice(start, end ?? history.length), undefined)) { | |
| 468 | − | if (!seen.has(commit.hash)) seen.set(commit.hash, push.time); | |
| 497 | + | const range = history.slice(start, end ?? history.length); | |
| 498 | + | const at = pushedByPerson(push) ? push.time : null; | |
| 499 | + | for (const commit of pushedCommits(range, undefined, byG1t)) { | |
| 500 | + | if (!seen.has(commit.hash)) seen.set(commit.hash, at); | |
| 469 | 501 | } | |
| 470 | 502 | } | |
| 471 | − | return [...seen].map(([hash, at]) => ({ hash, at })); | |
| 503 | + | return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : [])); | |
| 472 | 504 | } | |
| 473 | 505 | ||
| 506 | + | /** | |
| 507 | + | * Whether a read of the default branch reaches back far enough to place | |
| 508 | + | * every push, newest first: it holds the oldest push's `before`, or it is | |
| 509 | + | * the whole branch. A shorter read than `asked` is the whole branch. | |
| 510 | + | */ | |
| 511 | + | export function historyCovers(history: { hash: string }[], pushes: PushRecord[], asked: number): boolean { | |
| 512 | + | if (history.length < asked) return true; | |
| 513 | + | const oldest = pushes.at(-1)?.data.before; | |
| 514 | + | return oldest != null && history.some((commit) => commit.hash === oldest); | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | /** A merged pull request, as the week counts it. */ | |
| 474 | 518 | export type Merged = { | |
| 475 | 519 | repo: RepoPath; | |
| 476 | 520 | number: number; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { knownTimeZone, localTime, timeZoneLabel, timeZoneNames, utcOffset } from "./time-zone.ts"; | |
| 5 | + | ||
| 6 | + | // 2026-10-08 21:42 UTC: 3:42 PM in Denver (MDT), the next morning in Tokyo. | |
| 7 | + | const NOW = Date.UTC(2026, 9, 8, 21, 42); | |
| 8 | + | ||
| 9 | + | test("a profile's local time is the time of day in its zone", () => { | |
| 10 | + | // ICU puts a narrow no-break space before AM/PM in some versions. | |
| 11 | + | const at = (zone: string) => localTime(zone, NOW, "en-US")?.replace(/\s/gu, " "); | |
| 12 | + | assert.equal(at("America/Denver"), "3:42 PM"); | |
| 13 | + | assert.equal(at("Asia/Tokyo"), "6:42 AM"); | |
| 14 | + | assert.equal(at("UTC"), "9:42 PM"); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("no zone, or one the runtime does not know, shows no time", () => { | |
| 18 | + | assert.equal(localTime(null, NOW, "en-US"), null); | |
| 19 | + | assert.equal(localTime("", NOW, "en-US"), null); | |
| 20 | + | assert.equal(localTime("Mars/Olympus_Mons", NOW, "en-US"), null); | |
| 21 | + | assert.ok(!knownTimeZone("Mars/Olympus_Mons")); | |
| 22 | + | assert.ok(knownTimeZone("Europe/Berlin")); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | test("the zones to pick from are sorted, with UTC and a saved one always there", () => { | |
| 26 | + | const names = timeZoneNames("Mars/Olympus_Mons"); | |
| 27 | + | assert.ok(names.includes("UTC")); | |
| 28 | + | assert.ok(names.includes("America/Denver")); | |
| 29 | + | assert.ok(names.includes("Mars/Olympus_Mons")); | |
| 30 | + | assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b))); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("zones read as places, with their offset", () => { | |
| 34 | + | assert.equal(timeZoneLabel("America/Port_of_Spain"), "America/Port of Spain"); | |
| 35 | + | assert.equal(utcOffset("America/Denver", NOW), "UTC−06:00"); | |
| 36 | + | assert.equal(utcOffset("Asia/Kolkata", NOW), "UTC+05:30"); | |
| 37 | + | assert.equal(utcOffset("UTC", NOW), "UTC+00:00"); | |
| 38 | + | }); |
| 1 | + | /** | |
| 2 | + | * Time zones on a profile: the IANA names a person picks from in their | |
| 3 | + | * settings, and the local time the card over their name shows. Pure, so it | |
| 4 | + | * is tested on its own. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | /** Whether the runtime knows `zone` as a time zone. */ | |
| 8 | + | export function knownTimeZone(zone: string | null | undefined): zone is string { | |
| 9 | + | if (!zone) return false; | |
| 10 | + | try { | |
| 11 | + | new Intl.DateTimeFormat("en-US", { timeZone: zone }); | |
| 12 | + | return true; | |
| 13 | + | } catch { | |
| 14 | + | return false; | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * Every IANA zone the runtime knows, sorted, with `current` kept in the | |
| 20 | + | * list even when the runtime does not know it, so a saved choice is never | |
| 21 | + | * dropped. UTC is always there. | |
| 22 | + | */ | |
| 23 | + | export function timeZoneNames(current?: string | null): string[] { | |
| 24 | + | let names: string[] = []; | |
| 25 | + | try { | |
| 26 | + | names = Intl.supportedValuesOf("timeZone"); | |
| 27 | + | } catch { | |
| 28 | + | names = []; | |
| 29 | + | } | |
| 30 | + | const all = new Set(names); | |
| 31 | + | all.add("UTC"); | |
| 32 | + | if (current) all.add(current); | |
| 33 | + | return [...all].sort((a, b) => a.localeCompare(b)); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** A zone's name as a person reads it: `America/Port_of_Spain` as `America/Port of Spain`. */ | |
| 37 | + | export function timeZoneLabel(zone: string): string { | |
| 38 | + | return zone.replaceAll("_", " "); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** The zone's offset from UTC at `now`, such as `UTC−06:00`; null when unknown. */ | |
| 42 | + | export function utcOffset(zone: string, now: number): string | null { | |
| 43 | + | try { | |
| 44 | + | const part = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }) | |
| 45 | + | .formatToParts(now) | |
| 46 | + | .find((one) => one.type === "timeZoneName")?.value; | |
| 47 | + | if (!part) return null; | |
| 48 | + | // "GMT-06:00", or plain "GMT" at UTC itself. | |
| 49 | + | const offset = part.replace(/^GMT/, "") || "+00:00"; | |
| 50 | + | return `UTC${offset.replace("-", "−")}`; | |
| 51 | + | } catch { | |
| 52 | + | return null; | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * The time of day at `now` in `zone`, such as `3:42 PM`, in `locale` (the | |
| 58 | + | * viewer's own when left out); null when there is no zone or the runtime | |
| 59 | + | * does not know it. | |
| 60 | + | */ | |
| 61 | + | export function localTime(zone: string | null | undefined, now: number, locale?: string): string | null { | |
| 62 | + | if (!knownTimeZone(zone)) return null; | |
| 63 | + | return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: zone }).format(now); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** The browser's own zone, or null where it cannot say (on the server, say). */ | |
| 67 | + | export function browserTimeZone(): string | null { | |
| 68 | + | try { | |
| 69 | + | const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
| 70 | + | return knownTimeZone(zone) ? zone : null; | |
| 71 | + | } catch { | |
| 72 | + | return null; | |
| 73 | + | } | |
| 74 | + | } |
| 53 | 53 | import { PolicyNotice } from "./components/policy-notice"; | |
| 54 | 54 | import { readCookie } from "./lib/mission"; | |
| 55 | 55 | import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice"; | |
| 56 | + | import { PageMain } from "./components/landmark"; | |
| 56 | 57 | import { NotFound } from "./components/not-found"; | |
| 57 | 58 | import { usesAppShell } from "./lib/chrome"; | |
| 58 | 59 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./components/command-palette"; | |
| ⋯ | |||
| 578 | 579 | }, [reload, href]); | |
| 579 | 580 | if (reload) { | |
| 580 | 581 | return ( | |
| 581 | − | <main className="mx-auto max-w-xl px-4 py-32 text-center text-sm text-muted" aria-busy="true"> | |
| 582 | + | <PageMain className="mx-auto max-w-xl px-4 py-32 text-center text-sm text-muted" aria-busy="true"> | |
| 582 | 583 | <title>Loading · g1t</title> | |
| 583 | 584 | Loading the latest version of g1t… | |
| 584 | − | </main> | |
| 585 | + | </PageMain> | |
| 585 | 586 | ); | |
| 586 | 587 | } | |
| 587 | 588 | ||
| ⋯ | |||
| 608 | 609 | } | |
| 609 | 610 | ||
| 610 | 611 | return ( | |
| 611 | − | <main className="mx-auto max-w-xl px-4 py-32 text-center"> | |
| 612 | + | <PageMain className="mx-auto max-w-xl px-4 py-32 text-center"> | |
| 612 | 613 | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> | |
| 613 | 614 | <p className="mt-3 text-muted">{details}</p> | |
| 614 | 615 | <div className="mt-8"> | |
| ⋯ | |||
| 621 | 622 | <code>{stack}</code> | |
| 622 | 623 | </pre> | |
| 623 | 624 | )} | |
| 624 | − | </main> | |
| 625 | + | </PageMain> | |
| 625 | 626 | ); | |
| 626 | 627 | } | |
| 37 | 37 | stuckMinutes, | |
| 38 | 38 | waitedFor, | |
| 39 | 39 | } from "../lib/mission"; | |
| 40 | + | import { G1T_COMMIT_EMAILS, normalizeEmail } from "../lib/commit-people"; | |
| 40 | 41 | import { | |
| 41 | 42 | type Fact, | |
| 42 | 43 | type Merged, | |
| ⋯ | |||
| 56 | 57 | usd, | |
| 57 | 58 | waitingRows, | |
| 58 | 59 | weekOf, | |
| 60 | + | historyCovers, | |
| 59 | 61 | who, | |
| 60 | 62 | whyFor, | |
| 61 | 63 | withConfidence, | |
| ⋯ | |||
| 160 | 162 | */ | |
| 161 | 163 | const PUSH_PAGE = 200; | |
| 162 | 164 | const PUSH_PAGES = 5; | |
| 163 | − | /** Commits of the default branch read once, to place each push's commits. */ | |
| 165 | + | /** The first page is smaller: most projects push far less in two weeks. */ | |
| 166 | + | const FIRST_PUSH_PAGE = 50; | |
| 167 | + | /** | |
| 168 | + | * Commits of the default branch read to place each push's commits: a | |
| 169 | + | * short read first, which covers most projects' fortnight, and the longer | |
| 170 | + | * one only when it does not reach the oldest push. | |
| 171 | + | */ | |
| 172 | + | const HISTORY_FIRST = 200; | |
| 164 | 173 | const HISTORY_READ = 1000; | |
| 165 | 174 | ||
| 166 | 175 | /** | |
| ⋯ | |||
| 173 | 182 | const pushes: G1tEvent<"git.push">[] = []; | |
| 174 | 183 | let before: string | undefined; | |
| 175 | 184 | for (let page = 0; page < PUSH_PAGES; page++) { | |
| 176 | − | const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSH_PAGE, ...(before ? { before } : {}) }); | |
| 185 | + | const limit = page === 0 ? FIRST_PUSH_PAGE : PUSH_PAGE; | |
| 186 | + | const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit, ...(before ? { before } : {}) }); | |
| 177 | 187 | for (const event of batch) { | |
| 178 | 188 | if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">); | |
| 179 | 189 | } | |
| 180 | 190 | const oldest = batch.at(-1); | |
| 181 | − | if (batch.length < PUSH_PAGE || !oldest || Date.parse(oldest.time) < since) break; | |
| 191 | + | if (batch.length < limit || !oldest || Date.parse(oldest.time) < since) break; | |
| 182 | 192 | before = oldest.id; | |
| 183 | 193 | } | |
| 184 | 194 | if (pushes.length === 0) return []; | |
| 185 | − | // One read of the branch from the newest push back; each push brought | |
| 195 | + | // A read of the branch from the newest push back; each push brought | |
| 186 | 196 | // what lies between its `after` and its `before` in that history. | |
| 187 | 197 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 188 | − | const history = await reposApi.log(path, viewer, pushes[0].data.after, HISTORY_READ).catch(() => null); | |
| 198 | + | const read = (limit: number) => reposApi.log(path, viewer, pushes[0].data.after, limit).catch(() => null); | |
| 199 | + | let history = await read(HISTORY_FIRST); | |
| 200 | + | if (history?.ok && !historyCovers(history.value, pushes, HISTORY_FIRST)) history = await read(HISTORY_READ); | |
| 189 | 201 | if (!history?.ok) return []; | |
| 190 | − | return placePushes(history.value, pushes); | |
| 202 | + | // g1t's own commits are told by their author address, never by name. | |
| 203 | + | return placePushes(history.value, pushes, (commit) => G1T_COMMIT_EMAILS.has(normalizeEmail(commit.author.email))); | |
| 191 | 204 | } | |
| 192 | 205 | ||
| 193 | 206 | ||
| 40 | 40 | import { Hint } from "../../components/ui/hint"; | |
| 41 | 41 | import { useWorkflowReason } from "../../components/mirror"; | |
| 42 | 42 | import { searchLog } from "../../lib/log-lines"; | |
| 43 | − | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 44 | − | import { expiresIn, formatBytes } from "../../lib/artifacts"; | |
| 43 | + | import { listArtifacts, withLegacyArtifacts } from "../../lib/artifacts.server"; | |
| 44 | + | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/artifacts"; | |
| 45 | 45 | import { actions } from "../../lib/services.server"; | |
| 46 | 46 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 47 | 47 | import { accessTo, refusal } from "../../lib/access.server"; | |
| ⋯ | |||
| 58 | 58 | // An earlier attempt, when one is asked for. | |
| 59 | 59 | const asked = Number(new URL(request.url).searchParams.get("attempt") ?? ""); | |
| 60 | 60 | const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined; | |
| 61 | − | const [detail, artifacts, summaries] = await Promise.all([ | |
| 61 | + | const [detail, kept, summaries] = await Promise.all([ | |
| 62 | 62 | actions.run(repo, viewer, params.id, attempt).then(unwrap), | |
| 63 | 63 | listArtifacts(repo, viewer, params.id).catch(() => []), | |
| 64 | 64 | actions | |
| ⋯ | |||
| 66 | 66 | .then((found) => (found.ok ? found.value : [])) | |
| 67 | 67 | .catch((): JobSummary[] => []), | |
| 68 | 68 | ]); | |
| 69 | + | // Artifacts an older runner kept in KV: asked for only once the service | |
| 70 | + | // has shown the viewer the run, and never while it is still going. | |
| 71 | + | const artifacts = legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept; | |
| 69 | 72 | // Cancelling and re-running need Write. | |
| 70 | 73 | return { | |
| 71 | 74 | detail, | |
| 56 | 56 | import { distinctFacts } from "../../lib/memory-facts"; | |
| 57 | 57 | import { githubApp } from "../../lib/github.server"; | |
| 58 | 58 | import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 59 | − | import { ChangeSize } from "../../components/work"; | |
| 59 | + | import { ChangeSize, PersonLink } from "../../components/work"; | |
| 60 | 60 | import { | |
| 61 | 61 | type ActivityItem, | |
| 62 | 62 | type Need, | |
| ⋯ | |||
| 1174 | 1174 | <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted"> | |
| 1175 | 1175 | <span className="font-mono text-faint">#{pull.number}</span> | |
| 1176 | 1176 | <span>·</span> | |
| 1177 | + | {/* The avatar is whoever the line names first: the | |
| 1178 | + | agent that made it, or the person who wrote it | |
| 1179 | + | with their own tools. */} | |
| 1177 | 1180 | <span className="inline-flex items-center gap-1"> | |
| 1178 | − | <Avatar name={pull.agent} size={13} /> | |
| 1179 | − | {pull.requestedBy | |
| 1180 | − | ? `made by ${pull.author.username} for ${pull.requestedBy.username}` | |
| 1181 | − | : byAgent | |
| 1182 | − | ? `made by ${pull.agent}` | |
| 1183 | − | : `by ${pull.author.username}`} | |
| 1181 | + | {pull.requestedBy ? ( | |
| 1182 | + | <> | |
| 1183 | + | <Avatar name={pull.author.username} size={13} /> | |
| 1184 | + | made by <PersonLink name={pull.author.username} className="hover:text-fg" /> for{" "} | |
| 1185 | + | <PersonLink name={pull.requestedBy.username} className="hover:text-fg" /> | |
| 1186 | + | </> | |
| 1187 | + | ) : byAgent ? ( | |
| 1188 | + | <> | |
| 1189 | + | <Avatar name={pull.agent} size={13} /> | |
| 1190 | + | made by {pull.agent} | |
| 1191 | + | </> | |
| 1192 | + | ) : ( | |
| 1193 | + | <> | |
| 1194 | + | <Avatar name={pull.author.username} size={13} /> | |
| 1195 | + | by <PersonLink name={pull.author.username} className="hover:text-fg" /> | |
| 1196 | + | </> | |
| 1197 | + | )} | |
| 1184 | 1198 | </span> | |
| 1185 | 1199 | {pull.issue != null && ( | |
| 1186 | 1200 | <> | |
| ⋯ | |||
| 1190 | 1204 | </Link> | |
| 1191 | 1205 | </> | |
| 1192 | 1206 | )} | |
| 1193 | − | {pull.mergedBy && <span>· landed by {pull.mergedBy}</span>} | |
| 1207 | + | {pull.mergedBy && ( | |
| 1208 | + | <span> | |
| 1209 | + | · landed by <PersonLink name={pull.mergedBy} className="hover:text-fg" /> | |
| 1210 | + | </span> | |
| 1211 | + | )} | |
| 1194 | 1212 | <span> | |
| 1195 | 1213 | · <TimeAgo at={pull.mergedAt ?? pull.updatedAt} /> | |
| 1196 | 1214 | </span> | |
| 23 | 23 | } from "../../lib/session.server"; | |
| 24 | 24 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 25 | 25 | ||
| 26 | + | /** The most of an outcome's activity the page shows. */ | |
| 27 | + | const ACTIVITY_LIMIT = 40; | |
| 26 | 28 | ||
| 27 | 29 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 30 | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 49 | 51 | // What happened across the outcome's issues and pull requests. | |
| 50 | 52 | let activity: G1tEvent[] = []; | |
| 51 | 53 | if (found.status === "applied" && found.progress.length > 0) { | |
| 52 | − | const numbers = new Set([ | |
| 53 | − | ...found.progress.map((item) => item.number), | |
| 54 | − | ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])), | |
| 54 | + | const numbers = [ | |
| 55 | + | ...new Set([ | |
| 56 | + | ...found.progress.map((item) => item.number), | |
| 57 | + | ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])), | |
| 58 | + | ]), | |
| 59 | + | ]; | |
| 60 | + | const since = found.finishedAt ?? found.createdAt; | |
| 61 | + | // The log is read for this outcome alone, so a busy repository's other | |
| 62 | + | // work never crowds it out: events on its issues and pull requests, and | |
| 63 | + | // issues an agent filed while working on it, which belong to it too. | |
| 64 | + | const [own, filed] = await Promise.all([ | |
| 65 | + | events.list({ repoId: found.repoId, numbers, since, limit: ACTIVITY_LIMIT }).catch(() => []), | |
| 66 | + | events | |
| 67 | + | .list({ repoId: found.repoId, types: ["issue.opened"], actor: "usr_g1t_agent", since, limit: ACTIVITY_LIMIT }) | |
| 68 | + | .catch(() => []), | |
| 55 | 69 | ]); | |
| 56 | − | const since = found.finishedAt ?? found.createdAt; | |
| 57 | − | const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []); | |
| 58 | − | activity = recent | |
| 59 | − | .filter((event) => event.time >= since) | |
| 60 | − | .filter((event) => { | |
| 61 | − | const data = event.data as { number?: number; issue?: number }; | |
| 62 | − | // Issues an agent filed while working on this outcome belong to it too. | |
| 63 | − | if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true; | |
| 64 | − | return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue)); | |
| 65 | − | }) | |
| 66 | − | .slice(0, 40); | |
| 70 | + | const seen = new Set<string>(); | |
| 71 | + | activity = [...own, ...filed] | |
| 72 | + | .filter((event) => (seen.has(event.id) ? false : (seen.add(event.id), true))) | |
| 73 | + | .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)) | |
| 74 | + | .slice(0, ACTIVITY_LIMIT); | |
| 67 | 75 | // Events name accounts by id; show names. | |
| 68 | 76 | const named = await identity | |
| 69 | 77 | .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))]) | |
| 31 | 31 | location: text("location"), | |
| 32 | 32 | website: text("website"), | |
| 33 | 33 | pronouns: text("pronouns"), | |
| 34 | + | timezone: text("timezone"), | |
| 34 | 35 | }); | |
| 35 | 36 | return result.ok ? { profileSaved: true } : { profileError: result.error.message }; | |
| 36 | 37 | } |
| 684 | 684 | `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated` | |
| 685 | 685 | (401), `payment_required` (402, when the plan or a limit refuses compute), | |
| 686 | 686 | `forbidden` (403, with `needed_scope` when the token lacks a | |
| 687 | − | scope), `not_found` (404), `conflict` (409), `invalid` (422). Each | |
| 687 | + | scope), `not_found` (404), `conflict` (409), `invalid` (422), | |
| 688 | + | `rate_limited` (429, with `Retry-After`: 1,000 requests a minute per | |
| 689 | + | token, 60 per IP address without one; wait, then retry). Each | |
| 688 | 690 | operation's scope is `x-scope` in the OpenAPI document. The full description is at https://api.g1t.sh/openapi.json. | |
| 689 | 691 | - Git remote: `https://g1t.sh/{workspace}/{repo}.git`. In API paths, | |
| 690 | 692 | `{owner}` is the workspace. Pull request forks: | |
| ⋯ | |||
| 752 | 754 | - [Git](https://docs.g1t.sh/guides/git/) | |
| 753 | 755 | - [MCP tools](https://docs.g1t.sh/reference/mcp/) | |
| 754 | 756 | - [API reference](https://docs.g1t.sh/reference/api/) | |
| 757 | + | - [Rate limits](https://docs.g1t.sh/reference/rate-limits/) | |
| 755 | 758 | - [What g1t can't do yet](https://docs.g1t.sh/about/limitations/) | |
| 756 | 759 | - [An open letter to Cloudflare](https://docs.g1t.sh/about/open-letter-to-cloudflare/) | |
| 757 | 760 | - [Source](https://g1t.sh/flagon-io/g1t), MIT licensed | |
| 3 | 3 | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 6 | + | import { gitLimited, pageLimited } from "../app/lib/front-door-limits"; | |
| 6 | 7 | import { goImport } from "../app/lib/go-get"; | |
| 7 | 8 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| 8 | 9 | import { registryWorkspace, servicePath } from "../app/lib/registry-paths"; | |
| ⋯ | |||
| 50 | 51 | } | |
| 51 | 52 | const service = servicePath(pathname); | |
| 52 | 53 | if (service === "git") { | |
| 53 | − | return proxyGit(env, request); | |
| 54 | + | // Per address without credentials, per credential with them | |
| 55 | + | // (app/lib/front-door-limits.ts): anonymous clones are not free to | |
| 56 | + | // the repository's owner. | |
| 57 | + | return (await gitLimited(env, request)) ?? proxyGit(env, request); | |
| 54 | 58 | } | |
| 55 | 59 | if (service === "packages") { | |
| 56 | 60 | return proxyPackages(env, request); | |
| ⋯ | |||
| 65 | 69 | const target = MOVED_DOCS[page] ?? "/"; | |
| 66 | 70 | return Response.redirect(DOCS + target, 301); | |
| 67 | 71 | } | |
| 72 | + | // Pages and data requests, limited per address signed out and per | |
| 73 | + | // session signed in (app/lib/front-door-limits.ts). | |
| 74 | + | const limited = await pageLimited(env, request, pathname); | |
| 75 | + | if (limited) return limited; | |
| 68 | 76 | // Every page and data request says where its time went (Server-Timing) | |
| 69 | 77 | // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts). | |
| 70 | 78 | const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request))); | |
| 1 | − | import type { RunnerApi, ServiceBinding } from "@g1t/contracts"; | |
| 1 | + | import type { RateLimitBinding, RunnerApi, ServiceBinding } from "@g1t/contracts"; | |
| 2 | 2 | ||
| 3 | 3 | declare global { | |
| 4 | 4 | namespace Cloudflare { | |
| ⋯ | |||
| 44 | 44 | MCP_URL?: string; | |
| 45 | 45 | /** The social-card image service; an empty string for none. Unset on g1t.sh. */ | |
| 46 | 46 | OG_URL?: string; | |
| 47 | + | /** The front door's rate limits (app/lib/front-door-limits.ts). Absent when self-hosted. */ | |
| 48 | + | WEB_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 49 | + | WEB_HEAVY_LIMIT?: RateLimitBinding; | |
| 50 | + | WEB_SESSION_LIMIT?: RateLimitBinding; | |
| 51 | + | WEB_ADDRESS_LIMIT?: RateLimitBinding; | |
| 52 | + | GIT_ANONYMOUS_LIMIT?: RateLimitBinding; | |
| 53 | + | GIT_SIGNED_LIMIT?: RateLimitBinding; | |
| 47 | 54 | } | |
| 48 | 55 | } | |
| 49 | 56 | interface Env extends Cloudflare.Env {} | |
| 43 | 43 | // Production screenshots for a project's overview (services/og). | |
| 44 | 44 | { "binding": "SCREENSHOTS", "service": "g1t-og", "entrypoint": "Screenshots" } | |
| 45 | 45 | ], | |
| 46 | − | "observability": { "enabled": true }, | |
| 46 | + | // The front door's limits (app/lib/front-door-limits.ts). Every id and | |
| 47 | + | // limit is in RATE_LIMITS (packages/contracts/src/rate-limits.ts), which | |
| 48 | + | // the tests check this against. Self-hosted, without them, nothing is limited. | |
| 49 | + | "ratelimits": [ | |
| 50 | + | { "name": "WEB_ANONYMOUS_LIMIT", "namespace_id": "4201", "simple": { "limit": 600, "period": 60 } }, | |
| 51 | + | { "name": "WEB_HEAVY_LIMIT", "namespace_id": "4202", "simple": { "limit": 30, "period": 60 } }, | |
| 52 | + | { "name": "WEB_SESSION_LIMIT", "namespace_id": "4203", "simple": { "limit": 1200, "period": 60 } }, | |
| 53 | + | { "name": "WEB_ADDRESS_LIMIT", "namespace_id": "4204", "simple": { "limit": 3000, "period": 60 } }, | |
| 54 | + | { "name": "GIT_ANONYMOUS_LIMIT", "namespace_id": "4205", "simple": { "limit": 120, "period": 60 } }, | |
| 55 | + | { "name": "GIT_SIGNED_LIMIT", "namespace_id": "4206", "simple": { "limit": 1200, "period": 60 } } | |
| 56 | + | ], | |
| 57 | + | // Logs of a tenth of requests: every page view is one, too many to keep all. | |
| 58 | + | "observability": { "enabled": true, "head_sampling_rate": 0.1 }, | |
| 47 | 59 | "upload_source_maps": true | |
| 48 | 60 | } |
| 85 | 85 | ||
| 86 | 86 | /// Whether it fires in the minute starting at `ms` since the epoch, UTC. | |
| 87 | 87 | pub fn fires_at(&self, ms: u64) -> bool { | |
| 88 | + | self.minutes[(ms / 60_000 % 60) as usize] && self.hour_and_date_at(ms) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Whether the minute starting at `ms` is in its hours, days and | |
| 92 | + | /// months, whatever its minute field says. | |
| 93 | + | fn hour_and_date_at(&self, ms: u64) -> bool { | |
| 88 | 94 | let minutes_total = ms / 60_000; | |
| 89 | − | let minute = (minutes_total % 60) as usize; | |
| 90 | 95 | let hour = (minutes_total / 60 % 24) as usize; | |
| 91 | 96 | let days_since_epoch = (minutes_total / 60 / 24) as i64; | |
| 92 | 97 | // 1970-01-01 was a Thursday. | |
| ⋯ | |||
| 98 | 103 | (true, true) => day_ok || weekday_ok, | |
| 99 | 104 | _ => day_ok && weekday_ok, | |
| 100 | 105 | }; | |
| 101 | − | self.minutes[minute] && self.hours[hour] && self.months[month as usize] && date_ok | |
| 106 | + | self.hours[hour] && self.months[month as usize] && date_ok | |
| 102 | 107 | } | |
| 108 | + | ||
| 109 | + | /// Whether its minutes come closer together than | |
| 110 | + | /// [`MIN_INTERVAL_MINUTES`], counting round the hour. | |
| 111 | + | pub fn too_frequent(&self) -> bool { | |
| 112 | + | let set: Vec<usize> = (0..60).filter(|&m| self.minutes[m]).collect(); | |
| 113 | + | let Some(&first) = set.first() else { return false }; | |
| 114 | + | let wrap = first + 60 - set[set.len() - 1]; | |
| 115 | + | set.windows(2).map(|pair| pair[1] - pair[0]).chain([wrap]).any(|gap| gap < MIN_INTERVAL_MINUTES as usize) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Whether a workflow on this schedule runs in the minute starting at | |
| 119 | + | /// `ms`. A schedule no more frequent than every | |
| 120 | + | /// [`MIN_INTERVAL_MINUTES`] runs when it fires. A more frequent one | |
| 121 | + | /// runs on the five-minute marks: at a mark that is itself in its | |
| 122 | + | /// hours, days and months, when it fired in the five minutes the mark | |
| 123 | + | /// ends. At most every five minutes, and never outside its own hours. | |
| 124 | + | pub fn runs_at(&self, ms: u64) -> bool { | |
| 125 | + | if !self.too_frequent() { | |
| 126 | + | return self.fires_at(ms); | |
| 127 | + | } | |
| 128 | + | let minute = ms / 60_000; | |
| 129 | + | minute % MIN_INTERVAL_MINUTES == 0 | |
| 130 | + | && self.hour_and_date_at(ms) | |
| 131 | + | && (0..MIN_INTERVAL_MINUTES).any(|back| minute >= back && self.fires_at((minute - back) * 60_000)) | |
| 132 | + | } | |
| 103 | 133 | } | |
| 104 | 134 | ||
| 135 | + | /// The shortest interval a workflow's schedule runs at, in minutes. | |
| 136 | + | pub const MIN_INTERVAL_MINUTES: u64 = 5; | |
| 137 | + | ||
| 105 | 138 | /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm). | |
| 106 | 139 | fn civil_from_days(days: i64) -> (i64, u32, u32) { | |
| 107 | 140 | let z = days + 719_468; | |
| ⋯ | |||
| 168 | 201 | } | |
| 169 | 202 | ||
| 170 | 203 | #[test] | |
| 204 | + | fn schedules_run_at_most_every_five_minutes() { | |
| 205 | + | let runs = |text: &str| -> Vec<u64> { | |
| 206 | + | let schedule = Schedule::parse(text).unwrap(); | |
| 207 | + | (0..30).filter(|&m| schedule.runs_at(at(MONDAY, 3, m))).collect() | |
| 208 | + | }; | |
| 209 | + | assert_eq!(runs("* * * * *"), [0, 5, 10, 15, 20, 25]); | |
| 210 | + | assert_eq!(runs("*/2 * * * *"), [0, 5, 10, 15, 20, 25]); | |
| 211 | + | // Every five minutes or less often: exactly when it fires. | |
| 212 | + | assert_eq!(runs("*/5 * * * *"), [0, 5, 10, 15, 20, 25]); | |
| 213 | + | assert_eq!(runs("7,17 * * * *"), [7, 17]); | |
| 214 | + | assert_eq!(runs("*/10 * * * *"), [0, 10, 20]); | |
| 215 | + | // Two minutes close together: the later one waits for the mark. | |
| 216 | + | assert_eq!(runs("0,3 * * * *"), [0, 5]); | |
| 217 | + | // Close across the hour counts too. | |
| 218 | + | assert!(Schedule::parse("2,58 * * * *").unwrap().too_frequent()); | |
| 219 | + | assert!(!Schedule::parse("0 9 * * mon").unwrap().too_frequent()); | |
| 220 | + | // Every minute of one hour: its own marks, 09:00 to 09:55, and | |
| 221 | + | // nothing after. | |
| 222 | + | let nine = Schedule::parse("* 9 * * *").unwrap(); | |
| 223 | + | let day: Vec<(u64, u64)> = | |
| 224 | + | (0..24 * 60).filter(|&m| nine.runs_at(at(MONDAY, m / 60, m % 60))).map(|m| (m / 60, m % 60)).collect(); | |
| 225 | + | assert_eq!(day, (0..12).map(|i| (9, i * 5)).collect::<Vec<_>>()); | |
| 226 | + | assert!(!nine.runs_at(at(MONDAY, 10, 0))); | |
| 227 | + | // Every minute of Mondays: the last run is 23:55, none on Tuesday. | |
| 228 | + | let mondays = Schedule::parse("*/1 * * * 1").unwrap(); | |
| 229 | + | assert!(mondays.runs_at(at(MONDAY, 0, 0))); | |
| 230 | + | assert!(mondays.runs_at(at(MONDAY, 23, 55))); | |
| 231 | + | assert!(!mondays.runs_at(at(MONDAY + 1, 0, 0))); | |
| 232 | + | assert!(!(0..24 * 60).any(|m| mondays.runs_at(at(MONDAY + 1, m / 60, m % 60)))); | |
| 233 | + | // Close minutes: one run for each window they fall in, at its mark. | |
| 234 | + | let close = Schedule::parse("0,3 * * * *").unwrap(); | |
| 235 | + | let hour: Vec<u64> = (0..60).filter(|&m| close.runs_at(at(MONDAY, 3, m))).collect(); | |
| 236 | + | assert_eq!(hour, [0, 5]); | |
| 237 | + | assert!(close.runs_at(at(MONDAY, 4, 0))); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 171 | 241 | fn nonsense_is_refused() { | |
| 172 | 242 | for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] { | |
| 173 | 243 | assert!(Schedule::parse(text).is_err(), "{text}"); | |
| 124 | 124 | let concurrency = deploy.concurrency.as_ref().unwrap(); | |
| 125 | 125 | assert_eq!(concurrency.group, "deploy-production"); | |
| 126 | 126 | assert_eq!(concurrency.cancel_in_progress, json!(false)); | |
| 127 | − | assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front"]); | |
| 127 | + | assert_eq!(deploy.job_order(), ["check", "plan", "migrate", "core", "edge", "front", "smoke"]); | |
| 128 | 128 | // The stages share their steps (a YAML alias), and read the token only | |
| 129 | 129 | // where they deploy. | |
| 130 | 130 | let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len(); | |
| ⋯ | |||
| 168 | 168 | assert!(!starts_after(&deploy, "core", &skipped, &all, push.clone(), false, true)); | |
| 169 | 169 | assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true)); | |
| 170 | 170 | ||
| 171 | + | // Smoke follows the last stage that ran, and not a failed one. | |
| 172 | + | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "success"), ("front", "success")], &all, push.clone(), false)); | |
| 173 | + | assert!(starts(&deploy, "smoke", &[("core", "success"), ("edge", "skipped"), ("front", "success")], &none, push.clone(), false)); | |
| 174 | + | assert!(!starts(&deploy, "smoke", &[("core", "success"), ("edge", "failure"), ("front", "skipped")], &all, push.clone(), false)); | |
| 175 | + | // Nothing deployed: nothing to smoke-test. | |
| 176 | + | let nothing = plan_outputs(false, &[], &[], &[]); | |
| 177 | + | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], ¬hing, push.clone(), false)); | |
| 178 | + | ||
| 171 | 179 | // A dry run plans and stops. | |
| 172 | 180 | let dry = json!({ "dry_run": true, "units": "", "all": false }); | |
| 173 | 181 | assert!(!starts(&deploy, "migrate", &[], &all, dry.clone(), false)); | |
| 174 | − | assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry, false)); | |
| 182 | + | assert!(!starts(&deploy, "core", &[("migrate", "skipped")], &all, dry.clone(), false)); | |
| 183 | + | assert!(!starts(&deploy, "smoke", &[("core", "skipped"), ("edge", "skipped"), ("front", "skipped")], &all, dry, false)); | |
| 175 | 184 | } | |
| 176 | 185 | ||
| 177 | 186 | #[test] | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.