Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

158 linesCodeBlameRaw
1---
2title: Events
3description: How a state change in one service reaches every other that cares, through one log and a queue per subscriber, and how webhooks, live pages and followers read it.
4---
5
6import Aside from '../../../components/Aside.astro';
7import Diagram from '../../../components/Diagram.astro';
8
9Every state change in g1t is published as an event: a push, an issue
10opened, a pull request merged, a workflow finished, a workspace renamed.
11Services do not call each other to announce what happened. They publish,
12and the `events` service (`services/events`, Rust) delivers.
13
14## The envelope
15
16Every event has the same shape, which follows CloudEvents
17(`crates/contracts/src/events.rs`, `packages/contracts/src/events.ts`):
18
19| Field | What it is |
20| --- | --- |
21| `id` | `evt_` and a UUIDv7: sortable by the time it was made |
22| `type` | Such as `git.push`, `pull.merged`, `workflow.completed` |
23| `source` | The service that published it |
24| `time` | When, in RFC 3339 |
25| `repoId` | The repository it is about, or none |
26| `actor` | The account that caused it, or none |
27| `data` | The type's own fields, in its contract |
28
29Coordination has its own events, published by `work`: sessions starting
30and ending, claims, hand-offs, dependencies, overlaps, and the merge
31queue's verdicts (`integration.failed`, `integration.passed`,
32`decision.recorded`). They reach webhooks and followers like any other
33([Work and the merge queue](/architecture/work/#coordination)).
34
35Two fields in `data` matter everywhere:
36
37- `causedByJob` marks an event caused by a workflow's own token, so it
38 does not start more workflows.
39- `truncated: true` marks an event that was too big for a queue message
40 (96 KB) and had its long strings cut, step by step, until it fit.
41
42## From publish to every subscriber
43
44<Diagram name="event-fanout" />
45
461. A service calls `events`' `publish` with a batch of events. `events`
47 gives each its id and time and puts them on the `g1t-events` queue, then
48 returns. Publishing never waits on a subscriber.
492. `events` reads its own queue, 100 messages at a time, and for each
50 batch:
51 - writes the events to the log with `INSERT OR IGNORE`, so a batch that
52 comes round twice is written once;
53 - sends each subscriber queue the events routed to it;
54 - tells `notify`, so open pages of that repository refresh, and puts
55 items in people's inboxes, at most once per person per event.
563. If a send to a subscriber fails, `events` notes which queues already
57 have the batch (`fanout_sent`, kept a day) and throws. The retry skips
58 those queues.
594. After three tries, a batch goes to `g1t-events-dlq`.
60
61Every subscriber's consumer is set up the same way: three tries, then the
62dead-letter queue.
63
64### Subscriber queues
65
66Each service that reacts to events has its own queue,
67`g1t-events-<service>`. It gets only the types it needs, as `ROUTES` in
68`crates/contracts/src/subscribers.rs` says, plus every lifecycle event
69(renames, transfers and deletions of repositories, workspaces and users),
70which every subscriber gets.
71
72| Queue | Read by | Gets |
73| --- | --- | --- |
74| `g1t-events-work` | `work` | `git.push`, `branch.renamed`, `repo.created`, `repo.archived`, `repo.default_branch_changed`, `pull.merged`, `comment.created` |
75| `g1t-events-runner` | `runner` | Pull request changes, `checks.completed`, `queue.changed`, `comment.created`, issue changes, `repo.archived` |
76| `g1t-events-actions` | `actions` | `git.push`, `git.deleted`, `pull.*`, `issue.*`, `comment.*`, `release.*`, deployments, `workflow.completed`, `repo.archived` |
77| `g1t-events-webhooks` | `webhooks` | Everything |
78| `g1t-events-security` | `security` | `git.push`, pull request changes, `comment.created`, `repo.created`, `repo.visibility_changed` |
79| `g1t-events-search` | `search` | `git.push`, `repo.*`, `issue.*`, `pull.*`, `user.*`, `workspace.*` |
80| `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore |
81| `g1t-events-projects` | `projects` | Repositories, packages and deployments |
82| `g1t-events-repos` | `repos` | `pull.merged`, `pull.closed`, `pull.reopened`, deletions and restores |
83| `g1t-events-packages` | `packages` | `git.push`, `repo.visibility_changed`, workspace and team changes |
84| `g1t-events-integrations` | `integrations` | `issue.closed`, `pull.opened` |
85| `g1t-events-billing` | `billing` | Lifecycle events only |
86| `g1t-events-docs` | `artifacts` | `git.push`, `pull.merged` |
87
88A subscriber should treat every event as possibly delivered twice, and
89make its handling idempotent: most do it with a unique key on the event's
90`id`.
91
92## The log
93
94`g1t-events`' D1 database keeps the log. Each event gets a `seq` as it is
95written, one higher than the last; the log has one writer, the consumer
96above. Ids are made by publishers in many places, so they can arrive out
97of order. Reading "everything after this event" therefore follows `seq`,
98not `id`.
99
100You can read a repository's log with the API's `list_events`, newest
101first, 50 a page and up to 200, filtered by type. Status and check events
102are left out unless you ask for their type. The same database keeps the
103audit log, for 400 days.
104
105### Following
106
107Pass `after`, an event id, and `list_events` returns what the log wrote
108after it, oldest first. Add `wait`, up to 25 seconds, and the call waits
109for something to arrive:
110
111<Diagram name="follow-events" />
112
113The API asks the log about once a second while it waits (`apps/api/src/follow.rs`).
114`g1t work events --follow` runs this in a loop, starting each call from the
115last event it saw, so it misses nothing between calls. Coding agents get
116the same over MCP, with the `list_events` tool.
117
118## Webhooks
119
120`webhooks` reads every event and delivers those each subscription asks
121for:
122
123| | |
124| --- | --- |
125| **Request** | `POST` with JSON, to public addresses only, with a 10 second timeout. Anything but a 2xx is a failure. |
126| **Headers** | `x-g1t-event`, `x-g1t-delivery`, `x-g1t-hook`, and `x-g1t-signature-256: sha256=<HMAC-SHA256 of the body>` |
127| **Body** | `{ id, type, time, workspace, repository: { id, full_name }, actor: { id, username }, data }`, in snake_case |
128| **Retries** | After 1 minute, 5 minutes, 30 minutes, 2 hours and 5 hours: six attempts in about seven and a half hours |
129| **At most once** | A delivery is unique on the subscription and the event's `id` |
130| **Kept** | 14 days |
131
132Each subscription's secret is sealed in D1. [Webhooks](/guides/webhooks/)
133shows how to check a signature.
134
135## Live pages
136
137`notify` (TypeScript) keeps three kinds of Durable Object:
138
139| Object | One per | Holds |
140| --- | --- | --- |
141| `Feed` | Person | Their open tabs' sockets, notifications, counts and push subscriptions |
142| `Room` | Workspace | Who is here |
143| `Topic` | Repository | The pages open on it, told by `repo_changed` to refresh |
144
145Sockets use the WebSocket Hibernation API, so an idle tab costs nothing
146while it waits. Browser push uses VAPID, and phones get Expo push; both are
147sent to live 12 hours.
148
149`chat` and `artifacts` work the same way with rooms of their own: a
150`ChannelRoom` per channel, and a `PageRoom` per doc that syncs edits as a
151Yjs document and saves them to D1 a few seconds after a burst of typing.
152
153<Aside title="Scheduled jobs">
154 Some work runs on a clock rather than on events: webhook retries every
155 minute, Actions schedules every minute, fork retirement hourly, backups
156 nightly, the event log's purge daily. Each service's `wrangler.jsonc`
157 lists its `crons`.
158</Aside>