Skip to content

Commit

Merge branch 'fast-push' into mirroring

# Conflicts: # apps/api/src/operations.rs # apps/web/app/components/shell.tsx # apps/web/app/lib/access.ts # apps/web/app/routes/repo/issue-new.tsx # apps/web/app/routes/repo/pull-new.tsx

syntaqxcommitted Parents13c242bd9e761cBrowse files
300 files+1081−2000/300 viewed
+17−0
77 # typescript type checks and tests of the apps and TS services, the
88 # deploy and ops scripts, and the deploy manifest
99 # build the site, sudo and the docs build as they deploy
10+#
11+# On a push to main only `rust` runs, to keep main's caches current: a pull
12+# request's run restores from main's cache, never from another pull
13+# request's, so without it each pull request would start from nothing.
1014 name: CI
1115
1216 on:
1317 pull_request:
1418 branches: [main]
19+ push:
20+ branches: [main]
1521 workflow_dispatch:
1622
1723 # Its token only reads: it checks the code out and nothing more.
4753 !target/debug/incremental
4854 key: cargo-test-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
4955 restore-keys: cargo-test-${{ runner.os }}-
56+ # The workspace's library crates, which Cargo compiles again on every
57+ # checkout, come back from the repository's Actions cache when their
58+ # inputs did not change (scripts/sccache.sh). Test harnesses and
59+ # Workers' own crates are linked, and still compiled.
60+ - name: sccache
61+ run: bash scripts/sccache.sh install
5062 - name: Tests
5163 run: cargo test --workspace --locked --quiet
64+ - name: sccache's hits and misses
65+ if: ${{ always() }}
66+ run: bash scripts/sccache.sh stats
5267
5368 typescript:
5469 name: TypeScript
70+ if: ${{ github.event_name != 'push' }}
5571 runs-on: ubuntu-latest
5672 timeout-minutes: 30
5773 steps:
7187
7288 build:
7389 name: Build
90+ if: ${{ github.event_name != 'push' }}
7491 runs-on: ubuntu-latest
7592 timeout-minutes: 30
7693 steps:
+32−14
44 #
55 # check the deploy manifest is consistent, and the tool's tests pass
66 # plan what changed since each Worker's live commit, and pending migrations
7−# migrate pending D1 migrations, before any code
7+# (beside check, not after it: neither waits for the other)
8+# migrate pending D1 migrations, before any code, once check and plan pass
89 # core, edge, front the units of each stage, in jobs that share a build;
910 # a stage starts only when the one before it succeeded
1011 # smoke sign-in, sign-up and the waitlist still work on g1t.sh
7677 - name: The deploy tool's tests
7778 run: npm run test:deploy
7879
80+ # Runs beside check: nothing deploys until both have succeeded.
7981 plan:
8082 name: Plan
81− needs: check
8283 runs-on: ubuntu-latest
8384 # Production's secrets, without a deployment: planning deploys nothing.
8485 environment:
99100 with:
100101 # Each Worker's live commit is compared with this one.
101102 fetch-depth: 0
102− - name: Install Wrangler
103− run: npm ci --workspaces=false --no-audit --no-fund
103+ # No npm ci: with CLOUDFLARE_API_TOKEN the plan reads Cloudflare's API
104+ # itself (scripts/deploy/cloudflare.mjs), and needs no Wrangler.
104105 - name: Plan
105106 id: plan
106107 env:
115116
116117 migrate:
117118 name: Migrations
118− needs: plan
119+ needs: [check, plan]
119120 if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
120121 runs-on: ubuntu-latest
121122 environment:
133134
134135 core:
135136 name: core (${{ matrix.group }})
136− needs: [plan, migrate]
137+ needs: [check, plan, migrate]
137138 # Runs when nothing before it failed: a migrate job skipped for having
138139 # nothing to apply is not a failure.
139140 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
179180 key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
180181 restore-keys: cargo-crates-${{ runner.os }}-
181182 # The compiled dependencies of this job's units, for wasm32 and the
182− # build scripts and proc macros they run. The workspace's own crates
183− # are compiled again whatever is cached (a checkout's sources are
184− # newer), so an entry is saved only when the dependencies change: a
185− # new Cargo.lock, or a new base image (base.json names its Rust).
186− # Otherwise the nearest earlier entry, of any group, is a start.
183+ # build scripts and proc macros they run. Cargo calls rustc again for
184+ # the workspace's own crates whatever is cached (a checkout's sources
185+ # are newer); sccache, below, answers those calls. So an entry is
186+ # saved only when the dependencies change: a new Cargo.lock, or a new
187+ # base image (base.json names its Rust). Otherwise the nearest earlier
188+ # entry, of any group, is a start.
187189 - name: Cache the Cargo target
188190 if: ${{ matrix.rust }}
189191 uses: actions/cache@v4
214216 !target/**/incremental
215217 key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
216218 restore-keys: runner-musl-${{ runner.os }}-
219+ # Every rustc call that makes a library (the workspace's crates,
220+ # which Cargo compiles again on every checkout, and any dependency
221+ # not restored above) is looked up by its inputs in the repository's
222+ # Actions cache: unchanged crates come back from it instead of being
223+ # compiled. A Worker's own crate (a cdylib) and the runner's binary
224+ # are still compiled. worker-build runs Cargo, so its wasm32 builds
225+ # go through it too; wasm-bindgen and wasm-opt are not rustc.
226+ # Pinned by version and sha256 in scripts/sccache.sh; without the
227+ # cache, the job builds as before.
228+ - name: sccache
229+ if: ${{ matrix.rust || matrix.image }}
230+ run: bash scripts/sccache.sh install
217231 - name: Install
218232 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
219233 - name: Deploy ${{ matrix.units }}
223237 # are not drafted as incidents. Optional: without it, nothing is sent.
224238 STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }}
225239 run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2
240+ # Hits and misses, on the log and in the run's summary.
241+ - name: sccache's hits and misses
242+ if: ${{ always() && (matrix.rust || matrix.image) }}
243+ run: bash scripts/sccache.sh stats
226244
227245 edge:
228246 name: edge (${{ matrix.group }})
229− needs: [plan, migrate, core]
247+ needs: [check, plan, migrate, core]
230248 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
231249 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
232250 environment:
241259
242260 front:
243261 name: front (${{ matrix.group }})
244− needs: [plan, migrate, core, edge]
262+ needs: [check, plan, migrate, core, edge]
245263 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
246264 runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
247265 environment:
260278 # real waitlist is never touched. scripts/ops/smoke.mjs.
261279 smoke:
262280 name: Smoke
263− needs: [plan, core, edge, front]
281+ needs: [check, plan, core, edge, front]
264282 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') }}
265283 runs-on: ubuntu-latest
266284 timeout-minutes: 5
+1−0
11051105 "g1t-actions",
11061106 "g1t-scan",
11071107 "hex",
1108+ "libc",
11081109 "serde",
11091110 "serde_json",
11101111 "serde_yaml",
+30−24
11 # g1t
22
3−The open-source git platform where people and agents ship software
4−together, from the first issue to production on the edge. It runs on
5−Cloudflare Workers and Artifacts.
3+One open-source workspace where a team and its agents talk, work, write
4+things down and ship. No separate chat app, wiki or forge to stitch
5+together. It runs on Cloudflare Workers and Artifacts.
66
7−- **Collaborate.** Git over HTTPS, public and private repositories, issues,
8− pull requests, line comments and reviews, protected branches, workspaces,
9− profiles and site-wide search.
10−- **Agents as teammates.** Assign an issue to g1t or mention `@g1t`, or
11− connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an
12− outcome and a planner splits it into issues with dependencies that agents
13− take up as they unblock. Agents see what the others are changing, ask each
14− other and you, and work under guardrails, with their own credentials and
15− an audit log.
16−- **Ship safely.** Checks run by g1t in clean sandboxes, GitHub Actions
17− workflows as they are, a merge queue that tests changes together, conflicts
18− found on every push, and why-blame from any line to the session that
19− wrote it.
20−- **Run it.** A preview of every pull request and production on merge, on
21− `g1t.page`, with custom domains. Apps nobody visits cost nothing.
22−- **Secure and healthy.** Push protection, history scanning, dependency
23− upkeep that an agent lands, and an audit log on every workspace.
7+- **Chat.** Channels, direct messages and threads, live. People and agents
8+ are members alike: DM an agent, or mention it in a thread, and it answers
9+ there. Chat is included on every plan, with no seats and no history
10+ cutoff.
11+- **Agents.** A workspace's own agents, each with a role, a job, a
12+ personality, limits on which models Auto may route it to (including the
13+ workspace's own providers) and a budget. Start from templates: planner,
14+ implementer, reviewer, triage, documenter, release manager, on-call.
15+- **Docs** (coming soon). Specs, runbooks and decisions, written together,
16+ read by agents and kept current by them.
17+- **Code.** Git over HTTPS, issues, pull requests and reviews. Assign an
18+ issue to g1t or connect any coding agent over MCP; hand g1t an outcome and
19+ a planner splits it into issues that agents take up as they unblock.
20+ Checks run by g1t in clean sandboxes, workflows from `.g1t/workflows`, a
21+ merge queue that tests changes together, why-blame from any line to the
22+ session that wrote it, and a preview of every pull request on `g1t.page`.
23+- **Rails.** Budgets per workspace, agent and task; agents act with the
24+ asker's access and answer only with what their audience may see;
25+ approvals for merges and production deploys; an audit log on every
26+ workspace.
2427 - **Open and fair.** MIT licensed and self-hostable (an early Docker Compose
25− version of the core forge, in `deploy/self-host`). The forge is free; compute is what it costs
26− plus 20%, never per seat.
28+ version of the core forge, in `deploy/self-host`). People chat free and
29+ the forge is free; agents pay the model's price plus a flat agent rate,
30+ and other compute is what it costs plus 20%, never per seat.
31+
32+The plan for the workspace is [docs/WORKSPACE.md](docs/WORKSPACE.md).
2733
2834 g1t is made by Flagon, Inc. It is also an entry in Cloudflare's **Build the
2935 Next-Gen Git Platform** competition, which asks what a git platform looks
95101 and Message, and memory at two levels (project and workspace) that
96102 agents write and read.
97103 - Projects with deployments on g1t.page: a preview for every pull request,
98− production on merge, dependencies between projects, custom domains.
104+ production on merge, custom domains.
99105 - GitHub Actions workflows from `.g1t/workflows`, secrets and variables,
100106 webhooks and integrations (Sentry, Datadog, Jira, Linear).
101107 - Profiles, workspaces with display names, icons and renameable slugs.
142148 | `services/integrations` | Model providers, alerts, trackers and the GitHub App. | Rust |
143149 | `services/webhooks` | Webhook deliveries. | Rust |
144150 | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. | TypeScript |
145−| `services/projects` | Projects and the dependencies between them. | TypeScript |
151+| `services/projects` | Projects: what a workspace builds and runs, and where its code lives. | TypeScript |
146152 | `services/deployments` | Builds, previews and production on `g1t.page`. | TypeScript |
147153 | `services/pages` | Serves every app deployed on `g1t.page`, and custom domains. | TypeScript |
148154 | `services/models` | The model proxy at `models.g1t.sh`. | TypeScript |
+5−1
167167 None => Decision::allow("anonymous"),
168168 });
169169 record(op, services, viewer, input, &decision, Some(&outcome)).await;
170− Ok(outcome)
170+ // Every person in the answer with the case they chose (people.rs).
171+ Ok(match outcome {
172+ Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await),
173+ failed => failed,
174+ })
171175 }
172176
173177 /// Appends the entry, if this is something the log keeps. A failure to
+2−0
2222 mod oauth;
2323 mod oidc;
2424 mod packages;
25+mod people;
2526 mod openapi;
2627 mod pins;
2728 mod projects;
277278 DeviceClaim::Approved { token, user } => json!({
278279 "status": "approved",
279280 "token": token,
281+ "display_username": user.display_username.clone().filter(|display| display.eq_ignore_ascii_case(&user.username)).unwrap_or_else(|| user.username.clone()),
280282 "username": user.username,
281283 "verified": user.verified,
282284 }),
+10−3
6666 ),
6767 (
6868 "Personal access tokens",
69− "A workspace's rules for its members' personal access tokens: whether classic and fine-grained tokens reach it, whether fine-grained tokens wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.",
69+ "A workspace's rules for its members' personal access tokens: whether tokens made for all of a member's workspaces reach it, whether tokens may be made for it alone and wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.",
7070 &[
7171 Op::Tokens(TokenOp::GetTokenPolicy),
7272 Op::Tokens(TokenOp::SetTokenPolicy),
8383 ),
8484 (
8585 "Invites",
86− "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.",
86+ "While g1t is invite-only, every new account needs an invite. Your invites, inviting people into a workspace by username or email, and answering the invitations to workspaces sent to you.",
8787 &[
8888 Op::ListInvites,
8989 Op::CreateInvite,
9191 Op::ListWorkspaceInvites,
9292 Op::InviteMember,
9393 Op::RevokeWorkspaceInvite,
94+ Op::ListInvitations,
95+ Op::AcceptInvitation,
96+ Op::DeclineInvitation,
9497 ],
9598 ),
9699 (
566569 Op::RevokeInvite => "Revoke an invite",
567570 Op::ListWorkspaceInvites => "List a workspace's invites",
568571 Op::InviteMember => "Invite someone to a workspace",
572+ Op::ListInvitations => "List your workspace invitations",
573+ Op::AcceptInvitation => "Accept a workspace invitation",
574+ Op::DeclineInvitation => "Decline a workspace invitation",
569575 Op::RevokeWorkspaceInvite => "Revoke a workspace's invite",
570576 Op::TransferRepo => "Transfer a repository",
571577 Op::RenameRepo => "Rename a repository",
10601066 "properties": {
10611067 "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
10621068 "token": { "type": "string", "description": "Present when approved." },
1063− "username": { "type": "string" },
1069+ "username": { "type": "string", "description": "Lowercased: what the account is found and linked by." },
1070+ "display_username": { "type": "string", "description": "The username as its owner wrote it; the same as username when they chose no case." },
10641071 "verified": {
10651072 "type": "boolean",
10661073 "description": "Whether the account's email is confirmed.",
+86−12
124124 ListWorkspaceInvites,
125125 InviteMember,
126126 RevokeWorkspaceInvite,
127+ ListInvitations,
128+ AcceptInvitation,
129+ DeclineInvitation,
127130 ListRepos,
128131 GetRepo,
129132 CreateRepo,
687690 }
688691
689692 impl Op {
690− pub const ALL: [Op; 325] = [
693+ pub const ALL: [Op; 328] = [
691694 Op::Whoami,
692695 Op::GetWorkspace,
693696 Op::CreateWorkspace,
709712 Op::ListWorkspaceInvites,
710713 Op::InviteMember,
711714 Op::RevokeWorkspaceInvite,
715+ Op::ListInvitations,
716+ Op::AcceptInvitation,
717+ Op::DeclineInvitation,
712718 Op::ListRepos,
713719 Op::GetRepo,
714720 Op::CreateRepo,
10431049 Op::ListWorkspaceInvites => "list_workspace_invites",
10441050 Op::InviteMember => "invite_member",
10451051 Op::RevokeWorkspaceInvite => "revoke_workspace_invite",
1052+ Op::ListInvitations => "list_invitations",
1053+ Op::AcceptInvitation => "accept_invitation",
1054+ Op::DeclineInvitation => "decline_invitation",
10461055 Op::ListRepos => "list_repos",
10471056 Op::GetRepo => "get_repo",
10481057 Op::CreateRepo => "create_repo",
12401249 "Add an email address to your account. g1t emails it a link to confirm it; until then it cannot be primary and does not sign you in. Adding an address you added before and have not confirmed sends the link again. An address another account has confirmed cannot be added. An account has at most 10. Needs your account `password`; your confirmed addresses are told. People only."
12411250 }
12421251 Op::ConfirmEmail => {
1243− "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also joins the workspace its invite named, when the invite still applies: the answer's `joined` names it, or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only."
1252+ "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also invites it to the workspace its invite named, when the invite still applies: the answer's `invited_to` names it, and the invitation waits for you to accept or decline it (accept_invitation), or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only."
12441253 }
12451254 Op::RemoveEmail => {
12461255 "Remove an email address from your account. Never your primary address (make another primary first) and never your last confirmed one. Needs your account `password`; every confirmed address, the removed one included, is told. People only."
12491258 "Change what your addresses do; only the fields given change. `primary` is a confirmed address to make primary: account mail and password resets go there. `backup` is a confirmed address that also gets security notices, or an empty string for the primary only. Changing either needs your account `password`, and every confirmed address is told. `private_email` keeps your address off commits g1t makes for you (merges and changes made on the web, and agents' commits for you), which use your noreply address instead; `block_private_pushes` refuses pushes whose commits carry one of your addresses while it is private. People only."
12501259 }
12511260 Op::ListInvites => {
1252− "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you; `status` is pending, redeemed, expired or revoked."
1261+ "Your invites, newest first, and how many you have left. While g1t is invite-only, every new account needs an invite code. You may have 5 invites out at once: pending and used ones count, and one revoked or expired before it was used comes back. `allowance.limit` is null when you have no limit. `workspaces` lists the workspaces you own that were granted invites to share. A pending invite's `code` is shown to you. `status` is `pending`; `awaiting_confirmation` (used to make an account that has not confirmed its address yet); `awaiting_answer` (used to make an account that has yet to accept or decline the workspace it was invited to); `redeemed`; `declined` (its person declined the workspace); `expired`; or `revoked`. An invite that brings someone into a workspace names it in `workspace`, with the `role` it joins with and, once known, the account it is for in `invitee`."
12531262 }
12541263 Op::CreateInvite => {
1255− "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. It uses one of your invites, or with `workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites."
1264+ "Make an invite. With `email`, it is sent there and only that address can use it; without, anyone with the code can, once. It works for 30 days. With `workspace`, the new account is brought into that workspace: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. That must be a workspace you own on the g1t plan; a free workspace is refused with `payment_required` (402). Without `workspace`, the new account gets a free workspace of its own. It uses one of your invites, or with `charge_workspace`, one of the invites g1t granted that workspace (its owners only). Returns the invite with its `code`; the link is https://g1t.sh/invite/<code>. People only: an agent's token or a workspace's token cannot make invites."
12561265 }
12571266 Op::RevokeInvite => {
12581267 "Revoke a pending invite you made, or one made for a workspace you own. It stops working at once, and the invite comes back to whoever it was charged to."
12611270 "The invites made for a workspace, newest first, with each pending one's `code`. Owners only."
12621271 }
12631272 Op::InviteMember => {
1264− "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then."
1273+ "Invite someone into a workspace, by `username` or by `email`. Nobody joins without saying yes: they get an invitation to accept or decline, and join with `role` (`member` unless you give `owner`) when they accept. By `username`, the account gets the invitation in its inbox and by email, and it costs nothing. By `email`, it always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, the link makes the account, which is invited once it confirms its address; while g1t is invite-only that uses one of the workspace's granted invites, or else one of yours, and once anyone can sign up it costs nothing. With one, it costs nothing. Refused with `409` when the person is already a member or already has a pending invitation to the workspace. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then."
12651274 }
12661275 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
1276+ Op::ListInvitations => {
1277+ "The invitations to workspaces waiting for your answer, newest first: each one's `id`, the `workspace` (`slug`, `name`, `avatar`), the `role` accepting gives (`member` or `owner`), who sent it (`invited_by`, null when g1t staff did), and when it was made and when it expires. Expired, revoked and answered ones are left out. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list."
1278+ }
1279+ Op::AcceptInvitation => {
1280+ "Accept an invitation to a workspace sent to you. You join it at once with the role it names. Returns the workspace's slug in `workspace`. Refused with `404` when you have no open invitation with that id (it may have been answered, revoked or expired), with `403` until you confirm your email address or when your account does not meet what the workspace asks of its members, such as two-factor authentication, and with `payment_required` (402) while the workspace is free: it can add no one until it starts the g1t plan, and the invitation stays open until then. People only."
1281+ }
1282+ Op::DeclineInvitation => {
1283+ "Decline an invitation to a workspace sent to you. Whoever sent it is told in their inbox, and the workspace's owners can invite you again. People only."
1284+ }
12671285 Op::DeleteWorkspace => {
12681286 "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted."
12691287 }
12741292 "Change a workspace's display name and description, what every member gets on each of its repositories (base_permission: none, read, write or admin), who may create its teams (team_creation: members or owners), its member privileges, and whether it requires two-factor authentication. The member privileges are: members_can_create_public_repositories and members_can_create_private_repositories (who may create each kind; owners always can), members_can_change_repo_visibility (members with the Admin role on a repository may make it public or private), members_can_delete_repositories (they may delete or transfer it) and members_can_invite_outside_collaborators (they may give a role to someone outside the workspace). two_factor_requirement_enabled true holds every member and outside collaborator without two-factor authentication out of the workspace until they turn it on; you need it on yourself first. Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now."
12751293 }
12761294 Op::ListMembers => {
1277− "A workspace's members, owners first, then by username. Each has their `username`, `name`, `avatar`, `role` (`owner` or `member`), the roles they hold besides it (`org_roles`: `billing_manager`, `security_manager`), and, when an owner asks, whether they have two-factor authentication on (`two_factor`; null for anyone else). Members only."
1295+ "A workspace's members, owners first, then by username. Each has their `username`, `display_username` (the username as they wrote it), `name`, `avatar`, `role` (`owner` or `member`), the roles they hold besides it (`org_roles`: `billing_manager`, `security_manager`), and, when an owner asks, whether they have two-factor authentication on (`two_factor`; null for anyone else). Members only."
12781296 }
12791297 Op::UpdateMember => {
12801298 "Change a member's role in a workspace: `role` (`owner` or `member`) and the roles they hold besides it (`org_roles`, a list of `billing_manager` and `security_manager`, which replaces the one they have). Only the fields given are changed. A billing manager manages the workspace's billing as an owner does, and gets nothing on repositories from it; a security manager reads every repository and sees and manages its security alerts and security settings. Refused with `409` when it would leave the workspace without an owner. Owners only, signed in as a person. Returns the member."
13661384 "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it. Filter by state, by a label's name, or by a milestone's number."
13671385 }
13681386 Op::GetIssue => {
1369− "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried."
1387+ "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried. A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`."
13701388 }
13711389 Op::CreateIssue => {
13721390 "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role."
14491467 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed. Filter by a label's name, a milestone's number, or base, the branch they merge into."
14501468 }
14511469 Op::GetPullRequest => {
1452− "A pull request's status, base (the branch it merges into), head commit, labels, milestone, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the rules of the branch it merges into require, as success, failure, pending or expected when nothing has reported it yet), rules (each rule of that branch it does not meet yet, with the ruleset it comes from, what is wrong and how to meet it, in `unmet`; those you may bypass in `bypassable`; those of rulesets in evaluate that would refuse it in `evaluate`; and whether merging joins the merge queue), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files. `pull.reviewers` lists the people asked to review it and `pull.team_reviewers` the teams, as `workspace/team`. `code_owners` is there when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required`, `reviews` (one per section and rule that owns a changed file, with its `section`, `line`, `pattern`, `owners`, `files`, whether it is `optional`, the approvals `required`, who it was `approved_by` and `changes_requested_by`, and whether it is `satisfied`), what is still `missing`, and how many `errors` the file has (get_codeowners_errors lists them)."
1470+ "A pull request's status, base (the branch it merges into), head commit, labels, milestone, comments and reviews, the issue it is for, its checks (statuses: what each workflow run reported on its head, with a link to the run; get_workflow_run and get_job_logs say why one failed), required_checks (each check the rules of the branch it merges into require, as success, failure, pending or expected when nothing has reported it yet), rules (each rule of that branch it does not meet yet, with the ruleset it comes from, what is wrong and how to meet it, in `unmet`; those you may bypass in `bypassable`; those of rulesets in evaluate that would refuse it in `evaluate`; and whether merging joins the merge queue), whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files. `pull.reviewers` lists the people asked to review it and `pull.team_reviewers` the teams, as `workspace/team`. `code_owners` is there when the branch it merges into has a CODEOWNERS file: its `path`, whether code owners' approval is `required`, `reviews` (one per section and rule that owns a changed file, with its `section`, `line`, `pattern`, `owners`, `files`, whether it is `optional`, the approvals `required`, who it was `approved_by` and `changes_requested_by`, and whether it is `satisfied`), what is still `missing`, and how many `errors` the file has (get_codeowners_errors lists them). A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`. An agent's review also has `advisory: true`: its `verdict` (none, for a review that only comments) is shown but never counts toward required approvals or code owners, and never blocks a merge."
14531471 }
14541472 Op::CreatePullRequest => {
14551473 "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once. It merges into the default branch unless base names another existing branch; leave base out unless you were asked for another."
18481866 },
18491867 "workspace": {
18501868 "type": "string",
1869+ "description": "The workspace the new account is invited to, by slug. Once it confirms its address it gets an invitation to join as a member, and no workspace of its own. One you own, on the g1t plan.",
1870+ },
1871+ "charge_workspace": {
1872+ "type": "string",
18511873 "description": "Use one of the invites g1t granted this workspace instead of yours, by slug. Owners only.",
18521874 },
18531875 }),
18611883 Op::InviteMember => object(
18621884 json!({
18631885 "workspace": workspace_schema(),
1864− "email": { "type": "string", "description": "The address to invite." },
1886+ "username": {
1887+ "type": "string",
1888+ "description": "A g1t username to invite. Give this or email.",
1889+ },
1890+ "email": { "type": "string", "description": "An address to invite. Give this or username." },
1891+ "role": {
1892+ "type": "string",
1893+ "enum": ["member", "owner"],
1894+ "description": "The role they join with when they accept. member when left out.",
1895+ },
1896+ }),
1897+ &["workspace"],
1898+ ),
1899+ Op::ListInvitations => object(json!({}), &[]),
1900+ Op::AcceptInvitation | Op::DeclineInvitation => object(
1901+ json!({
1902+ "id": { "type": "string", "description": "The invitation's id, from list_invitations." },
18651903 }),
1866− &["workspace", "email"],
1904+ &["id"],
18671905 ),
18681906 Op::RevokeWorkspaceInvite => object(
18691907 json!({
33443382 | Op::ListWorkspaceInvites
33453383 | Op::InviteMember
33463384 | Op::RevokeWorkspaceInvite
3385+ | Op::ListInvitations
3386+ | Op::AcceptInvitation
3387+ | Op::DeclineInvitation
33473388 | Op::ListDeletedRepos
33483389 | Op::SearchContext
33493390 | Op::GetEntity
36593700 &json!({
36603701 "user": actor(),
36613702 "email": optional_text(input, "email"),
3662− "workspace": optional_text(input, "workspace"),
3703+ "workspace": optional_text(input, "charge_workspace"),
3704+ "join": optional_text(input, "workspace"),
36633705 "surface": services.audit.surface,
36643706 }),
36653707 )
36723714 pass(identity, "workspace_invites", &json!({ "slug": workspace(), "viewer": viewer })).await
36733715 }
36743716 Op::InviteMember => {
3717+ let role = optional_text(input, "role");
3718+ if role.as_deref().is_some_and(|role| role != "member" && role != "owner") {
3719+ return failed(FailureCode::Invalid, "role is member or owner.");
3720+ }
36753721 pass(
36763722 identity,
36773723 "invite_member",
36783724 &json!({
36793725 "actor": actor(),
36803726 "slug": workspace(),
3681− "email": text(input, "email"),
3727+ "email": optional_text(input, "email").unwrap_or_default(),
3728+ "username": optional_text(input, "username"),
3729+ "role": role,
36823730 "surface": services.audit.surface,
36833731 }),
36843732 )
36853733 .await
36863734 }
3735+ Op::ListInvitations => {
3736+ let waiting: Vec<g1t_contracts::identity::WorkspaceInvitation> =
3737+ g1t_kit::call(identity, "list_invitations", &json!({ "user": actor() })).await?;
3738+ ok(&waiting)
3739+ }
3740+ Op::AcceptInvitation => {
3741+ let joined: Outcome<String> = call(
3742+ identity,
3743+ "accept_invitation",
3744+ &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }),
3745+ )
3746+ .await?;
3747+ match joined {
3748+ Outcome::Ok(slug) => ok(&json!({ "workspace": slug })),
3749+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
3750+ }
3751+ }
3752+ Op::DeclineInvitation => {
3753+ pass(
3754+ identity,
3755+ "decline_invitation",
3756+ &json!({ "user": actor(), "id": text(input, "id"), "surface": services.audit.surface }),
3757+ )
3758+ .await
3759+ }
36873760 Op::RevokeWorkspaceInvite => {
36883761 pass(
36893762 identity,
46374710 .unwrap_or_else(|| if g1t_contracts::rules::is_agent(&user) { "agent".into() } else { user.username.clone() }),
46384711 runtime: Runtime::External,
46394712 base: optional_text(input, "base"),
4713+ draft: input.get("draft").and_then(|value| value.as_bool()).unwrap_or(false),
46404714 },
46414715 )
46424716 .await?;
+139−0
1+//! Usernames as their owners wrote them. Every person in an answer, the
2+//! REST API's and MCP's alike (an issue's `author`, a member, a
3+//! collaborator, whoever `whoami` is), carries `display_username` beside
4+//! `username`: `username` stays the lowercased key, stable for anything
5+//! that matches on it, and `display_username` is the case its owner chose
6+//! (`Ana`), the same as `username` when they chose none.
7+//!
8+//! The services keep the lowercased name; this fills the chosen case in on
9+//! the way out, with one call to identity per answer.
10+
11+use std::collections::{BTreeSet, HashMap};
12+
13+use g1t_contracts::identity::DisplayUsernamesArgs;
14+use serde_json::Value;
15+
16+use crate::operations::Services;
17+
18+/// The most people one answer looks up; the rest show `username` as it is.
19+const MOST: usize = 200;
20+
21+/// Whether `object` is a person (or another principal) named by `username`,
22+/// rather than a set of credentials that happens to carry one.
23+fn names_someone(object: &serde_json::Map<String, Value>) -> bool {
24+ matches!(object.get("username"), Some(Value::String(name)) if !name.is_empty())
25+ && !object.contains_key("password")
26+}
27+
28+/// The lowercased usernames in `value`, each once, at most [`MOST`].
29+pub fn usernames_in(value: &Value) -> Vec<String> {
30+ fn walk(value: &Value, out: &mut BTreeSet<String>) {
31+ match value {
32+ Value::Object(object) => {
33+ if names_someone(object)
34+ && let Some(Value::String(name)) = object.get("username")
35+ {
36+ out.insert(name.to_lowercase());
37+ }
38+ object.values().for_each(|value| walk(value, out));
39+ }
40+ Value::Array(items) => items.iter().for_each(|value| walk(value, out)),
41+ _ => {}
42+ }
43+ }
44+ let mut out = BTreeSet::new();
45+ walk(value, &mut out);
46+ out.into_iter().take(MOST).collect()
47+}
48+
49+/// Gives every person in `value` a `display_username`: the case from
50+/// `chosen` (by lowercased username), one they already carry when it is
51+/// the same name, or else `username` itself.
52+pub fn fill(value: &mut Value, chosen: &HashMap<String, String>) {
53+ match value {
54+ Value::Object(object) => {
55+ if names_someone(object) {
56+ let username = object["username"].as_str().unwrap_or_default().to_owned();
57+ // A service's own `displayUsername` (a profile's) becomes this.
58+ let theirs = object.remove("displayUsername");
59+ let carried = object
60+ .get("display_username")
61+ .or(theirs.as_ref())
62+ .and_then(Value::as_str)
63+ .filter(|display| display.eq_ignore_ascii_case(&username))
64+ .map(str::to_owned);
65+ let shown = chosen
66+ .get(&username.to_lowercase())
67+ .cloned()
68+ .or(carried)
69+ .unwrap_or(username);
70+ object.insert("display_username".to_owned(), Value::String(shown));
71+ }
72+ object.values_mut().for_each(|value| fill(value, chosen));
73+ }
74+ Value::Array(items) => items.iter_mut().for_each(|value| fill(value, chosen)),
75+ _ => {}
76+ }
77+}
78+
79+/// `value` with each person's chosen case filled in. When identity does
80+/// not answer, the people are still given `display_username`, as their
81+/// `username`: an answer is never held up for it.
82+pub async fn name_people(services: &Services, mut value: Value) -> Value {
83+ let usernames = usernames_in(&value);
84+ if usernames.is_empty() {
85+ return value;
86+ }
87+ let chosen: HashMap<String, String> =
88+ g1t_kit::call(&services.identity, "display_usernames", &DisplayUsernamesArgs { usernames })
89+ .await
90+ .unwrap_or_default();
91+ fill(&mut value, &chosen);
92+ value
93+}
94+
95+#[cfg(test)]
96+mod tests {
97+ use super::*;
98+ use serde_json::json;
99+
100+ #[test]
101+ fn every_person_in_an_answer_is_found_once() {
102+ let answer = json!({
103+ "issue": { "author": { "id": "usr_1", "username": "ana" }, "assignees": [{ "username": "bo" }, { "username": "ana" }] },
104+ "git": { "username": "ana", "password": "your g1t access token" },
105+ "count": 3,
106+ });
107+ assert_eq!(usernames_in(&answer), vec!["ana".to_owned(), "bo".to_owned()]);
108+ }
109+
110+ #[test]
111+ fn people_get_the_case_they_chose_or_their_username() {
112+ let mut answer = json!({
113+ "author": { "id": "usr_1", "username": "ana" },
114+ "members": [{ "username": "bo" }, { "username": "cy", "display_username": "Cy" }],
115+ "git": { "username": "ana", "password": "your g1t access token" },
116+ });
117+ let chosen = HashMap::from([("ana".to_owned(), "Ana".to_owned())]);
118+ fill(&mut answer, &chosen);
119+ assert_eq!(answer["author"]["username"], "ana");
120+ assert_eq!(answer["author"]["display_username"], "Ana");
121+ assert_eq!(answer["members"][0]["display_username"], "bo");
122+ assert_eq!(answer["members"][1]["display_username"], "Cy");
123+ assert!(answer["git"].get("display_username").is_none());
124+ }
125+
126+ #[test]
127+ fn a_carried_case_of_another_name_is_not_kept() {
128+ let mut answer = json!({ "username": "ana", "display_username": "Bob" });
129+ fill(&mut answer, &HashMap::new());
130+ assert_eq!(answer["display_username"], "ana");
131+ }
132+
133+ #[test]
134+ fn a_profiles_own_spelling_is_folded_in() {
135+ let mut answer = json!({ "username": "ana", "displayUsername": "Ana" });
136+ fill(&mut answer, &HashMap::new());
137+ assert_eq!(answer, json!({ "username": "ana", "display_username": "Ana" }));
138+ }
139+}
+190−52
2020 "status": "approved",
2121 "token": "g1t_…",
2222 "username": "syntaqx",
23+ "display_username": "syntaqx",
2324 "verified": true
2425 }
2526 },
2728 "response": {
2829 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
2930 "username": "syntaqx",
31+ "display_username": "syntaqx",
3032 "kind": "user",
3133 "verified": true,
3234 "workspaces": [
134136 "response": [
135137 {
136138 "username": "ada",
139+ "display_username": "Ada",
137140 "role": "owner",
138141 "org_roles": [],
139142 "two_factor": true,
142145 },
143146 {
144147 "username": "grace",
148+ "display_username": "grace",
145149 "role": "member",
146150 "org_roles": [
147151 "security_manager"
166170 },
167171 "response": {
168172 "username": "grace",
173+ "display_username": "grace",
169174 "role": "member",
170175 "org_roles": [
171176 "security_manager"
290295 },
291296 "response": {
292297 "username": "ada",
298+ "display_username": "Ada",
293299 "email": "ada@example.com",
294300 "verified": true,
295− "joined": "acme",
301+ "joined": null,
302+ "invited_to": "acme",
296303 "invite_lapsed": null
297304 },
298− "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `joined` is set when confirming a new account's address joined the workspace its invite named; when the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)."
305+ "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `invited_to` is set when the invite the account signed up with brings it into a workspace: confirming the address sends the account an invitation to that workspace, which it accepts with `POST /user/invitations/{id}/accept` or declines; nobody joins a workspace without saying yes. `joined` is kept for older clients and is null. When the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)."
299306 },
300307 "remove_email": {
301308 "request": {
409416 "created_at": "2026-10-05T17:00:00.000Z",
410417 "expires_at": "2026-11-04T17:00:00.000Z",
411418 "redeemed_at": null,
412− "revoked_at": null
419+ "revoked_at": null,
420+ "invitee": null,
421+ "role": null
413422 },
414423 {
415424 "id": "inv_01kp1v3c4d5e6f7g8h9j0k1m2n",
425434 "created_at": "2026-10-02T09:12:40.000Z",
426435 "expires_at": "2026-11-01T09:12:40.000Z",
427436 "redeemed_at": "2026-10-02T11:30:05.000Z",
428− "revoked_at": null
437+ "revoked_at": null,
438+ "invitee": "grace",
439+ "role": null
429440 }
430441 ]
431442 },
432− "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)."
443+ "notes": "`allowance.limit` and `allowance.remaining` are null when you have no limit. A revoked or expired invite that was never used is not counted. `status` is `pending`, `awaiting_confirmation`, `awaiting_answer` (the account it made has yet to accept or decline the workspace in `workspace`), `redeemed`, `declined`, `expired` or `revoked`. Ask for more at hey@flagon.io with the subject `[g1t Invites]`. See [Invites](/guides/authentication/#invites)."
433444 },
434445 "create_invite": {
435446 "request": {
436− "email": "ada@example.com"
447+ "email": "ada@example.com",
448+ "workspace": "acme-labs"
437449 },
438450 "response": {
439451 "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
441453 "hint": "g1t-k7m2",
442454 "email": "ada@example.com",
443455 "kind": "account",
444− "workspace": null,
456+ "workspace": "acme-labs",
445457 "status": "pending",
446458 "charged_to": "user",
447459 "invited_by": "syntaqx",
449461 "created_at": "2026-10-05T17:00:00.000Z",
450462 "expires_at": "2026-11-04T17:00:00.000Z",
451463 "redeemed_at": null,
452− "revoked_at": null
464+ "revoked_at": null,
465+ "invitee": null,
466+ "role": "member"
453467 },
454− "notes": "Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`."
468+ "notes": "`workspace` brings the new account into a workspace you own: once it confirms its address it gets an invitation to join as a member, which it accepts or declines, and no workspace of its own is made for it. Leave `workspace` out and the new account gets a free workspace of its own. A workspace on the free plan cannot bring anyone in: `402` with `payment_required`, until it starts the g1t plan. `charge_workspace` is separate: it uses one of the invites g1t granted that workspace instead of one of yours, and works with or without `workspace`. Refused with `402` (`limit`) when you have no invites left, with `409` when you already have a pending invite for that address or it already has a g1t account, and with `403` for an agent's or a workspace's token. The code is returned in full; send people the link `https://g1t.sh/invite/<code>`."
455469 },
456470 "revoke_invite": {
457471 "response": {
468482 "created_at": "2026-10-05T17:00:00.000Z",
469483 "expires_at": "2026-11-04T17:00:00.000Z",
470484 "redeemed_at": null,
471− "revoked_at": "2026-10-06T08:00:00.000Z"
485+ "revoked_at": "2026-10-06T08:00:00.000Z",
486+ "invitee": null,
487+ "role": null
472488 }
473489 },
474490 "list_workspace_invites": {
487503 "created_at": "2026-10-05T17:00:00.000Z",
488504 "expires_at": "2026-11-04T17:00:00.000Z",
489505 "redeemed_at": null,
490− "revoked_at": null
506+ "revoked_at": null,
507+ "invitee": null,
508+ "role": "member"
491509 },
492510 {
493511 "id": "inv_01kp1z9y8x7w6v5t4s3r2q1p0n",
494512 "code": "g1t-k7m2-q9xd-4hpw-…",
495513 "hint": "g1t-w2vb",
496− "email": "linus@example.com",
514+ "email": null,
497515 "kind": "workspace",
498516 "workspace": "acme-labs",
499517 "status": "pending",
503521 "created_at": "2026-10-05T17:00:00.000Z",
504522 "expires_at": "2026-11-04T17:00:00.000Z",
505523 "redeemed_at": null,
506− "revoked_at": null
524+ "revoked_at": null,
525+ "invitee": "linus",
526+ "role": "owner"
507527 }
508528 ]
509529 },
510530 "invite_member": {
511531 "request": {
512− "email": "ada@example.com"
532+ "username": "ada",
533+ "role": "member"
513534 },
514535 "response": {
515536 "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
516537 "code": "g1t-k7m2-q9xd-4hpw-…",
517538 "hint": "g1t-k7m2",
518− "email": "ada@example.com",
519− "kind": "account",
539+ "email": null,
540+ "kind": "workspace",
520541 "workspace": "acme-labs",
521542 "status": "pending",
522− "charged_to": "workspace",
543+ "charged_to": "none",
523544 "invited_by": "syntaqx",
524545 "redeemed_by": null,
525546 "created_at": "2026-10-05T17:00:00.000Z",
526547 "expires_at": "2026-11-04T17:00:00.000Z",
527548 "redeemed_at": null,
528− "revoked_at": null
549+ "revoked_at": null,
550+ "invitee": "ada",
551+ "role": "member"
529552 },
530− "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan."
553+ "notes": "Give `username` or `email`. Nobody is added without saying yes: the person gets an invitation to accept or decline, and joins with `role` when they accept. By username, `invitee` names the account and it costs nothing; `404` when there is no account with that username. By email, `kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when the link makes one; both answers look alike to the caller on purpose, and `invitee` stays null until an account is known. `409` when the person is already a member or already has a pending invitation to the workspace. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan. See [Workspace invitations](/reference/api/invites/list-invitations/) for the other side."
531554 },
532555 "revoke_workspace_invite": {
533556 "response": {
544567 "created_at": "2026-10-05T17:00:00.000Z",
545568 "expires_at": "2026-11-04T17:00:00.000Z",
546569 "redeemed_at": null,
547− "revoked_at": "2026-10-06T08:00:00.000Z"
570+ "revoked_at": "2026-10-06T08:00:00.000Z",
571+ "invitee": null,
572+ "role": "member"
548573 }
549574 },
575+ "list_invitations": {
576+ "response": [
577+ {
578+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y",
579+ "workspace": {
580+ "slug": "acme-labs",
581+ "name": "Acme Labs",
582+ "avatar": null
583+ },
584+ "role": "member",
585+ "invited_by": {
586+ "username": "syntaqx",
587+ "display_username": "syntaqx",
588+ "name": "Chase",
589+ "avatar": null
590+ },
591+ "created_at": "2026-10-05T17:00:00.000Z",
592+ "expires_at": "2026-11-04T17:00:00.000Z"
593+ }
594+ ],
595+ "notes": "Accept one with `POST /user/invitations/{id}/accept`, or decline it with `POST /user/invitations/{id}/decline`. An agent's or a workspace's token gets an empty list."
596+ },
597+ "accept_invitation": {
598+ "params": {
599+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y"
600+ },
601+ "response": {
602+ "workspace": "acme-labs"
603+ },
604+ "notes": "You join at once with the invitation's `role`. `404` when you have no open invitation with that id (it may have been answered, revoked or expired). `403` until you confirm your email address, and when your account does not meet what the workspace asks of its members, such as two-factor authentication, with a message that says what to turn on. `402` with `payment_required` while the workspace is on the free plan; the invitation stays open until it starts the g1t plan. Recorded as `member.added`."
605+ },
606+ "decline_invitation": {
607+ "params": {
608+ "id": "inv_01kp2x7m8n9q0r1s2t3v4w5x6y"
609+ },
610+ "response": true,
611+ "notes": "Whoever sent it is told in their inbox. The workspace's owners can invite you again. `404` when you have no open invitation with that id."
612+ },
550613 "list_repos": {
551614 "query": {
552615 "q": "hello"
9971060 "author": {
9981061 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
9991062 "username": "syntaqx",
1063+ "display_username": "syntaqx",
10001064 "kind": "user",
10011065 "verified": false,
10021066 "workspaces": []
10391103 "author": {
10401104 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
10411105 "username": "syntaqx",
1106+ "display_username": "syntaqx",
10421107 "kind": "user",
10431108 "verified": false,
10441109 "workspaces": []
10741139 "author": {
10751140 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
10761141 "username": "syntaqx",
1142+ "display_username": "syntaqx",
10771143 "kind": "user",
10781144 "verified": false,
10791145 "workspaces": []
11221188 "author": {
11231189 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
11241190 "username": "syntaqx",
1191+ "display_username": "syntaqx",
11251192 "kind": "user",
11261193 "verified": false,
11271194 "workspaces": []
11691236 "author": {
11701237 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
11711238 "username": "syntaqx",
1239+ "display_username": "syntaqx",
11721240 "kind": "user",
11731241 "verified": false,
11741242 "workspaces": []
11851253 "author": {
11861254 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
11871255 "username": "syntaqx",
1256+ "display_username": "syntaqx",
11881257 "kind": "user",
11891258 "verified": false,
11901259 "workspaces": []
12261295 "author": {
12271296 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
12281297 "username": "syntaqx",
1298+ "display_username": "syntaqx",
12291299 "kind": "user",
12301300 "verified": false,
12311301 "workspaces": []
12671337 "author": {
12681338 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
12691339 "username": "syntaqx",
1340+ "display_username": "syntaqx",
12701341 "kind": "user",
12711342 "verified": false,
12721343 "workspaces": []
13001371 "author": {
13011372 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
13021373 "username": "syntaqx",
1374+ "display_username": "syntaqx",
13031375 "kind": "user",
13041376 "verified": false,
13051377 "workspaces": []
13521424 "author": {
13531425 "id": "usr_g1t_agent",
13541426 "username": "g1t",
1427+ "display_username": "g1t",
13551428 "kind": "agent",
13561429 "verified": false,
13571430 "workspaces": []
13591432 "requested_by": {
13601433 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
13611434 "username": "syntaqx",
1435+ "display_username": "syntaqx",
13621436 "kind": "user",
13631437 "verified": false,
13641438 "workspaces": []
13881462 "author": {
13891463 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
13901464 "username": "syntaqx",
1465+ "display_username": "syntaqx",
13911466 "kind": "user",
13921467 "verified": false,
13931468 "workspaces": []
14351510 "author": {
14361511 "id": "usr_g1t_agent",
14371512 "username": "g1t",
1513+ "display_username": "g1t",
14381514 "kind": "agent",
14391515 "verified": false,
14401516 "workspaces": []
14421518 "requested_by": {
14431519 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
14441520 "username": "syntaqx",
1521+ "display_username": "syntaqx",
14451522 "kind": "user",
14461523 "verified": false,
14471524 "workspaces": []
14691546 "author": {
14701547 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
14711548 "username": "syntaqx",
1549+ "display_username": "syntaqx",
14721550 "kind": "user",
14731551 "verified": true,
14741552 "workspaces": [
14981576 "author": {
14991577 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
15001578 "username": "syntaqx",
1579+ "display_username": "syntaqx",
15011580 "kind": "user",
15021581 "verified": true,
15031582 "workspaces": [
16041683 "author": {
16051684 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
16061685 "username": "syntaqx",
1686+ "display_username": "syntaqx",
16071687 "kind": "user",
16081688 "verified": false,
16091689 "workspaces": []
16661746 "author": {
16671747 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
16681748 "username": "syntaqx",
1749+ "display_username": "syntaqx",
16691750 "kind": "user",
16701751 "verified": false,
16711752 "workspaces": []
17191800 "author": {
17201801 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
17211802 "username": "syntaqx",
1803+ "display_username": "syntaqx",
17221804 "kind": "user",
17231805 "verified": false,
17241806 "workspaces": []
17701852 "author": {
17711853 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
17721854 "username": "syntaqx",
1855+ "display_username": "syntaqx",
17731856 "kind": "user",
17741857 "verified": false,
17751858 "workspaces": []
18271910 "author": {
18281911 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
18291912 "username": "syntaqx",
1913+ "display_username": "syntaqx",
18301914 "kind": "user",
18311915 "verified": false,
18321916 "workspaces": []
18541938 "author": {
18551939 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
18561940 "username": "syntaqx",
1941+ "display_username": "syntaqx",
18571942 "kind": "user",
18581943 "verified": false,
18591944 "workspaces": []
18771962 "author": {
18781963 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
18791964 "username": "syntaqx",
1965+ "display_username": "syntaqx",
18801966 "kind": "user",
18811967 "verified": false,
18821968 "workspaces": []
18861972 "line": null,
18871973 "verdict": null,
18881974 "created_at": "2026-10-01T18:33:10.420Z"
1975+ },
1976+ {
1977+ "id": "cmt_01m43sw2a6c0e4h8k2n6r0v4z8",
1978+ "kind": "comment",
1979+ "author": {
1980+ "id": "agt_01m43q8d2f6h0k4n8r2v6z0c4g",
1981+ "username": "margo",
1982+ "kind": "agent",
1983+ "verified": false,
1984+ "workspaces": []
1985+ },
1986+ "body": "The empty-name case is handled and tested. One nit: trim the name before using it.",
1987+ "path": null,
1988+ "line": null,
1989+ "verdict": "approve",
1990+ "created_at": "2026-10-01T18:36:41.207Z",
1991+ "agent": {
1992+ "id": "agt_01m43q8d2f6h0k4n8r2v6z0c4g",
1993+ "handle": "margo",
1994+ "display_name": "Margo",
1995+ "avatar_seed": "margo"
1996+ },
1997+ "acting_for": {
1998+ "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
1999+ "username": "syntaqx",
2000+ "kind": "user",
2001+ "verified": false,
2002+ "workspaces": []
2003+ },
2004+ "advisory": true
18892005 }
18902006 ],
18912007 "checks": null,
20412157 "author": {
20422158 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
20432159 "username": "syntaqx",
2160+ "display_username": "syntaqx",
20442161 "kind": "user",
20452162 "verified": false,
20462163 "workspaces": []
20612178 "author": {
20622179 "id": "usr_01kz9d3f7h1k5n9r3v7z1c5g9b",
20632180 "username": "ana",
2181+ "display_username": "Ana",
20642182 "kind": "user",
20652183 "verified": true,
20662184 "workspaces": [
21312249 "author": {
21322250 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
21332251 "username": "syntaqx",
2252+ "display_username": "syntaqx",
21342253 "kind": "user",
21352254 "verified": false,
21362255 "workspaces": []
21922311 "author": {
21932312 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
21942313 "username": "syntaqx",
2314+ "display_username": "syntaqx",
21952315 "kind": "user",
21962316 "verified": false,
21972317 "workspaces": []
22452365 "author": {
22462366 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
22472367 "username": "syntaqx",
2368+ "display_username": "syntaqx",
22482369 "kind": "user",
22492370 "verified": false,
22502371 "workspaces": []
22932414 "author": {
22942415 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
22952416 "username": "syntaqx",
2417+ "display_username": "syntaqx",
22962418 "kind": "user",
22972419 "verified": false,
22982420 "workspaces": []
23402462 "author": {
23412463 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
23422464 "username": "syntaqx",
2465+ "display_username": "syntaqx",
23432466 "kind": "user",
23442467 "verified": false,
23452468 "workspaces": []
23882511 "author": {
23892512 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
23902513 "username": "syntaqx",
2514+ "display_username": "syntaqx",
23912515 "kind": "user",
23922516 "verified": false,
23932517 "workspaces": []
43544478 "kind": "project",
43554479 "id": "ent_9b1d6f0a2c3e4b5d6e7f8a9b",
43564480 "title": "web",
4357− "snippet": "The storefront. Written in TypeScript. Packages: @acme/web. Uses api. Owned by ana.",
4481+ "snippet": "The storefront. Written in TypeScript. Packages: @acme/web. Owned by ana.",
43584482 "project": "web",
43594483 "url": "/acme/web",
43604484 "score": 0.71,
43794503 "kind": "project",
43804504 "key": "web",
43814505 "name": "web",
4382− "summary": "The storefront. Written in TypeScript. Packages: @acme/web. Uses api. Owned by ana.",
4506+ "summary": "The storefront. Written in TypeScript. Packages: @acme/web. Owned by ana.",
43834507 "project": "web",
43844508 "private": true,
43854509 "data": {
44044528 },
44054529 "relations": [
44064530 {
4407− "kind": "depends_on",
4531+ "kind": "exposes",
44084532 "direction": "out",
44094533 "entity": {
44104534 "id": "ent_0a7c3e5b9d1f2a4c6e8b0d2f",
44114535 "workspace": "acme",
4412− "kind": "project",
4413− "key": "api",
4414− "name": "api",
4415− "summary": "The public API. Written in Rust.",
4416− "project": "api",
4536+ "kind": "package",
4537+ "key": "npm:@acme/web",
4538+ "name": "@acme/web",
4539+ "summary": null,
4540+ "project": "web",
44174541 "private": true,
44184542 "data": {},
44194543 "source": "scan",
4420− "ref": "/acme/api",
4421− "updated_at": "2026-10-04T20:57:12.000Z"
4544+ "ref": "/acme/web/blob/main/package.json",
4545+ "updated_at": "2026-10-04T20:58:41.000Z"
44224546 }
44234547 },
44244548 {
44504574 "people": [
44514575 {
44524576 "username": "syntaqx",
4577+ "display_username": "syntaqx",
44534578 "name": "Chase Pierce",
44544579 "avatar": null,
44554580 "role": "admin",
44594584 },
44604585 {
44614586 "username": "linus",
4587+ "display_username": "linus",
44624588 "name": null,
44634589 "avatar": null,
44644590 "role": "maintain",
44684594 },
44694595 {
44704596 "username": "grace",
4597+ "display_username": "grace",
44714598 "name": "Grace Hopper",
44724599 "avatar": null,
44734600 "role": "write",
44774604 },
44784605 {
44794606 "username": "ada",
4607+ "display_username": "Ada",
44804608 "name": "Ada Lovelace",
44814609 "avatar": null,
44824610 "role": "triage",
45394667 "expires_at": "2026-10-12T17:00:00.000Z"
45404668 }
45414669 },
4542− "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan."
4670+ "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. A `collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/). On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan."
45434671 },
45444672 "update_collaborator": {
45454673 "params": {
45524680 },
45534681 "response": {
45544682 "username": "ada",
4683+ "display_username": "Ada",
45554684 "name": "Ada Lovelace",
45564685 "avatar": null,
45574686 "role": "write",
45784707 },
45794708 "response": {
45804709 "username": "ada",
4710+ "display_username": "Ada",
45814711 "role": "triage",
45824712 "source": "direct",
45834713 "capabilities": [
47164846 "response": [
47174847 {
47184848 "username": "ada",
4849+ "display_username": "Ada",
47194850 "name": "Ada Lovelace",
47204851 "avatar": null,
47214852 "repos": [
49915122 "response": [
49925123 {
49935124 "username": "syntaqx",
5125+ "display_username": "syntaqx",
49945126 "name": "Chase Pierce",
49955127 "avatar": null,
49965128 "role": "maintainer",
49985130 },
49995131 {
50005132 "username": "ana",
5133+ "display_username": "Ana",
50015134 "name": "Ana Lima",
50025135 "avatar": null,
50035136 "role": "member",
50055138 },
50065139 {
50075140 "username": "bo",
5141+ "display_username": "bo",
50085142 "name": null,
50095143 "avatar": null,
50105144 "role": "member",
50245158 },
50255159 "response": {
50265160 "username": "ana",
5161+ "display_username": "Ana",
50275162 "name": "Ana Lima",
50285163 "avatar": null,
50295164 "role": "maintainer",
85928727 "author": {
85938728 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
85948729 "username": "syntaqx",
8730+ "display_username": "syntaqx",
85958731 "kind": "user",
85968732 "verified": false,
85978733 "workspaces": []
86578793 "author": {
86588794 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
86598795 "username": "syntaqx",
8796+ "display_username": "syntaqx",
86608797 "kind": "user",
86618798 "verified": false,
86628799 "workspaces": []
87178854 "author": {
87188855 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
87198856 "username": "syntaqx",
8857+ "display_username": "syntaqx",
87208858 "kind": "user",
87218859 "verified": false,
87228860 "workspaces": []
1060910747 "workspace": "flagon-io"
1061010748 },
1061110749 "response": {
10612− "allow_classic": true,
10613− "allow_fine_grained": true,
10750+ "allow_tokens_for_all_workspaces": true,
10751+ "allow_tokens_for_this_workspace": true,
1061410752 "require_approval": true,
1061510753 "max_lifetime_days": null,
1061610754 "forbid_no_expiry": false,
1062310761 "workspace": "flagon-io"
1062410762 },
1062510763 "request": {
10626− "allow_classic": false,
10764+ "allow_tokens_for_all_workspaces": false,
1062710765 "max_lifetime_days": 90
1062810766 },
1062910767 "response": {
10630− "allow_classic": false,
10631− "allow_fine_grained": true,
10768+ "allow_tokens_for_all_workspaces": false,
10769+ "allow_tokens_for_this_workspace": true,
1063210770 "require_approval": true,
1063310771 "max_lifetime_days": 90,
1063410772 "forbid_no_expiry": false,
1063510773 "updated_by": "ada",
1063610774 "updated_at": "2026-10-08T09:30:00.000Z"
1063710775 },
10638− "notes": "From each token's next request, classic tokens no longer reach the workspace, and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else."
10776+ "notes": "From each token's next request, tokens made for all of a member's workspaces no longer reach this one (tokens made for it alone still do), and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else."
1063910777 },
1064010778 "list_member_tokens": {
1064110779 "params": {
1064610784 "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1",
1064710785 "name": "release-bot",
1064810786 "owner": "ada",
10649− "kind": "fine_grained",
10650− "description": "Publishes releases from CI",
10787+ "description": "Publishes releases from CI",
1065110788 "created_at": "2026-10-08T09:00:00.000Z",
1065210789 "created_by": null,
1065310790 "last_used_at": null,
1065410791 "expires_at": "2026-11-07T09:00:00.000Z",
10655− "scopes": ["repo:read", "repo:write", "code:read", "code:write"],
10656− "resource_owner": "flagon-io",
10792+ "scopes": ["repo:read", "code:write"],
10793+ "workspace": "flagon-io",
1065710794 "repository_selection": "selected",
1065810795 "repositories": ["flagon-io/hello"],
10659− "permissions": { "contents": "write", "metadata": "read" },
10796+ "permissions": { "code": "write", "repo": "read" },
1066010797 "status": "pending",
1066110798 "review_reason": null,
1066210799 "reaches": false,
1067310810 "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1",
1067410811 "name": "release-bot",
1067510812 "owner": "ada",
10676− "kind": "fine_grained",
10677− "description": "Publishes releases from CI",
10813+ "description": "Publishes releases from CI",
1067810814 "created_at": "2026-10-08T09:00:00.000Z",
1067910815 "created_by": null,
1068010816 "last_used_at": null,
1068110817 "expires_at": "2026-11-07T09:00:00.000Z",
10682− "scopes": ["repo:read", "repo:write", "code:read", "code:write"],
10683− "resource_owner": "flagon-io",
10818+ "scopes": ["repo:read", "code:write"],
10819+ "workspace": "flagon-io",
1068410820 "repository_selection": "selected",
1068510821 "repositories": ["flagon-io/hello"],
10686− "permissions": { "contents": "write", "metadata": "read" },
10822+ "permissions": { "code": "write", "repo": "read" },
1068710823 "status": "pending",
1068810824 "review_reason": null,
1068910825 "reaches": false,
1070310839 "id": "tok_01HZX3K2M9V7Q4N8B6D5C3A2E1",
1070410840 "name": "release-bot",
1070510841 "owner": "ada",
10706− "kind": "fine_grained",
10707− "description": "Publishes releases from CI",
10842+ "description": "Publishes releases from CI",
1070810843 "created_at": "2026-10-08T09:00:00.000Z",
1070910844 "created_by": null,
1071010845 "last_used_at": null,
1071110846 "expires_at": "2026-11-07T09:00:00.000Z",
10712− "scopes": ["repo:read", "repo:write", "code:read", "code:write"],
10713− "resource_owner": "flagon-io",
10847+ "scopes": ["repo:read", "code:write"],
10848+ "workspace": "flagon-io",
1071410849 "repository_selection": "selected",
1071510850 "repositories": ["flagon-io/hello"],
10716− "permissions": { "contents": "write", "metadata": "read" },
10851+ "permissions": { "code": "write", "repo": "read" },
1071710852 "status": "active",
1071810853 "review_reason": null,
1071910854 "reaches": true,
1079610931 "kind": "user",
1079710932 "name": "ada",
1079810933 "username": "ada",
10934+ "display_username": "Ada",
1079910935 "avatar": "5f2b8c1d9e7a3f6b4c0d2e8a1b9c7d5e3f1a0b2c4d6e8f0a1b3c5d7e9f0a2b4c",
1080010936 "commits": 52,
1080110937 "first_at": "2026-09-28T14:11:52.000Z",
1085410990 "response": [
1085510991 {
1085610992 "username": "ada",
10993+ "display_username": "Ada",
1085710994 "avatar": null,
1085810995 "starred_at": "2026-10-06T18:30:00.000Z"
1085910996 },
1086010997 {
1086110998 "username": "sam",
10999+ "display_username": "sam",
1086211000 "avatar": null,
1086311001 "starred_at": "2026-10-02T09:12:00.000Z"
1086411002 }
+4−1
210210 through::<g1t_contracts::identity::Invite>(op, sent)
211211 }
212212 Op::ListWorkspaceInvites => through::<Vec<g1t_contracts::identity::Invite>>(op, sent),
213+ Op::ListInvitations => through::<Vec<g1t_contracts::identity::WorkspaceInvitation>>(op, sent),
213214 Op::ListNotifications => through::<g1t_contracts::inbox::InboxPage>(op, sent),
214215 Op::GetNotificationThread | Op::MarkThreadRead | Op::MarkThreadDone | Op::SaveThread | Op::SnoozeThread => {
215216 through::<g1t_contracts::inbox::InboxThread>(op, sent)
267268 assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}");
268269 continue;
269270 };
270− let sample = sample(op, example);
271+ let mut sample = sample(op, example);
272+ // Each person gets their chosen case on the way out (people.rs).
273+ crate::people::fill(&mut sample, &std::collections::HashMap::new());
271274 converted += wire::camel_case_keys(&sample).len();
272275 let sent = wire::snake_case(sample);
273276 let leaked = wire::camel_case_keys(&sent);
+5−1
5151 route("GET", "/user/invites", Op::ListInvites, &[]),
5252 route("POST", "/user/invites", Op::CreateInvite, &[]),
5353 route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
54+ // Invitations to workspaces waiting for your answer.
55+ route("GET", "/user/invitations", Op::ListInvitations, &[]),
56+ route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]),
57+ route("POST", "/user/invitations/:id/decline", Op::DeclineInvitation, &[]),
5458 route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
5559 // A workspace's rules for personal access tokens, and its members' tokens.
5660 route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]),
5761 route("PATCH", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::SetTokenPolicy), &[]),
58− route("GET", "/workspaces/:workspace/personal-access-tokens", Op::Tokens(TokenOp::ListMemberTokens), &[("kind", "kind")]),
62+ route("GET", "/workspaces/:workspace/personal-access-tokens", Op::Tokens(TokenOp::ListMemberTokens), &[]),
5963 route("POST", "/workspaces/:workspace/personal-access-tokens/:id", Op::Tokens(TokenOp::RevokeMemberToken), &[]),
6064 route("GET", "/workspaces/:workspace/personal-access-token-requests", Op::Tokens(TokenOp::ListTokenRequests), &[]),
6165 route("POST", "/workspaces/:workspace/personal-access-token-requests/:id", Op::Tokens(TokenOp::ReviewTokenRequest), &[]),
+25−45
11 //! A workspace's rules for personal access tokens, over REST and MCP: the
2−//! policy (which kinds reach it, approval, lifetime), the members' tokens
3−//! that reach it, approving or denying fine-grained tokens that wait for
2+//! policy (which tokens reach it, approval, lifetime), the members' tokens
3+//! that reach it, approving or denying tokens made for it that wait for
44 //! approval, and revoking a token there. Identity decides and keeps all of
55 //! it (services/identity/src/token_reach.rs); owners only, as people.
66
5050 TokenOp::GetTokenPolicy => "Get a workspace's personal access token policy",
5151 TokenOp::SetTokenPolicy => "Set a workspace's personal access token policy",
5252 TokenOp::ListMemberTokens => "List the personal access tokens that reach a workspace",
53− TokenOp::ListTokenRequests => "List fine-grained tokens waiting for approval",
54− TokenOp::ReviewTokenRequest => "Approve or deny a fine-grained token",
53+ TokenOp::ListTokenRequests => "List personal access tokens waiting for approval",
54+ TokenOp::ReviewTokenRequest => "Approve or deny a personal access token",
5555 TokenOp::RevokeMemberToken => "Revoke a member's token in a workspace",
5656 }
5757 }
5858
5959 pub fn description(self) -> &'static str {
6060 match self {
61− TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_classic (classic tokens reach it), allow_fine_grained (fine-grained tokens may name it as their resource owner), require_approval (a fine-grained token naming it waits for an owner's approval; true unless an owner says, and never for an owner's own token), max_lifetime_days (the longest a token reaching it may last; null for no limit, and a fine-grained token lasts at most 366 days anyway) and forbid_no_expiry (a token that never expires does not reach it). A token outside the rules keeps working elsewhere and reaches the workspace's public repositories only. Members only.",
61+ TokenOp::GetTokenPolicy => "A workspace's rules for its members' personal access tokens: allow_tokens_for_all_workspaces (a token made for every workspace of its owner reaches this one), allow_tokens_for_this_workspace (a token may be made for this workspace alone), require_approval (a token made for this workspace waits for an owner's approval; true unless an owner says, and never for an owner's own token), max_lifetime_days (the longest a token reaching it may last; null for no limit, and a token with an expiry lasts at most 366 days anyway) and forbid_no_expiry (a token that never expires does not reach it). A token outside the rules keeps working elsewhere and reaches the workspace's public repositories only. Members only.",
6262 TokenOp::SetTokenPolicy => "Change a workspace's rules for personal access tokens; fields left out stay as they are. max_lifetime_days of 0 removes the limit. The rules apply from each token's next request, to tokens made before them too. Owners only, as people.",
63− TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it: every fine-grained token naming it as its resource owner, whatever its status, and every classic token that has not expired. Each with its owner, kind, name, scopes, a fine-grained token's permissions, repository_selection, repositories and status (active, pending, denied or revoked), when it was made, last used and expires, and whether it reaches the workspace now (reaches, and blocked_by when not: pending approval, denied, revoked, classic tokens not allowed, lasts too long, never expires). Never the token itself. kind narrows it to classic or fine_grained. Owners only, as people.",
64− TokenOp::ListTokenRequests => "The fine-grained tokens naming the workspace that wait for an owner's approval, as list_member_tokens shows them. Until approved, a token reaches public repositories only. Owners only, as people.",
65− TokenOp::ReviewTokenRequest => "Approve or deny a fine-grained token waiting for approval: decision is approve or deny, and reason, if given, is shown to the token's owner, who hears of it in their inbox. An approved token reaches the workspace from its next request; a denied one reaches public repositories only. Recorded in the audit log as token.approved or token.denied. Owners only, as people.",
66− TokenOp::RevokeMemberToken => "Take a member's token out of the workspace, with an optional reason its owner is shown. A fine-grained token naming the workspace stops reaching it for good; a classic token keeps working everywhere else but never reaches this workspace again. Recorded in the audit log as token.revoked. Owners only, as people.",
63+ TokenOp::ListMemberTokens => "The personal access tokens of the workspace's members and outside collaborators that can reach it and have not expired: every token made for this workspace, whatever its status, and every token made for all of its owner's workspaces. Each with its owner, name, description, permissions (each resource at its level, such as {\"issues\": \"write\"}), scopes, workspace (the one it is made for; null for all of its owner's), repository_selection (all, selected or public), repositories, status (active, pending, denied or revoked), when it was made, last used and expires, and whether it reaches the workspace now (reaches, and blocked_by when not: pending approval, denied, revoked, tokens for all workspaces not allowed, tokens made for this workspace not allowed, lasts too long, never expires). Never the token itself. Owners only, as people.",
64+ TokenOp::ListTokenRequests => "The tokens made for the workspace that wait for an owner's approval, as list_member_tokens shows them. Until approved, a token reaches public repositories only. Owners only, as people.",
65+ TokenOp::ReviewTokenRequest => "Approve or deny a token waiting for approval: decision is approve or deny, and reason, if given, is shown to the token's owner, who hears of it in their inbox. An approved token reaches the workspace from its next request; a denied one reaches public repositories only. Recorded in the audit log as token.approved or token.denied. Owners only, as people.",
66+ TokenOp::RevokeMemberToken => "Take a member's token out of the workspace, with an optional reason its owner is shown. A token made for this workspace stops reaching it for good; a token made for all of its owner's workspaces keeps working everywhere else but never reaches this one again. Recorded in the audit log as token.revoked. Owners only, as people.",
6767 }
6868 }
6969
7878 let id = json!({ "type": "string", "description": "The token's id, tok_…." });
7979 let reason = json!({ "type": "string", "description": "Why, shown to the token's owner." });
8080 let (properties, required): (Value, &[&str]) = match self {
81− TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests => (json!({ "workspace": workspace }), &["workspace"]),
81+ TokenOp::GetTokenPolicy | TokenOp::ListTokenRequests | TokenOp::ListMemberTokens => (json!({ "workspace": workspace }), &["workspace"]),
8282 TokenOp::SetTokenPolicy => (
8383 json!({
8484 "workspace": workspace,
85− "allow_classic": { "type": "boolean", "description": "Classic tokens reach the workspace." },
86− "allow_fine_grained": { "type": "boolean", "description": "Fine-grained tokens may name the workspace as their resource owner." },
87− "require_approval": { "type": "boolean", "description": "A fine-grained token naming the workspace waits for an owner's approval." },
85+ "allow_tokens_for_all_workspaces": { "type": "boolean", "description": "A token made for every workspace of its owner reaches this one." },
86+ "allow_tokens_for_this_workspace": { "type": "boolean", "description": "A token may be made for this workspace alone." },
87+ "require_approval": { "type": "boolean", "description": "A token made for this workspace waits for an owner's approval." },
8888 "max_lifetime_days": { "type": "integer", "description": "The longest a token reaching it may last, in days, 1 to 3650; 0 for no limit." },
8989 "forbid_no_expiry": { "type": "boolean", "description": "A token that never expires does not reach the workspace." },
9090 }),
9191 &["workspace"],
9292 ),
93− TokenOp::ListMemberTokens => (
94− json!({
95− "workspace": workspace,
96− "kind": { "type": "string", "enum": ["classic", "fine_grained"], "description": "Only tokens of this kind." },
97− }),
98− &["workspace"],
99− ),
10093 TokenOp::ReviewTokenRequest => (
10194 json!({
10295 "workspace": workspace,
138131 }
139132 }
140133
141−/// A member's token in one flat shape: the token's fields, a fine-grained
142−/// token's beside them, and its owner and whether it reaches the workspace.
143−/// Keys stay as identity sends them (`camelCase`); the API's converter
144−/// writes them out in `snake_case`.
134+/// A member's token in one flat shape: the token's fields, and its owner
135+/// and whether it reaches the workspace. Keys stay as identity sends them
136+/// (`camelCase`); the API's converter writes them out in `snake_case`.
145137 pub(crate) fn member_view(member: &Value) -> Value {
146138 let mut out = Map::new();
147139 if let Some(token) = member["token"].as_object() {
148140 for (key, value) in token {
149− if key != "fineGrained" && key != "legacy" {
141+ if key != "legacy" && key != "workspaceOwned" && key != "admin" {
150142 out.insert(key.clone(), value.clone());
151− }
152− }
153− if let Some(details) = token.get("fineGrained").and_then(Value::as_object) {
154− for (key, value) in details {
155− let key = if key == "workspace" { "resourceOwner".to_owned() } else { key.clone() };
156− out.insert(key, value.clone());
157143 }
158144 }
159145 }
196182 &json!({
197183 "actor": actor,
198184 "slug": workspace,
199− "allow_classic": flag(input, "allow_classic"),
200− "allow_fine_grained": flag(input, "allow_fine_grained"),
185+ "allow_tokens_for_all_workspaces": flag(input, "allow_tokens_for_all_workspaces"),
186+ "allow_tokens_for_this_workspace": flag(input, "allow_tokens_for_this_workspace"),
201187 "require_approval": flag(input, "require_approval"),
202188 "max_lifetime_days": days,
203189 "forbid_no_expiry": flag(input, "forbid_no_expiry"),
207193 .await?
208194 }
209195 TokenOp::ListMemberTokens | TokenOp::ListTokenRequests => {
210− let kind = text(input, "kind");
211− if kind.as_deref().is_some_and(|kind| kind != "classic" && kind != "fine_grained") {
212− return Ok(Outcome::fail(FailureCode::Invalid, "kind is classic or fine_grained."));
213− }
214196 let status = (op == TokenOp::ListTokenRequests).then_some("pending");
215− let kind = if op == TokenOp::ListTokenRequests { Some("fine_grained".to_owned()) } else { kind };
216− let members: Outcome<Value> =
217− g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status, "kind": kind })).await?;
197+ let members: Outcome<Value> = g1t_kit::call(identity, "list_member_tokens", &json!({ "actor": actor, "slug": workspace, "status": status })).await?;
218198 map(members, list)
219199 }
220200 TokenOp::ReviewTokenRequest => {
275255 "token": {
276256 "id": "tok_1", "name": "ci", "createdAt": "2026-10-08T00:00:00.000Z", "lastUsedAt": null,
277257 "createdBy": null, "scopes": ["repo:read", "code:read"], "legacy": false, "expiresAt": "2026-11-07T00:00:00.000Z",
278− "kind": "fine_grained",
279− "fineGrained": { "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "permissions": { "contents": "read", "metadata": "read" }, "status": "pending" },
258+ "permissions": { "code": "read", "repo": "read" },
259+ "workspace": "acme", "repositorySelection": "selected", "repositories": ["acme/web"], "status": "pending",
280260 },
281261 }));
282262 assert_eq!(view["owner"], "ana");
283− assert_eq!(view["resourceOwner"], "acme");
263+ assert_eq!(view["workspace"], "acme");
284264 assert_eq!(view["repositorySelection"], "selected");
285− assert_eq!(view["permissions"]["contents"], "read");
265+ assert_eq!(view["permissions"]["code"], "read");
286266 assert_eq!(view["status"], "pending");
287267 assert_eq!(view["blockedBy"], "pending approval");
288− assert!(view.get("fineGrained").is_none() && view.get("legacy").is_none());
268+ assert!(view.get("legacy").is_none());
289269 }
290270
291271 #[test]
+508−40
1010 //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the
1111 //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`,
1212 //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field
13−//! names.
13+//! names; the cache's methods also in protobuf (`application/protobuf`),
14+//! which other clients of the protocol send (sccache, through OpenDAL).
1415 //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`,
1516 //! which the toolkit's client uses whenever the server it runs against is
16−//! not github.com: on g1t, that is the one it uses.
17+//! not github.com: on g1t, that is the one it uses, and sccache's too.
18+//! Its entries are sent in 32 MB chunks, or in one chunk of any size.
1719 //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those
18−//! hand out. Downloads are a plain GET. Uploads speak the part of Azure
19−//! Blob Storage's protocol the toolkit's client uses (Put Blob, Put
20−//! Block, Put Block List), mapped onto an R2 multipart upload: a block's
21−//! id ends in its index, which is its part's number.
20+//! hand out. Downloads are a GET, of the whole blob or of one byte range
21+//! (`Range`), as the toolkit's client fetches large entries in segments.
22+//! Uploads speak the part of Azure Blob Storage's protocol the toolkit's
23+//! client uses (Put Blob, Put Block, Put Block List), mapped onto an R2
24+//! multipart upload: a block's id ends in its index, which is its part's
25+//! number. An upload link carries a query, as an Azure SAS link does,
26+//! which clients that sign their requests with it need.
2227 //!
2328 //! Every call carries the job's runtime token; the actions service checks
2429 //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there).
8590
8691 // ── Twirp ───────────────────────────────────────────────────────────────────
8792
93+/// Twirp's binary encoding.
94+const PROTOBUF: &str = "application/protobuf";
95+
96+/// Whether a request's `Content-Type` is Twirp's protobuf encoding.
97+fn is_protobuf(content_type: &str) -> bool {
98+ let kind = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
99+ kind == PROTOBUF || kind == "application/x-protobuf"
100+}
101+
102+/// The cache service's messages in protobuf, read into and written from the
103+/// JSON the handlers use (`results/api/v1/cache.proto`, field numbers as
104+/// there). Only what the cache's three methods carry: strings, a repeated
105+/// string, an int64 and a bool. `metadata` (field 1 of each request) is
106+/// skipped: the runtime token says whose cache it is.
107+mod proto {
108+ use serde_json::{Map, Value};
109+
110+ #[derive(Clone, Copy)]
111+ enum Kind {
112+ Text,
113+ Texts,
114+ Int,
115+ Bool,
116+ }
117+
118+ /// A message's fields: number, JSON name, kind.
119+ type Fields = &'static [(u64, &'static str, Kind)];
120+
121+ fn request_fields(method: &str) -> Option<Fields> {
122+ Some(match method {
123+ "CreateCacheEntry" => &[(2, "key", Kind::Text), (3, "version", Kind::Text)],
124+ "FinalizeCacheEntryUpload" => &[(2, "key", Kind::Text), (3, "size_bytes", Kind::Int), (4, "version", Kind::Text)],
125+ "GetCacheEntryDownloadURL" => &[(2, "key", Kind::Text), (3, "restore_keys", Kind::Texts), (4, "version", Kind::Text)],
126+ _ => return None,
127+ })
128+ }
129+
130+ fn response_fields(method: &str) -> Fields {
131+ match method {
132+ "CreateCacheEntry" => &[(1, "ok", Kind::Bool), (2, "signed_upload_url", Kind::Text), (3, "message", Kind::Text)],
133+ "FinalizeCacheEntryUpload" => &[(1, "ok", Kind::Bool), (2, "entry_id", Kind::Int), (3, "message", Kind::Text)],
134+ "GetCacheEntryDownloadURL" => &[(1, "ok", Kind::Bool), (2, "signed_download_url", Kind::Text), (3, "matched_key", Kind::Text)],
135+ _ => &[],
136+ }
137+ }
138+
139+ fn varint(bytes: &[u8], at: &mut usize) -> Option<u64> {
140+ let mut value = 0u64;
141+ for shift in (0..64).step_by(7) {
142+ let byte = *bytes.get(*at)?;
143+ *at += 1;
144+ value |= u64::from(byte & 0x7f) << shift;
145+ if byte & 0x80 == 0 {
146+ return Some(value);
147+ }
148+ }
149+ None
150+ }
151+
152+ fn put_varint(out: &mut Vec<u8>, mut value: u64) {
153+ while value >= 0x80 {
154+ out.push((value as u8 & 0x7f) | 0x80);
155+ value >>= 7;
156+ }
157+ out.push(value as u8);
158+ }
159+
160+ /// A request of `method` as JSON, or None when it is not one.
161+ pub fn request(method: &str, bytes: &[u8]) -> Option<Value> {
162+ let fields = request_fields(method)?;
163+ let mut out = Map::new();
164+ let mut at = 0;
165+ while at < bytes.len() {
166+ let tag = varint(bytes, &mut at)?;
167+ let (number, wire) = (tag >> 3, tag & 7);
168+ let known = fields.iter().find(|(n, _, _)| *n == number);
169+ match wire {
170+ 0 => {
171+ let value = varint(bytes, &mut at)?;
172+ if let Some((_, name, Kind::Int)) = known {
173+ // An int64 is sent as its two's complement.
174+ out.insert((*name).to_owned(), Value::String((value as i64).to_string()));
175+ }
176+ }
177+ 2 => {
178+ let length = usize::try_from(varint(bytes, &mut at)?).ok()?;
179+ let end = at.checked_add(length).filter(|end| *end <= bytes.len())?;
180+ let raw = &bytes[at..end];
181+ at = end;
182+ match known {
183+ Some((_, name, Kind::Text)) => {
184+ out.insert((*name).to_owned(), Value::String(String::from_utf8(raw.to_vec()).ok()?));
185+ }
186+ Some((_, name, Kind::Texts)) => {
187+ let text = Value::String(String::from_utf8(raw.to_vec()).ok()?);
188+ match out.entry((*name).to_owned()).or_insert_with(|| Value::Array(Vec::new())) {
189+ Value::Array(list) => list.push(text),
190+ _ => return None,
191+ }
192+ }
193+ _ => {}
194+ }
195+ }
196+ 1 => at = at.checked_add(8).filter(|end| *end <= bytes.len())?,
197+ 5 => at = at.checked_add(4).filter(|end| *end <= bytes.len())?,
198+ _ => return None,
199+ }
200+ }
201+ Some(Value::Object(out))
202+ }
203+
204+ /// A response of `method` from its JSON. Defaults are left out, as
205+ /// proto3 does.
206+ pub fn response(method: &str, value: &Value) -> Vec<u8> {
207+ let mut out = Vec::new();
208+ for (number, name, kind) in response_fields(method) {
209+ let field = &value[*name];
210+ match kind {
211+ Kind::Bool if field.as_bool() == Some(true) => {
212+ put_varint(&mut out, number << 3);
213+ put_varint(&mut out, 1);
214+ }
215+ Kind::Int => {
216+ let n = field.as_i64().or_else(|| field.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
217+ if n != 0 {
218+ put_varint(&mut out, number << 3);
219+ put_varint(&mut out, n as u64);
220+ }
221+ }
222+ Kind::Text => {
223+ let text = field.as_str().unwrap_or_default();
224+ if !text.is_empty() {
225+ put_varint(&mut out, (number << 3) | 2);
226+ put_varint(&mut out, text.len() as u64);
227+ out.extend_from_slice(text.as_bytes());
228+ }
229+ }
230+ _ => {}
231+ }
232+ }
233+ out
234+ }
235+}
236+
88237 /// A Twirp error: its code and message, at the status Twirp gives it.
89238 fn twirp_error(code: &str, message: &str) -> Result<Response> {
90239 let status = match code {
92241 "permission_denied" => 403,
93242 "not_found" => 404,
94243 "already_exists" => 409,
95− "invalid_argument" => 400,
244+ "invalid_argument" | "malformed" => 400,
245+ "bad_route" => 404,
96246 "failed_precondition" => 412,
97247 "resource_exhausted" => 429,
98248 _ => 500,
155305 })
156306 }
157307
308+/// The Azure Storage version g1t's blob links answer as.
309+const AZURE_VERSION: &str = "2024-11-04";
310+
311+/// An upload link: the blob's, with a query as an Azure SAS link has one.
312+/// A client that treats it as a container, a blob and a SAS token (OpenDAL,
313+/// which sccache uses) refuses a link without one; the token in the path
314+/// is what g1t checks.
315+pub fn upload_url(api: &str, blob: &str) -> String {
316+ format!("{}?sv={AZURE_VERSION}", blob_url(api, blob))
317+}
318+
158319 /// Starts an R2 upload for an entry the service reserved, and the signed
159320 /// link the toolkit sends it to.
160321 async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> {
167328 )
168329 .await?;
169330 Ok(match signed {
170− Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)),
331+ Outcome::Ok(blob) => Outcome::Ok(upload_url(&services.addresses.api, &blob)),
171332 Outcome::Fail(refused) => Outcome::Fail(refused),
172333 })
173334 }
174335
175−/// `POST /twirp/{service}/{method}`.
176−pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> {
336+/// `POST /twirp/{service}/{method}`. A failure inside is logged and
337+/// answered as Twirp's `internal`, with its cause.
338+pub async fn twirp(request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> {
339+ match twirp_inner(request, env, services, service, method).await {
340+ Ok(response) => Ok(response),
341+ Err(error) => twirp_error("internal", &failed(&format!("POST /twirp/{service}/{method}"), &error)),
342+ }
343+}
344+
345+async fn twirp_inner(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> {
177346 let token = bearer(&request);
178347 let Some(job) = runtime_job(&token) else {
179348 return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
180349 };
181− let body: Value = request.json().await.unwrap_or(Value::Null);
350+ // Twirp clients send JSON or protobuf, and are answered in kind.
351+ let binary = is_protobuf(&request.headers().get("content-type")?.unwrap_or_default());
352+ let body: Value = if binary {
353+ match proto::request(method, &request.bytes().await.unwrap_or_default()) {
354+ Some(body) => body,
355+ None => return twirp_error("malformed", "That is not a protobuf message this method takes."),
356+ }
357+ } else {
358+ request.json().await.unwrap_or(Value::Null)
359+ };
360+ let answer = |value: Value| -> Result<Response> {
361+ if binary {
362+ let mut response = Response::from_bytes(proto::response(method, &value))?;
363+ response.headers_mut().set("content-type", PROTOBUF)?;
364+ Ok(response)
365+ } else {
366+ Response::from_json(&value)
367+ }
368+ };
182369 let bucket = env.bucket("ACTIONS_CACHE")?;
183370 let actions = &services.actions;
184371 let (run, own_job) = backend_ids(&token);
201388 let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
202389 match found {
203390 Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => {
204− Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
391+ answer(json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
205392 }
206− Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
393+ Outcome::Ok(_) => answer(json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
207394 Outcome::Fail(refused) => twirp_failure(&refused),
208395 }
209396 }
212399 let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?;
213400 let reserved = match reserved {
214401 Outcome::Ok(reserved) => reserved,
215− // The client warns with this and goes on, as for a key
216− // another job is saving.
217− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
402+ // A key already saved, or being saved by another job, to a
403+ // protobuf client (OpenDAL's) is Twirp's `already_exists`
404+ // (409), which it takes as "someone else has it": sccache
405+ // then still writes. An `ok: false` would read as a broken
406+ // cache, and sccache would only read from it.
407+ Outcome::Fail(refused) if binary && refused.code == FailureCode::Conflict => return twirp_failure(&refused),
408+ // The toolkit's client logs this ("another job may be
409+ // creating this cache") and goes on.
410+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
218411 };
219412 match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? {
220− Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })),
221− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
413+ Outcome::Ok(url) => answer(json!({ "ok": true, "signed_upload_url": url })),
414+ Outcome::Fail(refused) => answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
222415 }
223416 }
224417 (CACHE_SERVICE, "FinalizeCacheEntryUpload") => {
226419 let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?;
227420 let pending = match pending {
228421 Outcome::Ok(pending) => pending,
229− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
422+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
230423 };
231424 let Some(object) = bucket.head(&pending.object).await? else {
232− return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
425+ return answer(json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
233426 };
234427 match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? {
235− Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })),
236− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
428+ Outcome::Ok(()) => answer(json!({ "ok": true, "entry_id": pending.number.to_string() })),
429+ Outcome::Fail(refused) => answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
237430 }
238431 }
239432 (ARTIFACT_SERVICE, "CreateArtifact") => {
317510 }
318511
319512 fn query(request: &Request, name: &str) -> Option<String> {
320− request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned())
513+ query_in(&request.url().ok()?, name)
514+}
515+
516+fn query_in(url: &worker::Url, name: &str) -> Option<String> {
517+ url.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned())
321518 }
322519
323520 /// The part a chunk of the older protocol is, from its `Content-Range`:
324−/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them.
521+/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. A first
522+/// chunk may be larger, up to `MAX_BLOCK_BYTES`: a client that sends an
523+/// entry in one request (sccache does) sends a single chunk from 0.
325524 pub fn chunk_part(range: &str) -> Option<(u16, u64)> {
326525 let range = range.trim().strip_prefix("bytes ")?;
327526 let (span, _) = range.split_once('/')?;
328527 let (start, end) = span.split_once('-')?;
329528 let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?);
330− if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES {
529+ if end < start {
530+ return None;
531+ }
532+ let length = end - start + 1;
533+ if start == 0 && length <= MAX_BLOCK_BYTES {
534+ return Some((1, length));
535+ }
536+ if start % CACHE_PART_BYTES != 0 || length > CACHE_PART_BYTES {
537+ return None;
538+ }
539+ Some(((start / CACHE_PART_BYTES + 1) as u16, length))
540+}
541+
542+/// The bytes a download's `Range` header asks for, out of `size`: first and
543+/// last, inclusive. None to send the whole blob (no header, or one this
544+/// does not read, such as several ranges); `Some(None)` when the range is
545+/// past the end (416).
546+pub fn byte_range(header: &str, size: u64) -> Option<Option<(u64, u64)>> {
547+ let spec = header.trim().strip_prefix("bytes=")?.trim();
548+ if spec.contains(',') {
331549 return None;
332550 }
333− Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1))
551+ let (first, last) = spec.split_once('-')?;
552+ let (first, last) = (first.trim(), last.trim());
553+ let range = if first.is_empty() {
554+ // The last `n` bytes.
555+ let n: u64 = last.parse().ok()?;
556+ if n == 0 || size == 0 {
557+ return Some(None);
558+ }
559+ (size.saturating_sub(n), size - 1)
560+ } else {
561+ let first: u64 = first.parse().ok()?;
562+ let last: u64 = if last.is_empty() { u64::MAX } else { last.parse().ok()? };
563+ if last < first {
564+ return None;
565+ }
566+ if first >= size {
567+ return Some(None);
568+ }
569+ (first, last.min(size - 1))
570+ };
571+ Some(Some(range))
572+}
573+
574+/// What a lookup of the older protocol answers: 200 with the entry, 204
575+/// for a miss (which the toolkit's client and sccache read as "not
576+/// cached"), or the refusal's status.
577+pub fn lookup_answer(found: Outcome<Option<CacheHit>>, version: &str, api: &str) -> (u16, Option<Value>) {
578+ match found {
579+ Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => (
580+ 200,
581+ Some(json!({
582+ "cacheKey": key,
583+ "cacheVersion": version,
584+ "scope": "",
585+ "creationTime": created_at,
586+ "archiveLocation": blob_url(api, &blob),
587+ })),
588+ ),
589+ // No entry, or one without a download link (no ACTIONS_KEY): a miss.
590+ Outcome::Ok(_) => (204, None),
591+ Outcome::Fail(refused) => (refused.code.http_status(), Some(json!({ "message": refused.message, "error": { "message": refused.message } }))),
592+ }
593+}
594+
595+/// Logs a toolkit request that failed inside g1t, and says what to tell
596+/// its client: the cause, so a job's log shows more than a bare 500.
597+fn failed(route: &str, error: &worker::Error) -> String {
598+ worker::console_error!("toolkit: {route} failed: {error}");
599+ format!("g1t could not answer this: {error}")
334600 }
335601
336602 /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it.
337−pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
603+/// A failure inside is logged and answered as a 500 with its cause.
604+pub async fn cache_v1(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
605+ match cache_v1_inner(request, env, services, method, rest).await {
606+ Ok(response) => Ok(response),
607+ Err(error) => plain_error(500, &failed(&format!("{method} {CACHE_PATH}_apis/artifactcache/{rest}"), &error)),
608+ }
609+}
610+
611+async fn cache_v1_inner(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
338612 let token = bearer(&request);
339613 let Some(job) = runtime_job(&token) else {
340614 return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
351625 let version = query(&request, "version").unwrap_or_default();
352626 let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) };
353627 let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
354− match found {
355− Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({
356− "cacheKey": key,
357− "cacheVersion": version,
358− "scope": "",
359− "creationTime": created_at,
360− "archiveLocation": blob_url(&services.addresses.api, &blob),
361− })),
362− Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)),
363− Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message),
628+ match lookup_answer(found, &version, &services.addresses.api) {
629+ (status, Some(body)) => Ok(Response::from_json(&body)?.with_status(status)),
630+ (status, None) => Ok(Response::empty()?.with_status(status)),
364631 }
365632 }
366633 ("POST", ["caches"]) => {
510777 }
511778
512779 /// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload.
513−pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> {
780+/// A failure inside is logged and answered as Azure's `InternalError`.
781+pub async fn blob(request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> {
782+ match blob_inner(request, env, services, method, token).await {
783+ Ok(response) => Ok(response),
784+ // The token is a credential: the route is logged without it.
785+ Err(error) => azure_error(500, "InternalError", &failed(&format!("{method} /actions/toolkit/blobs/…"), &error)),
786+ }
787+}
788+
789+async fn blob_inner(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> {
514790 let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?;
515791 let grant = match opened {
516792 Outcome::Ok(grant) => grant,
527803 headers.set("content-length", &size.to_string())?;
528804 headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?;
529805 headers.set("x-ms-blob-type", "BlockBlob")?;
806+ headers.set("accept-ranges", "bytes")?;
530807 if let Some(name) = &grant.filename {
531808 headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?;
532809 }
538815 headers(&mut response, object.size())?;
539816 return Ok(response);
540817 }
818+ // One byte range (`Range`, or Azure's `x-ms-range`): the
819+ // toolkit's client fetches a large entry in segments, side by
820+ // side, and writes each where its range says.
821+ let asked = match request.headers().get("x-ms-range")? {
822+ Some(range) => Some(range),
823+ None => request.headers().get("range")?,
824+ };
825+ if let Some(asked) = asked.filter(|r| !r.trim().is_empty()) {
826+ let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
827+ let size = object.size();
828+ match byte_range(&asked, size) {
829+ Some(Some((first, last))) => {
830+ let length = last - first + 1;
831+ let Some(object) = bucket.get(&grant.object).range(worker::Range::OffsetWithLength { offset: first, length }).execute().await? else {
832+ return azure_error(404, "BlobNotFound", "It is gone.");
833+ };
834+ let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
835+ let mut response = Response::from_body(body.response_body()?)?.with_status(206);
836+ headers(&mut response, length)?;
837+ response.headers_mut().set("content-range", &format!("bytes {first}-{last}/{size}"))?;
838+ return Ok(response);
839+ }
840+ Some(None) => {
841+ let mut response = azure_error(416, "InvalidRange", "The range is past the end of the blob.")?;
842+ response.headers_mut().set("content-range", &format!("bytes */{size}"))?;
843+ return Ok(response);
844+ }
845+ // Not a range this reads: the whole blob.
846+ None => {}
847+ }
848+ }
541849 let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
542850 let size = object.size();
543851 let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
674982 let mb32 = CACHE_PART_BYTES;
675983 assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32)));
676984 assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100)));
677− // Not on a chunk's boundary, too long, or not a range.
985+ // A whole entry in one chunk, as sccache (OpenDAL) sends it: its
986+ // check file is 13 bytes, a compiled crate can be well over 32 MB.
987+ assert_eq!(chunk_part("bytes 0-12/*"), Some((1, 13)));
988+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), Some((1, mb32 + 1)));
989+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES - 1)), Some((1, MAX_BLOCK_BYTES)));
990+ // Not on a chunk's boundary, too long, backwards, or not a range.
678991 assert_eq!(chunk_part("bytes 5-10/*"), None);
679− assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None);
992+ assert_eq!(chunk_part(&format!("bytes {mb32}-{}/*", mb32 * 2)), None);
993+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES)), None);
994+ assert_eq!(chunk_part("bytes 10-5/*"), None);
680995 assert_eq!(chunk_part("0-10"), None);
681996 }
682997
998+ #[test]
999+ fn downloads_read_one_byte_range() {
1000+ // OpenDAL's stat: the first byte.
1001+ assert_eq!(byte_range("bytes=0-0", 100), Some(Some((0, 0))));
1002+ // The toolkit's segments, the last one cut at the end.
1003+ assert_eq!(byte_range("bytes=0-49", 100), Some(Some((0, 49))));
1004+ assert_eq!(byte_range("bytes=50-999", 100), Some(Some((50, 99))));
1005+ assert_eq!(byte_range("bytes=90-", 100), Some(Some((90, 99))));
1006+ assert_eq!(byte_range("bytes=-10", 100), Some(Some((90, 99))));
1007+ assert_eq!(byte_range("bytes=-1000", 100), Some(Some((0, 99))));
1008+ // Past the end: 416.
1009+ assert_eq!(byte_range("bytes=100-200", 100), Some(None));
1010+ assert_eq!(byte_range("bytes=0-0", 0), Some(None));
1011+ assert_eq!(byte_range("bytes=-0", 100), Some(None));
1012+ // Not read: the whole blob.
1013+ assert_eq!(byte_range("bytes=0-1,5-6", 100), None);
1014+ assert_eq!(byte_range("bytes=9-3", 100), None);
1015+ assert_eq!(byte_range("items=0-1", 100), None);
1016+ assert_eq!(byte_range("bytes=a-b", 100), None);
1017+ }
1018+
1019+ #[test]
1020+ fn upload_links_carry_a_query_as_sas_links_do() {
1021+ let url = upload_url("https://api.g1t.sh", "tok.sig");
1022+ assert_eq!(url, "https://api.g1t.sh/actions/toolkit/blobs/tok.sig?sv=2024-11-04");
1023+ // How OpenDAL reads a signed upload link: a container, a blob in
1024+ // it, and a SAS query, all of which must be there.
1025+ let rest = url.strip_prefix("https://api.g1t.sh/").unwrap();
1026+ let (path, query) = rest.split_once('?').unwrap();
1027+ let (container, blob) = path.split_once('/').unwrap();
1028+ assert_eq!((container, blob, query), ("actions", "toolkit/blobs/tok.sig", "sv=2024-11-04"));
1029+ }
1030+
1031+ /// A protobuf length-delimited field, as prost writes it.
1032+ fn pb_text(number: u8, text: &str) -> Vec<u8> {
1033+ let mut out = vec![(number << 3) | 2, text.len() as u8];
1034+ out.extend_from_slice(text.as_bytes());
1035+ out
1036+ }
1037+
1038+ /// The requests sccache 0.18 sends (OpenDAL 0.58's `ghac` service, with
1039+ /// prost): fields in number order, defaults left out, no metadata.
1040+ #[test]
1041+ fn twirp_reads_sccaches_protobuf_requests() {
1042+ assert!(is_protobuf("application/protobuf"));
1043+ assert!(is_protobuf("Application/Protobuf; charset=utf-8"));
1044+ assert!(!is_protobuf("application/json"));
1045+ assert!(!is_protobuf(""));
1046+
1047+ let key = "sccache/f/c/b/fcb0a1d2e3";
1048+ let version = "sccache-v0.18.0";
1049+ let create = [pb_text(2, key), pb_text(3, version)].concat();
1050+ let read = proto::request("CreateCacheEntry", &create).unwrap();
1051+ assert_eq!((text(&read, "key"), text(&read, "version")), (key.to_owned(), version.to_owned()));
1052+
1053+ // size_bytes is field 3, a varint: 300 is 0xac 0x02.
1054+ let finalize = [pb_text(2, key), vec![0x18, 0xac, 0x02], pb_text(4, version)].concat();
1055+ let read = proto::request("FinalizeCacheEntryUpload", &finalize).unwrap();
1056+ assert_eq!(number(&read, "size_bytes"), Some(300));
1057+ assert_eq!(text(&read, "version"), version);
1058+
1059+ let lookup = [pb_text(2, key), pb_text(4, version)].concat();
1060+ let read = proto::request("GetCacheEntryDownloadURL", &lookup).unwrap();
1061+ assert_eq!(text(&read, "key"), key);
1062+ assert!(field(&read, "restore_keys").is_null());
1063+ // The toolkit's own lookup, with metadata (skipped) and restore keys.
1064+ let metadata = vec![0x0a, 0x02, 0x08, 0x07];
1065+ let with_restore = [metadata, pb_text(2, "k"), pb_text(3, "k-"), pb_text(3, "x-"), pb_text(4, "v")].concat();
1066+ let read = proto::request("GetCacheEntryDownloadURL", &with_restore).unwrap();
1067+ assert_eq!(field(&read, "restore_keys"), &json!(["k-", "x-"]));
1068+ assert_eq!(text(&read, "version"), "v");
1069+
1070+ // The same three, as prost 0.14 encodes them with OpenDAL's
1071+ // generated types (the `ghac` crate, 0.3.0), byte for byte.
1072+ let recorded = |hex: &str| -> Vec<u8> { (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect() };
1073+ let prefix = "1218736363616368652f662f632f622f66636230613164326533";
1074+ let suffix = "0f736363616368652d76302e31382e30";
1075+ assert_eq!(recorded(&format!("{prefix}1a{suffix}")), create);
1076+ assert_eq!(recorded(&format!("{prefix}18ac0222{suffix}")), finalize);
1077+ assert_eq!(recorded(&format!("{prefix}22{suffix}")), lookup);
1078+
1079+ // Cut short, or not a cache method.
1080+ assert!(proto::request("CreateCacheEntry", &create[..create.len() - 1]).is_none());
1081+ assert!(proto::request("CreateCacheEntry", &[0x12, 0xff]).is_none());
1082+ assert!(proto::request("CreateArtifact", &create).is_none());
1083+ assert_eq!(proto::request("CreateCacheEntry", &[]), Some(json!({})));
1084+ }
1085+
1086+ #[test]
1087+ fn twirp_answers_in_protobuf_as_prost_reads_it() {
1088+ let url = "https://api.g1t.sh/actions/toolkit/blobs/t?sv=2024-11-04";
1089+ let created = proto::response("CreateCacheEntry", &json!({ "ok": true, "signed_upload_url": url }));
1090+ assert_eq!(created, [vec![0x08, 0x01], pb_text(2, url)].concat());
1091+ // Refused: ok false is the default, so only the message is sent.
1092+ let refused = proto::response("CreateCacheEntry", &json!({ "ok": false, "signed_upload_url": "", "message": "no" }));
1093+ assert_eq!(refused, pb_text(3, "no"));
1094+ // entry_id is an int64, given as a string in JSON.
1095+ let finalized = proto::response("FinalizeCacheEntryUpload", &json!({ "ok": true, "entry_id": "300" }));
1096+ assert_eq!(finalized, vec![0x08, 0x01, 0x10, 0xac, 0x02]);
1097+ let found = proto::response("GetCacheEntryDownloadURL", &json!({ "ok": true, "signed_download_url": "u", "matched_key": "k" }));
1098+ assert_eq!(found, [vec![0x08, 0x01], pb_text(2, "u"), pb_text(3, "k")].concat());
1099+ // A miss is an empty message: ok false.
1100+ assert!(proto::response("GetCacheEntryDownloadURL", &json!({ "ok": false, "signed_download_url": "", "matched_key": "" })).is_empty());
1101+ }
1102+
6831103 /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact`
6841104 /// 2 send them (protobuf-ts, proto field names, no defaults).
6851105 #[test]
7161136 assert_eq!(runtime_job("deadbeef"), None);
7171137 }
7181138
1139+ /// sccache 0.18's storage check, at server start: a lookup of
1140+ /// `sccache/.sccache_check`. The actions service answers a miss with
1141+ /// `Ok(None)`, `{"ok":true,"value":null}`, which was read back as a
1142+ /// malformed outcome, and every lookup that missed was a 500
1143+ /// ("Server startup failed: cache storage failed to read").
1144+ #[test]
1145+ fn sccaches_first_lookup_misses_with_a_204() {
1146+ let url = worker::Url::parse(
1147+ "https://api.g1t.sh/actions/toolkit/_apis/artifactcache/cache?keys=sccache/.sccache_check&version=sccache-v0.18.0",
1148+ )
1149+ .unwrap();
1150+ assert_eq!(query_in(&url, "keys").as_deref(), Some("sccache/.sccache_check"));
1151+ assert_eq!(query_in(&url, "version").as_deref(), Some("sccache-v0.18.0"));
1152+
1153+ // As the actions service replies (`g1t_kit::reply`), and the API
1154+ // reads it (`g1t_kit::call`).
1155+ let wire = serde_json::to_string(&Outcome::<Option<CacheHit>>::Ok(None)).unwrap();
1156+ assert_eq!(wire, r#"{"ok":true,"value":null}"#);
1157+ let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap();
1158+ assert_eq!(lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh"), (204, None));
1159+
1160+ // Once saved, the same lookup is a hit with its download link.
1161+ let hit = CacheHit {
1162+ key: "sccache/.sccache_check".into(),
1163+ object: "c/repo_1/cache_1".into(),
1164+ size: 13,
1165+ created_at: "2026-10-08T12:00:00.000Z".into(),
1166+ blob: Some("tok.sig".into()),
1167+ };
1168+ let wire = serde_json::to_string(&Outcome::Ok(Some(hit))).unwrap();
1169+ let found: Outcome<Option<CacheHit>> = g1t_kit::read_answer("cache_lookup", &wire).unwrap();
1170+ let (status, body) = lookup_answer(found, "sccache-v0.18.0", "https://api.g1t.sh");
1171+ let body = body.unwrap();
1172+ assert_eq!(status, 200);
1173+ assert_eq!(body["cacheKey"], "sccache/.sccache_check");
1174+ assert_eq!(body["cacheVersion"], "sccache-v0.18.0");
1175+ assert_eq!(body["archiveLocation"], "https://api.g1t.sh/actions/toolkit/blobs/tok.sig");
1176+
1177+ // A refusal keeps its status and says why.
1178+ let refused = Outcome::<Option<CacheHit>>::fail(FailureCode::Unauthenticated, "That job is not running.");
1179+ let (status, body) = lookup_answer(refused, "v", "https://api.g1t.sh");
1180+ assert_eq!((status, body.unwrap()["message"].as_str()), (401, Some("That job is not running.")));
1181+
1182+ // An answer that does not read names its method and the cause.
1183+ let unread = g1t_kit::read_answer::<Outcome<CacheHit>>("cache_lookup", r#"{"ok":true,"value":null}"#).unwrap_err();
1184+ assert!(unread.to_string().contains("cache_lookup answered with what could not be read"), "{unread}");
1185+ }
1186+
7191187 #[test]
7201188 fn a_job_is_told_where_the_toolkit_s_services_are() {
7211189 let vars = runtime_variables("https://api.g1t.sh", "tok", false);
+7−4
395395 a("transfer_ownership", Op::TransferOwnership, "Hand it to another member: they become an owner, you a member"),
396396 a("leave", Op::LeaveWorkspace, "Leave it yourself"),
397397 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
398− a("invite_member", Op::InviteMember, "Invite an email address"),
398+ a("invite_member", Op::InviteMember, "Invite someone by username or email address"),
399399 a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"),
400400 a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"),
401401 a("connect_integration", Op::ConnectIntegration, "Connect one"),
418418 a("delete_ruleset", Op::Rules(RulesOp::DeleteWorkspaceRuleset), "Delete one of its rulesets"),
419419 a("rule_evaluations", Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), "How rules judged changes across its repositories"),
420420 a("get_token_policy", Op::Tokens(TokenOp::GetTokenPolicy), "Its rules for personal access tokens"),
421− a("set_token_policy", Op::Tokens(TokenOp::SetTokenPolicy), "Change them: kinds allowed, approval, lifetime"),
421+ a("set_token_policy", Op::Tokens(TokenOp::SetTokenPolicy), "Change them: which tokens reach it, approval, lifetime"),
422422 a("list_member_tokens", Op::Tokens(TokenOp::ListMemberTokens), "Members' personal access tokens that reach it"),
423− a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Fine-grained tokens waiting for approval"),
423+ a("list_token_requests", Op::Tokens(TokenOp::ListTokenRequests), "Tokens waiting for approval"),
424424 a("review_token_request", Op::Tokens(TokenOp::ReviewTokenRequest), "Approve or deny one"),
425425 a("revoke_member_token", Op::Tokens(TokenOp::RevokeMemberToken), "Revoke a member's token in it"),
426426 ],
505505 Tool {
506506 name: "account",
507507 title: "Your account",
508− description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
508+ description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to workspaces and repositories waiting for you.",
509509 default_action: Some("whoami"),
510510 actions: &[
511511 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
517517 a("list_invites", Op::ListInvites, "Your invites to g1t"),
518518 a("create_invite", Op::CreateInvite, "Make an invite"),
519519 a("revoke_invite", Op::RevokeInvite, "Revoke one"),
520+ a("list_workspace_invitations", Op::ListInvitations, "Invitations to workspaces for you"),
521+ a("accept_workspace_invitation", Op::AcceptInvitation, "Accept one and join"),
522+ a("decline_workspace_invitation", Op::DeclineInvitation, "Decline one"),
520523 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
521524 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
522525 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
+20−3
1616 integrations: [
1717 starlight({
1818 title: 'g1t docs',
19− description: 'Guides and reference for g1t, where people and agents ship software together.',
19+ description: 'Guides and reference for g1t, the workspace where a team and its agents talk, work and ship.',
2020 components: {
2121 Footer: './src/components/Footer.astro',
2222 Head: './src/components/Head.astro',
5656 head: [
5757 { tag: 'link', attrs: { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } },
5858 { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '192x192', href: '/icon-192.png' } },
59− { tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } },
59+ { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '512x512', href: '/icon-512.png' } },
60+ { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' } },
61+ { tag: 'link', attrs: { rel: 'manifest', href: '/site.webmanifest' } },
62+ { tag: 'meta', attrs: { name: 'theme-color', content: '#0f0f11' } },
6063 ],
6164 sidebar: [
6265 {
7073 ],
7174 },
7275 {
76+ label: 'Chat and agents',
77+ items: [
78+ { label: 'Chat', slug: 'guides/chat' },
79+ { label: 'Agents', slug: 'guides/agents' },
80+ { label: 'Sessions', slug: 'guides/agent-sessions' },
81+ { label: 'Agent memory', slug: 'guides/agent-memory' },
82+ { label: 'Routines', slug: 'guides/agent-routines' },
83+ { label: 'Agent budgets and spend', slug: 'guides/agent-budgets' },
84+ { label: 'What agents can do for whom', slug: 'guides/agent-access' },
85+ { label: 'Docs', slug: 'guides/docs' },
86+ { label: 'Agents in your chat app', slug: 'guides/chat-app', badge: { text: 'Soon', variant: 'default' } },
87+ ],
88+ },
89+ {
7390 label: 'Projects',
7491 items: [
7592 { label: 'Projects', slug: 'guides/projects' },
100117 ],
101118 },
102119 {
103− label: 'Agents',
120+ label: 'Agents on code',
104121 items: [
105122 { label: "g1t's agent", slug: 'guides/working-with-g1t' },
106123 { label: 'Guardrails', slug: 'guides/guardrails' },
+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.

+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.

+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.