Skip to content

Commit

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.

syntaqxcommitted Parents035affc7145809Browse files
196 files+1803−1730/196 viewed
+16−0
77 # migrate pending D1 migrations, before any code
88 # core, edge, front the units of each stage, in jobs that share a build;
99 # a stage starts only when the one before it succeeded
10+# smoke sign-in, sign-up and the waitlist still work on g1t.sh
1011 #
1112 # Each run that deploys is one production deployment of g1t.sh, made by the
1213 # jobs that name `environment: production` (one per run, however many jobs):
252253 max-parallel: 4
253254 matrix: ${{ fromJSON(needs.plan.outputs.front) }}
254255 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
+36−35
126126
127127 ## Layout
128128
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 |
163163
164164 Each service is its own Worker, and each one that keeps data has its own
165165 database. They call each other through service bindings and react to each
166166 other through events. The core services (accounts, repositories, work,
167167 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.
169170
170171 ## Run your own
171172
+33−4
5656 name: String,
5757 }
5858
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+
5971 fn store(env: &Env) -> Result<KvStore> {
6072 env.kv("BLOBS")
6173 }
404416 // Artifacts older runners kept in KV, for the days they
405417 // are still there.
406418 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+ }
410424 }
411425 }
412426 crate::reply(&listed)
547561 match found {
548562 Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await,
549563 // 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? {
551565 Some(bytes) => Response::from_bytes(bytes),
552566 None => error(404, "No such artifact."),
553567 },
568+ Outcome::Fail(_) => error(404, "No such artifact."),
554569 }
555570 }
556571 _ => error(404, "No such endpoint."),
612627 return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302);
613628 }
614629 // 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+ }
615633 match get(&store(env)?, &format!("a/{run}/{name}")).await? {
616634 Some(bytes) => {
617635 let mut response = Response::from_bytes(bytes)?;
624642 }
625643 }
626644
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+}
+7−2
1414 mod checks;
1515 mod deployments;
1616 mod deploy_keys;
17+mod limits;
1718 mod logs;
1819 mod mirrors;
1920 mod mcp;
622623 return Ok(response);
623624 }
624625
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+ }
625630 let viewer = match authenticate(&request, &services).await? {
626631 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)),
628633 };
629634 // A person who has not confirmed their email address: who they are,
630635 // their addresses, and confirming one, nothing else (REST or MCP).
947952 "authorization, content-type",
948953 )?;
949954 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")?;
951956 Ok(response)
952957 }
+143−0
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+}
+8−1
4242 // actions/cache entries (`c/`) and artifacts (`a/`), uploaded in parts. The actions
4343 // service lists them and decides what is kept (services/actions/src/cache.rs).
4444 "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 }
4653 }
+1−0
160160 items: [
161161 { label: 'API overview', slug: 'reference/api' },
162162 { label: 'MCP tools', slug: 'reference/mcp' },
163+ { label: 'Rate limits', slug: 'reference/rate-limits' },
163164 { label: 'Try it in the explorer', link: '/api/reference/', attrs: { target: '_self' } },
164165 { label: 'OpenAPI document', link: 'https://api.g1t.sh/openapi.json' },
165166 { label: 'llms.txt', link: 'https://g1t.sh/llms.txt' },
+30−0
9898 Why each of these is missing, and what to use instead, is on
9999 [What g1t can't do yet](/about/limitations/#actions-and-runners).
100100
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+
101124 ## Actions and workflows from other repositories
102125
103126 A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's
10831106 or a comment made with it runs nothing, so a workflow cannot set itself
10841107 off. `workflow_dispatch` and [`repository_dispatch`](#repository-dispatch)
10851108 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/).
10861116
10871117 `permissions:` goes at the top of the workflow, for every job, or on a job,
10881118 which then ignores the workflow's. Once either is written, every permission
+5−3
149149
150150 Memory also fills itself. At the end of every run that changes code, the
151151 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
154156 agent is given until they are kept: at once when two independent sources
155157 say the same thing or a project's `AGENTS.md` or manifests state it,
156158 otherwise by a member in the **Review** list on **Agents → Memory** or the
190192 - **edit** one that has drifted, or change its kind;
191193 - **forget** one that no longer holds;
192194 - **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.
194196
195197 Each shows who added it, when, and when it was last given to an agent. A
196198 memory that has not been given to an agent for a long time is a good one to
+2−2
2626
2727 | Page | Address | What is on it |
2828 | --- | --- | --- |
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. |
3030 | 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). |
3131 | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). |
3232 | 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. |
10421042 | 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. |
10431043 | 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. |
10441044 | 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. |
10461046 | Your username | Held for your account. Nobody else can take it. |
10471047
10481048 Within 30 days, support can restore it: write to support@g1t.sh from one
+57−4
3636 | `README`, `AGENTS.md` (or `CLAUDE.md`), `CONTRIBUTING`, `docs/*.md`, `runbooks/*.md` | **Docs**, split into sections for search |
3737 | `.g1t/workflows/*`, `.github/workflows/*` | Whether the project's tests run in checks |
3838 | `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) |
3940
4041 and joins them with what g1t already knows:
4142
7677 | **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 |
7778 | **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 |
7879 | **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 |
8081
8182 What is captured arrives as a **candidate**. No agent is given a candidate
8283 until it is **kept**:
8788 - **by a person**, in the Review queue.
8889
8990 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
9194 source (the same run, the same file) is not counted twice.
9295
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+
93146 ### Review
94147
95148 The **Memory** tab of the Context page lists every candidate in the
99152
100153 - **Keep** gives it to every agent from their next run on.
101154 - **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.
104157
105158 ### Never a secret
106159
+33−1
223223
224224 When a newer version comes out for a dependency (or group) that already
225225 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.
227259
228260 ### Landing them
229261
+5−0
124124 are served under the binding name your config gives them. Cron triggers
125125 (`triggers.crons`) are not scheduled, so a `scheduled` handler never
126126 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.
127132 - `vars` are deployed as plain-text bindings (or JSON, for objects). Rows
128133 of the project's [secrets and variables](/guides/secrets-and-variables/)
129134 available to Deployments are bound too, and replace a `var` of the same
+28−2
142142 the compared branch.
143143
144144 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
147148 folder or file you are on.
148149
149150 ## Pull request forks
183184
184185 Each push sends only what the one before did not.
185186
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+
186202 ### When the store is busy
187203
188204 If Cloudflare Artifacts is rate limiting g1t or not answering, g1t tries
203219 until the month turns. Counting starts on 2026-10-14. See
204220 [git operations](/guides/usage-and-billing/#git-operations).
205221
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+
206232 What these limits mean in practice, and what to do instead, is on
207233 [What g1t can't do yet](/about/limitations/#git).
208234
+8−0
275275 spend the same way it reports it for billing and stops the agent once
276276 the spend reaches the cap. The step in flight when it does can take the
277277 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.
278286 - The time cap is enforced twice: the harness stops the agent when it
279287 passes, and the sandbox itself is stopped three minutes after, whatever
280288 is running in it.
+39−2
299299 | Open | The pull request is open. |
300300 | Merged | The pull request merged. |
301301 | 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. |
303303 | Needs code changes | Raising the version was not enough; an agent is working on it. |
304304 | Failed | The update could not be made. |
305305
306+### When an update is no longer needed
307+
306308 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).
308345
309346 How each lockfile is changed:
310347
+16−0
852852 workflow job is recorded as failed with "Not started:" and the reason.
853853 Runs already under way finish, so usage can go slightly past a limit.
854854
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+
855871 ## Security on every plan
856872
857873 Every workspace, free or on the plan, has the [audit log](/guides/audit-log/),
+4−2
4848
4949 `data` holds what the event is about: ids and numbers to fetch the rest with
5050 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.
5254
5355 With these headers:
5456
6466
6567 | Event | When |
6668 | --- | --- |
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). |
6870 | `repo.created`, `repo.forked` | A repository was made, or forked for a pull request. |
6971 | `repo.updated` | Its description, website, topics, protection or visibility changed. |
7072 | `repo.visibility_changed` | It was made public or private. |
+10−1
414414 (`ops@g1t.sh`), in URLs, in package scopes (`@g1t/platform`) and in longer
415415 names (`@g1t-bot`) are ignored. Matching ignores case. Agents mentioning
416416 `@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).
418421
419422 **Who can.** People with the Write [role](/guides/access-and-roles/) or higher on the
420423 repository, members or not. Anyone else who mentions it gets a short reply
428431
429432 Each comment starts one run at most; to ask again, write a new comment.
430433
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+
431440 ## The label rule
432441
433442 Under a project's **Settings → Agents**, someone with the Maintain role or
+13−2
566566 their own*. An agent's change landed without you when g1t merged it, by
567567 auto-merge or from the [merge queue](/guides/merge-queue/), with no person
568568 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
570573 list, and **New issue** opens a new issue in the project you pick.
571574
572575 | Across the top | What it counts |
669672 `example.com` is saved as `https://example.com`. Your email address is
670673 never shown.
671674
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+
672682 **Who sees what.** A profile is public, but the work and workspaces on it
673683 are filtered for whoever is looking:
674684
692702 | --- | --- |
693703 | Picture, name, username and pronouns | Always |
694704 | Bio and location | They filled them in |
705+| Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) |
695706 | **Member of** | The same workspaces their profile shows you, at most three named |
696707 | **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 |
697708
729740 | One of your confirmed addresses, or your noreply address | You |
730741 | An address added to an account but not confirmed | The name in the commit |
731742 | 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` |
733744 | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` |
734745
735746 `Co-authored-by` trailers are matched the same way, and their pictures sit
+14−0
142142 | 404 | `not_found` | It does not exist, or you cannot see it. A path that is not an endpoint answers this too. |
143143 | 409 | `conflict` | The request conflicts with the current state. |
144144 | 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). |
145146
146147 Branch on `code`, not on `message`: messages are written for people and
147148 may change.
163164 [Settings → Access tokens](https://g1t.sh/settings/tokens), or use another
164165 token. A `403` for any other reason has no `needed_scope`.
165166
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+
166180 ## Lists
167181
168182 Lists come newest first, unless an endpoint says otherwise. Most return
+4−0
203203 the REST API returns them.
204204 - Reading a public repository needs no sign-in through the API. Through MCP,
205205 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/).
206210
207211 The tables below list each action's required inputs. Optional inputs are
208212 in the tool's schema, which `tools/list` returns, and on the action's page
+81−0
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/).
+3−1
88 "directory": "./dist",
99 "not_found_handling": "404-page"
1010 },
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 }
1214 }
+16−0
4747 fail,
4848 ok,
4949 } from "@g1t/contracts";
50+import { LIMIT_PERIOD_SECONDS, type RateLimitBinding, clientAddress, isLimited, secretKey } from "@g1t/contracts/rate-limits";
5051 import bricolage from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2";
5152 import hanken from "@g1t/theme/fonts/hanken-grotesk-latin.woff2";
5253 import plexMono from "@g1t/theme/fonts/ibm-plex-mono-latin-400.woff2";
186187 * it, deploys are not announced and detection does not hold off for them.
187188 */
188189 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;
189197 }
190198
191199 /** How long the edge keeps a page or the JSON. */
539547
540548 if (path === "/subscribe" && post) {
541549 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();
542557 const data = await form(request);
543558 if (String(data.get("website") ?? "")) return message("Check your inbox", "If the address is right, a confirmation link is on its way.");
544559 const email = normalizeEmail(data.get("email"));
545560 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();
546562 const chosen = chosenParts(data.getAll("components").map(String), parts(env).map((p) => p.key));
547563 const token = newToken();
548564 const { send } = await requestSubscription(env.DB, email, chosen, await hashToken(token), new Date(), CONFIRM_TTL_MS, RESEND_AFTER_MS);
+9−1
6969 "STATUS_FROM": "g1t status <noreply@g1t.sh>",
7070 "OG_IMAGE": "https://og.g1t.sh/image?path=%2Fstatus&v=2"
7171 },
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 }
7381 }
+17−2
44 */
55 import { data, redirect } from "react-router";
66
7−import { parseCostSettings, parseMapping, parseRange } from "./costs";
7+import { parseCostSettings, parseMapping, parsePauseLevel, parseRange } from "./costs";
88 import { admin } from "./services.server";
99 import { settle } from "./settle";
1010 import { requireStaff } from "./staff";
1717 mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
1818 removed: "Mapping removed.",
1919 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.",
2022 };
2123
2224 export type CostsActionResult = { error: string; section: string; values?: Record<string, string> };
2527 requireStaff(context as Parameters<typeof requireStaff>[0]);
2628 const url = new URL(request.url);
2729 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())]);
2931 const done = url.searchParams.get("done");
3032 return {
3133 range,
3234 bucket: url.searchParams.get("product"),
3335 report: report.ok ? report.value : null,
3436 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,
3540 done: done ? (DONE[done] ?? null) : null,
3641 };
3742 }
6267 if (!result.value.ok) return fail("lift", result.value.error.message);
6368 throw back("lifted", "#spend");
6469 }
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+ }
6580 if (intent === "decide") {
6681 const id = String(form.get("id") ?? "");
6782 const decision = form.get("decision") === "approve" ? "approve" : "reject";
+38−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { CostDay, SpendCaps } from "@g1t/contracts";
4+import type { CostDay, PlatformGuard, SpendCaps } from "@g1t/contracts";
55
66 import {
7+ count,
78 daySeries,
89 daysBetween,
910 marginOnPrice,
1213 parseBucket,
1314 parseCostSettings,
1415 parseMapping,
16+ parsePauseLevel,
17+ pauseBanner,
1518 parseRange,
1619 percentLabel,
1720 proposalOutcome,
1821 spendBanner,
1922 spendRows,
2023 subscriptionsOver,
24+ thresholdShare,
2125 unitDollars,
2226 versionCells,
2327 whoPaid,
200204 assert.equal(proposalOutcome({ status: "superseded", decidedBy: null, effectiveAt: null }), "replaced by a later measurement");
201205 assert.equal(proposalOutcome({ status: "open", decidedBy: null, effectiveAt: null }), null);
202206 });
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+});
+40−1
22 * Costs & margin: the arithmetic behind the page, apart from the SVG and
33 * the Workers runtime so it can be tested under Node. Money is in micros.
44 */
5−import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts";
5+import type { CostDay, CostMappingInput, CostSettings, PauseLevel, PlatformGuard, SpendCaps } from "@g1t/contracts";
66
77 import { parseDollars, usd } from "./money.ts";
88
266266 if (capMicros <= 0) return 0;
267267 return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100));
268268 }
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+}
+15−3
77 import { MobileBar, Sidebar } from "./components/shell";
88 import { ButtonLink } from "./components/ui";
99 import type { NavCounts } from "./lib/nav";
10−import { spendBanner } from "./lib/costs";
10+import { pauseBanner, spendBanner } from "./lib/costs";
1111 import { admin, identity, statusAdmin } from "./lib/services.server";
1212 import { settle } from "./lib/settle";
1313 import { requireStaff, zoneContext } from "./lib/staff";
2727 export async function loader({ context }: Route.LoaderArgs) {
2828 const { email } = requireStaff(context);
2929 // 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([
3131 settle(identity.waitlistPending()),
3232 settle(statusAdmin.openCount()),
3333 settle(admin.costAlerts()),
3434 settle(admin.spendCaps()),
35+ settle(admin.platformGuard()),
3536 ]);
3637 const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 };
3738 // Every page says times in this zone (components/ui.tsx `When`).
4445 // g1t's own spend (billing's budget): the daily breaker open, or a comped
4546 // account's monthly budget used up. Red until it clears or staff act.
4647 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 };
4852 }
4953
5054 export function Layout({ children }: { children: React.ReactNode }) {
7983 </a>
8084 </div>
8185 )}
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+ )}
8294 {children}
8395 </div>
8496 {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
+181−2
11 import type { ReactNode } from "react";
22 import { Link } from "react-router";
33
4−import type { CostsReport } from "@g1t/contracts";
4+import type { CostsReport, PlatformGuard, PlatformMetric } from "@g1t/contracts";
55
66 import type { Route } from "./+types/costs";
77 import { DaysChart } from "~/components/costs";
88 import { CostsHeader, chip, costsHref } from "~/components/costs-header";
99 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";
1111 import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
1212 import { usd } from "~/lib/money";
1313
3434 <div className="mt-5">
3535 <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
3636 </div>
37+ <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} />
3738 </main>
3839 );
3940 }
5051
5152 <SpendSection caps={report.caps} range={range} error={failed?.section === "lift" ? failed.error : null} />
5253
54+ <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} />
55+
5356 <Section
5457 className="mt-6"
5558 title={product ? `${product.title}, by day` : "By day"}
504507 );
505508 }
506509
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&apos;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&apos;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+}
+2−1
4242 // The Access application needs a Service Auth policy including it.
4343 "STAFF_SERVICE_TOKENS": "434297ede60761875e84742d0486cf27.access=claude"
4444 },
45− "observability": { "enabled": true },
45+ // Every log kept: staff only, few requests.
46+ "observability": { "enabled": true, "head_sampling_rate": 1 },
4647 "upload_source_maps": true
4748 }
+12−0
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+}
+3−2
55 import { withNext } from "../lib/next";
66 import { useSignUpCopy } from "../lib/registration";
77 import { missingKind, notFoundCopy } from "../lib/not-found";
8+import { PageMain } from "./landmark";
89 import { Pixel404 } from "./logo";
910 import { ButtonLink, SubmitButton } from "./ui";
1011
2930 const here = pathname + search;
3031 const signUp = useSignUpCopy();
3132 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">
3334 <Pixel404 className="text-[3.5rem] sm:text-[4.5rem]" />
3435 <h1 className="mt-10 text-balance text-2xl font-semibold tracking-tight">{copy.title}</h1>
3536 <p className="mt-2 text-balance text-muted">{copy.body}</p>
8283 </SubmitButton>
8384 </Form>
8485 )}
85− </main>
86+ </PageMain>
8687 );
8788 }
+47−1
11 import { Check } from "lucide-react";
2−import { useState } from "react";
2+import { useEffect, useMemo, useState } from "react";
33 import { Form, Link } from "react-router";
44
55 import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
66
7+import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone";
78 import { SubmitButton, usePending } from "./ui";
9+import { Combobox } from "./ui/combobox";
810 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
911 import { Input } from "./ui/input";
1012 import { Textarea } from "./ui/textarea";
2628 }) {
2729 const busy = usePending({ intent: "profile" });
2830 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]);
2947 return (
3048 <section id="profile" className="scroll-mt-20">
3149 <div className="flex flex-wrap items-baseline justify-between gap-2">
97115 />
98116 <FieldDescription>An https:// address.</FieldDescription>
99117 </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>
100146 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
101147 <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102148 Save profile
+2−1
44
55 import { type Abilities, type InboxCounts, type Membership, type Spike, type User, mayCreateTeams } from "@g1t/contracts";
66
7+import { InMain } from "./landmark";
78 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
89 import { AgentButton, InboxBell } from "./inbox";
910 import { PinButton } from "./pin-button";
19201921 </header>
19211922 {banner}
19221923 <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>
19241925 </main>
19251926 </div>
19261927 <CommandPalette
+6−2
1−import { Building2, GitCommitHorizontal, MapPin } from "lucide-react";
1+import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react";
22 import { type ReactElement, type ReactNode, useEffect, useState } from "react";
33 import { Link, useParams } from "react-router";
44
55 import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard";
66 import { G1T_MENTION_HREF } from "../lib/markdown-plugins";
7+import { localTime } from "../lib/time-zone";
78 import { Avatar } from "./ui";
89 import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
910 import { Skeleton } from "./ui/skeleton";
8687
8788 function PersonCard({ card }: { card: UserCardData }) {
8889 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());
8992 return (
9093 <div className="space-y-3">
9194 <div className="flex items-start gap-3">
107110 </div>
108111 </div>
109112 {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) && (
111114 <ul className="space-y-1.5 text-[0.8125rem] text-muted">
112115 {card.location && (
113116 <Line icon={<MapPin size={14} />}>
114117 <span className="wrap-anywhere">{card.location}</span>
115118 </Line>
116119 )}
120+ {time && <Line icon={<Clock size={14} />}>{time} local time</Line>}
117121 {card.workspaces.length > 0 && (
118122 <Line icon={<Building2 size={14} />}>
119123 Member of{" "}
+1−1
1010 | --- | --- |
1111 | 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. |
1212 | 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. |
1414 | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. |
1515 | 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. |
1616 | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. |
+21−7
22
33 import type { RepoPath, Viewer } from "@g1t/contracts";
44
5+import { LEGACY_KV_UNTIL } from "./artifacts";
56 import { actions } from "./services.server";
67
78 /**
89 * A run's artifacts, for its page. The actions service lists them (their
910 * bytes are in R2, downloaded through the API's signed links); artifacts an
1011 * 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).
1214 */
1315 export type ArtifactRow = {
1416 /** The artifact's number; null for one kept in KV. */
3537 }));
3638 }
3739
40+/** A run's artifacts the actions service keeps, which checks who may see them. */
3841 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 });
4343 const rows: ArtifactRow[] = kept.ok
4444 ? kept.value.artifacts.map((a) => ({ id: a.id, name: a.name, size: a.size, expiresAt: a.expires_at, createdAt: a.created_at }))
4545 : [];
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];
4658 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);
4860 }
49− return rows.sort((a, b) => a.name.localeCompare(b.name));
61+ return all.sort((a, b) => a.name.localeCompare(b.name));
5062 }
5163
5264 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;
5367 const base = `a/${run}/${name}`;
5468 const meta = await env.BLOBS.get<Meta>(base, "json");
5569 if (!meta) return null;
+13−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { expiresIn, formatBytes } from "./artifacts.ts";
4+import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "./artifacts.ts";
55
66 test("sizes read as KB, MB or GB", () => {
77 assert.equal(formatBytes(10), "1 KB");
1818 assert.equal(expiresIn("2026-10-08T11:00:00Z", now), "expired");
1919 assert.equal(expiresIn(null, now), "");
2020 });
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+});
+21−0
1616 if (days === 1) return "expires tomorrow";
1717 return `expires in ${days} days`;
1818 }
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+}
+178−0
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+});
+91−0
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+}
+8−0
1515 location: "London",
1616 website: "https://ada.example",
1717 pronouns: "she/her",
18+ timezone: "Europe/London",
1819 avatar: "cafe",
1920 createdAt: "2026-01-01T00:00:00Z",
2021 };
9798 assert.equal(hidden?.kind === "user" && hidden.committed, null);
9899 });
99100
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+
100108 test("a failing service leaves its part out, not the card", async () => {
101109 const card = await buildCard(
102110 "ada",
+3−0
2020 pronouns: string | null;
2121 bio: string | null;
2222 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;
2325 avatar: string | null;
2426 /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
2527 workspaces: { slug: string; name: string; avatar: string | null }[];
112114 pronouns: profile.pronouns,
113115 bio: profile.bio,
114116 location: profile.location,
117+ timezone: profile.timezone ?? null,
115118 avatar: profile.avatar,
116119 workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
117120 more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
+4−2
33 * answer not yet kept walks history, which can take seconds on a large or
44 * busy repository; the page goes out without it then, the column empty,
55 * 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.
810 */
911 import { waitUntil } from "cloudflare:workers";
1012
+15−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { distinctFacts } from "./memory-facts.ts";
4+import { distinctFacts, sameFact } from "./memory-facts.ts";
55
66 test("a fact remembered twice is shown once, the first kept", () => {
77 const facts = [
1313 assert.deepEqual(distinctFacts(facts).map((fact) => fact.id), ["a", "b"]);
1414 assert.deepEqual(distinctFacts([]), []);
1515 });
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+});
+45−11
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+
136 /**
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`).
642 */
743 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;
1549 }
+50−11
44 import {
55 type Merged,
66 placePushes,
7+ historyCovers,
78 change,
89 checksFact,
910 confidenceAsk,
2122 sortRows,
2223 stallReason,
2324 summaryLine,
25+ pushedByPerson,
2426 pushedCommits,
2527 waitingRows,
2628 weekOf,
175177 files: [],
176178 });
177179
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"]);
182186 // 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"]);
184188 });
185189
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+
186224 test("a change landed without a person when g1t merged it", () => {
187225 assert.ok(landedByAgents({ mergedBy: "g1t" }));
188226 assert.ok(landedByAgents({ mergedBy: "g1t" }));
373411 });
374412
375413 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 } });
377415 // 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")];
379417 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" } },
383421 ];
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), [
385424 { hash: "m1", at: "2026-10-05T12:00:00Z" },
386425 { hash: "w2", at: "2026-10-07T12:00:00Z" },
387426 { hash: "w1", at: "2026-10-07T12:00:00Z" },
+61−17
432432
433433 // --- Landed -----------------------------------------------------------------
434434
435−/** A merged pull request, as the week counts it. */
436435 /**
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.
441439 */
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[] }>(
443469 history: C[],
444470 before: string | undefined,
471+ byG1t: (commit: C) => boolean = () => false,
445472 ): C[] {
446473 const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
447474 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));
449476 }
450477
451478 /**
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.
456484 */
457−export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>(
485+export function placePushes<C extends { hash: string; parents: string[] }>(
458486 history: C[],
459− pushes: { time: string; data: { after: string; before?: string } }[],
487+ pushes: PushRecord[],
488+ byG1t: (commit: C) => boolean = () => false,
460489 ): { hash: string; at: string }[] {
461490 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>();
463493 for (const push of [...pushes].reverse()) {
464494 const start = index.get(push.data.after);
465495 if (start === undefined) continue;
466496 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);
469501 }
470502 }
471− return [...seen].map(([hash, at]) => ({ hash, at }));
503+ return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : []));
472504 }
473505
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. */
474518 export type Merged = {
475519 repo: RepoPath;
476520 number: number;
+38−0
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+});
+74−0
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+}
+5−4
5353 import { PolicyNotice } from "./components/policy-notice";
5454 import { readCookie } from "./lib/mission";
5555 import { WORKSPACE_COOKIE, workspaceFor } from "./lib/workspace-choice";
56+import { PageMain } from "./components/landmark";
5657 import { NotFound } from "./components/not-found";
5758 import { usesAppShell } from "./lib/chrome";
5859 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./components/command-palette";
578579 }, [reload, href]);
579580 if (reload) {
580581 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">
582583 <title>Loading · g1t</title>
583584 Loading the latest version of g1t…
584− </main>
585+ </PageMain>
585586 );
586587 }
587588
608609 }
609610
610611 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">
612613 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
613614 <p className="mt-3 text-muted">{details}</p>
614615 <div className="mt-8">
621622 <code>{stack}</code>
622623 </pre>
623624 )}
624− </main>
625+ </PageMain>
625626 );
626627 }
+19−6
3737 stuckMinutes,
3838 waitedFor,
3939 } from "../lib/mission";
40+import { G1T_COMMIT_EMAILS, normalizeEmail } from "../lib/commit-people";
4041 import {
4142 type Fact,
4243 type Merged,
5657 usd,
5758 waitingRows,
5859 weekOf,
60+ historyCovers,
5961 who,
6062 whyFor,
6163 withConfidence,
160162 */
161163 const PUSH_PAGE = 200;
162164 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;
164173 const HISTORY_READ = 1000;
165174
166175 /**
173182 const pushes: G1tEvent<"git.push">[] = [];
174183 let before: string | undefined;
175184 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 } : {}) });
177187 for (const event of batch) {
178188 if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">);
179189 }
180190 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;
182192 before = oldest.id;
183193 }
184194 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
186196 // what lies between its `after` and its `before` in that history.
187197 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);
189201 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)));
191204 }
192205
193206
+6−3
4040 import { Hint } from "../../components/ui/hint";
4141 import { useWorkflowReason } from "../../components/mirror";
4242 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";
4545 import { actions } from "../../lib/services.server";
4646 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
4747 import { accessTo, refusal } from "../../lib/access.server";
5858 // An earlier attempt, when one is asked for.
5959 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
6060 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([
6262 actions.run(repo, viewer, params.id, attempt).then(unwrap),
6363 listArtifacts(repo, viewer, params.id).catch(() => []),
6464 actions
6666 .then((found) => (found.ok ? found.value : []))
6767 .catch((): JobSummary[] => []),
6868 ]);
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;
6972 // Cancelling and re-running need Write.
7073 return {
7174 detail,
+26−8
5656 import { distinctFacts } from "../../lib/memory-facts";
5757 import { githubApp } from "../../lib/github.server";
5858 import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
59−import { ChangeSize } from "../../components/work";
59+import { ChangeSize, PersonLink } from "../../components/work";
6060 import {
6161 type ActivityItem,
6262 type Need,
11741174 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
11751175 <span className="font-mono text-faint">#{pull.number}</span>
11761176 <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. */}
11771180 <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+ )}
11841198 </span>
11851199 {pull.issue != null && (
11861200 <>
11901204 </Link>
11911205 </>
11921206 )}
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+ )}
11941212 <span>
11951213 · <TimeAgo at={pull.mergedAt ?? pull.updatedAt} />
11961214 </span>
+22−14
2323 } from "../../lib/session.server";
2424 import { useRefreshWhile } from "../../lib/refresh";
2525
26+/** The most of an outcome's activity the page shows. */
27+const ACTIVITY_LIMIT = 40;
2628
2729 export function meta({ params, ...args }: Route.MetaArgs) {
2830 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
4951 // What happened across the outcome's issues and pull requests.
5052 let activity: G1tEvent[] = [];
5153 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(() => []),
5569 ]);
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);
6775 // Events name accounts by id; show names.
6876 const named = await identity
6977 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
+1−0
3131 location: text("location"),
3232 website: text("website"),
3333 pronouns: text("pronouns"),
34+ timezone: text("timezone"),
3435 });
3536 return result.ok ? { profileSaved: true } : { profileError: result.error.message };
3637 }
+4−1
684684 `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated`
685685 (401), `payment_required` (402, when the plan or a limit refuses compute),
686686 `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
688690 operation's scope is `x-scope` in the OpenAPI document. The full description is at https://api.g1t.sh/openapi.json.
689691 - Git remote: `https://g1t.sh/{workspace}/{repo}.git`. In API paths,
690692 `{owner}` is the workspace. Pull request forks:
752754 - [Git](https://docs.g1t.sh/guides/git/)
753755 - [MCP tools](https://docs.g1t.sh/reference/mcp/)
754756 - [API reference](https://docs.g1t.sh/reference/api/)
757+- [Rate limits](https://docs.g1t.sh/reference/rate-limits/)
755758 - [What g1t can't do yet](https://docs.g1t.sh/about/limitations/)
756759 - [An open letter to Cloudflare](https://docs.g1t.sh/about/open-letter-to-cloudflare/)
757760 - [Source](https://g1t.sh/flagon-io/g1t), MIT licensed
+9−1
33 import { identityClient, isNamespaceShaped } from "@g1t/contracts";
44
55 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
6+import { gitLimited, pageLimited } from "../app/lib/front-door-limits";
67 import { goImport } from "../app/lib/go-get";
78 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
89 import { registryWorkspace, servicePath } from "../app/lib/registry-paths";
5051 }
5152 const service = servicePath(pathname);
5253 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);
5458 }
5559 if (service === "packages") {
5660 return proxyPackages(env, request);
6569 const target = MOVED_DOCS[page] ?? "/";
6670 return Response.redirect(DOCS + target, 301);
6771 }
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;
6876 // Every page and data request says where its time went (Server-Timing)
6977 // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts).
7078 const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request)));
+8−1
1−import type { RunnerApi, ServiceBinding } from "@g1t/contracts";
1+import type { RateLimitBinding, RunnerApi, ServiceBinding } from "@g1t/contracts";
22
33 declare global {
44 namespace Cloudflare {
4444 MCP_URL?: string;
4545 /** The social-card image service; an empty string for none. Unset on g1t.sh. */
4646 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;
4754 }
4855 }
4956 interface Env extends Cloudflare.Env {}
+13−1
4343 // Production screenshots for a project's overview (services/og).
4444 { "binding": "SCREENSHOTS", "service": "g1t-og", "entrypoint": "Screenshots" }
4545 ],
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 },
4759 "upload_source_maps": true
4860 }
+72−2
8585
8686 /// Whether it fires in the minute starting at `ms` since the epoch, UTC.
8787 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 {
8894 let minutes_total = ms / 60_000;
89− let minute = (minutes_total % 60) as usize;
9095 let hour = (minutes_total / 60 % 24) as usize;
9196 let days_since_epoch = (minutes_total / 60 / 24) as i64;
9297 // 1970-01-01 was a Thursday.
98103 (true, true) => day_ok || weekday_ok,
99104 _ => day_ok && weekday_ok,
100105 };
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
102107 }
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+ }
103133 }
104134
135+/// The shortest interval a workflow's schedule runs at, in minutes.
136+pub const MIN_INTERVAL_MINUTES: u64 = 5;
137+
105138 /// The date of a day counted from 1970-01-01 (Howard Hinnant's algorithm).
106139 fn civil_from_days(days: i64) -> (i64, u32, u32) {
107140 let z = days + 719_468;
168201 }
169202
170203 #[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]
171241 fn nonsense_is_refused() {
172242 for text in ["", "* * * *", "61 * * * *", "* * * * funday", "*/0 * * * *", "5-1 * * * *"] {
173243 assert!(Schedule::parse(text).is_err(), "{text}");
+11−2
124124 let concurrency = deploy.concurrency.as_ref().unwrap();
125125 assert_eq!(concurrency.group, "deploy-production");
126126 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"]);
128128 // The stages share their steps (a YAML alias), and read the token only
129129 // where they deploy.
130130 let steps = |id: &str| deploy.jobs.iter().find(|j| j.id == id).unwrap().steps.len();
168168 assert!(!starts_after(&deploy, "core", &skipped, &all, push.clone(), false, true));
169169 assert!(!starts_after(&deploy, "front", &skipped, &all, push.clone(), false, true));
170170
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")], &nothing, push.clone(), false));
178+
171179 // A dry run plans and stops.
172180 let dry = json!({ "dry_run": true, "units": "", "all": false });
173181 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));
175184 }
176185
177186 #[test]
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.