Skip to content

Architecture

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.

Every state change in g1t is published as an event: a push, an issue opened, a pull request merged, a workflow finished, a workspace renamed. Services do not call each other to announce what happened. They publish, and the events service (services/events, Rust) delivers.

Every event has the same shape, which follows CloudEvents (crates/contracts/src/events.rs, packages/contracts/src/events.ts):

Field What it is
id evt_ and a UUIDv7: sortable by the time it was made
type Such as git.push, pull.merged, workflow.completed
source The service that published it
time When, in RFC 3339
repoId The repository it is about, or none
actor The account that caused it, or none
data The type’s own fields, in its contract

Coordination has its own events, published by work: sessions starting and ending, claims, hand-offs, dependencies, overlaps, and the merge queue’s verdicts (integration.failed, integration.passed, decision.recorded). They reach webhooks and followers like any other (Work and the merge queue).

Two fields in data matter everywhere:

  • causedByJob marks an event caused by a workflow’s own token, so it does not start more workflows.
  • truncated: true marks an event that was too big for a queue message (96 KB) and had its long strings cut, step by step, until it fit.
One publish, written once to the log, then copied to a queue for each service that subscribesDiagram: One publish, written once to the log, then copied to a queue for each service that subscribes - Publishers: repos (git.push, repo.); work (pull., issue.); actions (workflow.); identity (workspace.); and the rest (any service) - Subscriber queues: work; runner; actions; webhooks; security; search; deployments; projects; repos; packages; billing; integrations; artifacts - events (publish → sendBatch); Queue g1t-events (batches of 100); events (deliver); D1 g1t-events (the log, by seq); notify (live pages, inbox); Dead letters (g1t-events-dlq) Arrows: - repos → events - work → events - actions → events - identity → events - and the rest → events - events ⇢ Queue g1t-events (asynchronous) - Queue g1t-events ⇢ events (asynchronous) - events → D1 g1t-events: INSERT OR IGNORE - events → notify: repo_changed - Queue g1t-events ⇢ Dead letters: after 3 tries (asynchronous) - events ⇢ work (asynchronous) - events ⇢ runner (asynchronous) - events ⇢ actions (asynchronous) - events ⇢ webhooks (asynchronous) - events ⇢ security (asynchronous) - events ⇢ search (asynchronous) - events ⇢ deployments (asynchronous) - events ⇢ projects (asynchronous) - events ⇢ repos (asynchronous) - events ⇢ packages (asynchronous) - events ⇢ billing (asynchronous) - events ⇢ integrations (asynchronous) - events ⇢ artifacts (asynchronous)PublishersSubscriber queuesreposgit.push, repo.*workpull.*, issue.*actionsworkflow.*identityworkspace.*and the restany serviceeventspublish → sendBatchQueue g1t-eventsbatches of 100eventsdeliverD1 g1t-eventsthe log, by seqnotifylive pages, inboxDead lettersg1t-events-dlqworkrunneractionswebhookssecuritysearchdeploymentsprojectsrepospackagesbillingintegrationsartifactsINSERT OR IGNORErepo_changedafter 3 tries
One publish, written once to the log, then copied to a queue for each service that subscribes
One publish, written once to the log, then copied to a queue for each service that subscribes
One publish, written once to the log, then copied to a queue for each service that subscribesDiagram: One publish, written once to the log, then copied to a queue for each service that subscribes - Publishers: repos (git.push, repo.); work (pull., issue.); actions (workflow.); identity (workspace.); and the rest (any service) - Subscriber queues: work; runner; actions; webhooks; security; search; deployments; projects; repos; packages; billing; integrations; artifacts - events (publish → sendBatch); Queue g1t-events (batches of 100); events (deliver); D1 g1t-events (the log, by seq); notify (live pages, inbox); Dead letters (g1t-events-dlq) Arrows: - repos → events - work → events - actions → events - identity → events - and the rest → events - events ⇢ Queue g1t-events (asynchronous) - Queue g1t-events ⇢ events (asynchronous) - events → D1 g1t-events: INSERT OR IGNORE - events → notify: repo_changed - Queue g1t-events ⇢ Dead letters: after 3 tries (asynchronous) - events ⇢ work (asynchronous) - events ⇢ runner (asynchronous) - events ⇢ actions (asynchronous) - events ⇢ webhooks (asynchronous) - events ⇢ security (asynchronous) - events ⇢ search (asynchronous) - events ⇢ deployments (asynchronous) - events ⇢ projects (asynchronous) - events ⇢ repos (asynchronous) - events ⇢ packages (asynchronous) - events ⇢ billing (asynchronous) - events ⇢ integrations (asynchronous) - events ⇢ artifacts (asynchronous)PublishersSubscriber queuesreposgit.push, repo.*workpull.*, issue.*actionsworkflow.*identityworkspace.*and the restany serviceeventspublish → sendBatchQueue g1t-eventsbatches of 100eventsdeliverD1 g1t-eventsthe log, by seqnotifylive pages, inboxDead lettersg1t-events-dlqworkrunneractionswebhookssecuritysearchdeploymentsprojectsrepospackagesbillingintegrationsartifactsINSERT OR IGNORErepo_changedafter 3 tries
  1. A service calls events’ publish with a batch of events. events gives each its id and time and puts them on the g1t-events queue, then returns. Publishing never waits on a subscriber.
  2. events reads its own queue, 100 messages at a time, and for each batch:
    • writes the events to the log with INSERT OR IGNORE, so a batch that comes round twice is written once;
    • sends each subscriber queue the events routed to it;
    • tells notify, so open pages of that repository refresh, and puts items in people’s inboxes, at most once per person per event.
  3. If a send to a subscriber fails, events notes which queues already have the batch (fanout_sent, kept a day) and throws. The retry skips those queues.
  4. After three tries, a batch goes to g1t-events-dlq.

Every subscriber’s consumer is set up the same way: three tries, then the dead-letter queue.

Each service that reacts to events has its own queue, g1t-events-<service>. It gets only the types it needs, as ROUTES in crates/contracts/src/subscribers.rs says, plus every lifecycle event (renames, transfers and deletions of repositories, workspaces and users), which every subscriber gets.

Queue Read by Gets
g1t-events-work work git.push, branch.renamed, repo.created, repo.archived, repo.default_branch_changed, pull.merged, comment.created
g1t-events-runner runner Pull request changes, checks.completed, queue.changed, comment.created, issue changes, repo.archived
g1t-events-actions actions git.push, git.deleted, pull.*, issue.*, comment.*, release.*, deployments, workflow.completed, repo.archived
g1t-events-webhooks webhooks Everything
g1t-events-security security git.push, pull request changes, comment.created, repo.created, repo.visibility_changed
g1t-events-search search git.push, repo.*, issue.*, pull.*, user.*, workspace.*
g1t-events-deployments deployments Pushes, the pull request lifecycle, workspace deletion and restore
g1t-events-projects projects Repositories, packages and deployments
g1t-events-repos repos pull.merged, pull.closed, pull.reopened, deletions and restores
g1t-events-packages packages git.push, repo.visibility_changed, workspace and team changes
g1t-events-integrations integrations issue.closed, pull.opened
g1t-events-billing billing Lifecycle events only
g1t-events-docs artifacts git.push, pull.merged

A subscriber should treat every event as possibly delivered twice, and make its handling idempotent: most do it with a unique key on the event’s id.

g1t-events’ D1 database keeps the log. Each event gets a seq as it is written, one higher than the last; the log has one writer, the consumer above. Ids are made by publishers in many places, so they can arrive out of order. Reading “everything after this event” therefore follows seq, not id.

You can read a repository’s log with the API’s list_events, newest first, 50 a page and up to 200, filtered by type. Status and check events are left out unless you ask for their type. The same database keeps the audit log, for 400 days.

Pass after, an event id, and list_events returns what the log wrote after it, oldest first. Add wait, up to 25 seconds, and the call waits for something to arrive:

Following a repository’s events: a long poll that resumes from the last event it sawDiagram: Following a repository’s events: a long poll that resumes from the last event it saw 1. g1t CLI → API: list_events after=evt_…, wait=25 2. API → events: list after the cursor 3. events ⇠ API: nothing yet - Note (API, events): asks again about once a second, for up to 25 s 4. API → events: list after the cursor 5. events ⇠ API: events, in seq order 6. API ⇠ g1t CLI: events, oldest first 7. g1t CLI → g1t CLI: ask again after the last idg1t CLIwork events --followAPIapps/apieventsservices/eventslist_events after=evt_…, wait=251list after the cursor2nothing yet3asks again about once a second, for up to 25 slist after the cursor4events, in seq order5events, oldest first67ask again after the last id
Following a repository’s events: a long poll that resumes from the last event it saw
Following a repository’s events: a long poll that resumes from the last event it saw
Following a repository’s events: a long poll that resumes from the last event it sawDiagram: Following a repository’s events: a long poll that resumes from the last event it saw 1. g1t CLI → API: list_events after=evt_…, wait=25 2. API → events: list after the cursor 3. events ⇠ API: nothing yet - Note (API, events): asks again about once a second, for up to 25 s 4. API → events: list after the cursor 5. events ⇠ API: events, in seq order 6. API ⇠ g1t CLI: events, oldest first 7. g1t CLI → g1t CLI: ask again after the last idg1t CLIwork events --followAPIapps/apieventsservices/eventslist_events after=evt_…, wait=251list after the cursor2nothing yet3asks again about once a second, for up to 25 slist after the cursor4events, in seq order5events, oldest first67ask again after the last id

The API asks the log about once a second while it waits (apps/api/src/follow.rs). g1t work events --follow runs this in a loop, starting each call from the last event it saw, so it misses nothing between calls. Coding agents get the same over MCP, with the list_events tool.

webhooks reads every event and delivers those each subscription asks for:

Request POST with JSON, to public addresses only, with a 10 second timeout. Anything but a 2xx is a failure.
Headers x-g1t-event, x-g1t-delivery, x-g1t-hook, and x-g1t-signature-256: sha256=<HMAC-SHA256 of the body>
Body { id, type, time, workspace, repository: { id, full_name }, actor: { id, username }, data }, in snake_case
Retries After 1 minute, 5 minutes, 30 minutes, 2 hours and 5 hours: six attempts in about seven and a half hours
At most once A delivery is unique on the subscription and the event’s id
Kept 14 days

Each subscription’s secret is sealed in D1. Webhooks shows how to check a signature.

notify (TypeScript) keeps three kinds of Durable Object:

Object One per Holds
Feed Person Their open tabs’ sockets, notifications, counts and push subscriptions
Room Workspace Who is here
Topic Repository The pages open on it, told by repo_changed to refresh

Sockets use the WebSocket Hibernation API, so an idle tab costs nothing while it waits. Browser push uses VAPID, and phones get Expo push; both are sent to live 12 hours.

chat and artifacts work the same way with rooms of their own: a ChannelRoom per channel, and a PageRoom per doc that syncs edits as a Yjs document and saves them to D1 a few seconds after a burst of typing.