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.

g1t.sh
403 linesCodeBlameRaw
1/**
2 * Notifications: the notify service (`services/notify`) keeps one feed per
3 * person, a Durable Object holding their open tabs' sockets, their latest
4 * notifications, live unread counts, their browser push subscriptions and
5 * the phones the g1t app pushes to.
6 *
7 * Chat tells it about every message a person should count or hear of, the
8 * events service about every inbox item, and the site forwards each tab's
9 * `/-/live` socket to it. Wire shapes are snake_case end to end.
10 */
11import type { CardAction } from "./chat";
12import type { ServiceBinding } from "./clients";
13import type { User } from "./identity";
14
15/**
16 * What a notification is about; the preferences decide which ones toast and
17 * push. `security` is an urgent security alert (critical or high, a refused
18 * push, a vulnerability report), shown like a mention; the rest of the
19 * security items are ordinary `inbox` ones.
20 */
21export type NotificationKind =
22 | "dm"
23 | "mention"
24 | "thread_reply"
25 | "inbox"
26 /** Retired with g1t's own agents (a session at its cap); kept so stored items and preferences still read. */
27 | "approval"
28 | "security";
29
30export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "approval", "security"];
31
32/** Who it is from: a person, an agent, or g1t itself. */
33export type NotificationActor = {
34 kind: "user" | "agent" | "system";
35 id: string;
36 /** How they show: a display name. */
37 name: string;
38 /** A person's uploaded avatar hash, or null for the letter avatar. */
39 avatar?: string | null;
40 /** What an agent's face is drawn from when it chose none. */
41 avatar_seed?: string | null;
42};
43
44export type FeedNotification = {
45 /** Unique per notification: the same id twice is told once. */
46 id: string;
47 kind: NotificationKind;
48 /** The workspace's slug. */
49 workspace: string;
50 title: string;
51 /** A short preview, a line or two. */
52 body: string;
53 /** Where clicking it goes, relative to the site. */
54 href: string;
55 actor: NotificationActor;
56 /** The conversation it is in, for a chat notification: toasts for the open one are skipped, and pushes collapse by it. */
57 channel_id?: string | null;
58 /** The thread it is in, for a reply: quick replies go there. */
59 thread_root?: string | null;
60 /**
61 * For an inbox item: its thread's key (events' `inbox_items.thread`).
62 * Once the item settles by itself, events names the thread to notify
63 * (`set_inbox` with `settled`): the notification is no longer replayed
64 * to tabs that open later, and open tabs drop it.
65 */
66 inbox_thread?: string | null;
67 /**
68 * The chat card it is about, when that card has something to press,
69 * such as a draft issue (File issue, Discard). The toast and the notifications panel show these
70 * actions, and pressing one goes to the site's `card_action`, exactly as
71 * on the card itself.
72 */
73 card?: NotificationCard | null;
74 created_at: string;
75};
76
77/** A card's place and its main actions, carried on a notification about it. */
78export type NotificationCard = {
79 /** The conversation the card is in. */
80 channel_id: string;
81 /** The message that is the card. */
82 message_id: string;
83 /** The card's actions as it offers them: ids, labels, inputs and links, the same as on the card. */
84 actions: CardAction[];
85};
86
87/** How much a person hears of. Counts always move; this governs toasts and pushes. */
88export type NotifyLevel = "all" | "dms_mentions" | "none";
89
90export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
91
92export type NotifyPreferences = {
93 level: NotifyLevel;
94 /** A different level for a workspace, by slug. */
95 workspaces: Record<string, NotifyLevel>;
96};
97
98/** A change to them: a workspace set to null goes back to the general level. */
99export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> };
100
101export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
102
103/** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */
104export type PushSubscriptionJson = {
105 endpoint: string;
106 expiration_time?: number | null;
107 keys: { p256dh: string; auth: string };
108};
109
110/**
111 * A phone the g1t app runs on, as the app registers it: the push token
112 * Expo gave it (`ExponentPushToken[…]`), which platform, and a name to
113 * know it by (at most 100 characters).
114 */
115export type PushDevice = { token: string; platform: "android" | "ios"; name?: string | null };
116
117/** A conversation's unread counts for one person. */
118export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean };
119
120/**
121 * What a person has unread in a workspace. `chat_unread` leaves out muted
122 * conversations, as the rail's badge does; mentions count everywhere.
123 * `inbox_unread` is the person's whole inbox, which is not per workspace.
124 * `complete` says `per_channel` was read from chat itself (on connect), so
125 * a conversation left out of it has nothing unread.
126 */
127export type FeedCounts = {
128 workspace: string;
129 chat_unread: number;
130 chat_mentions: number;
131 inbox_unread: number;
132 per_channel: Omit<ChannelCounts, "muted">[];
133 complete: boolean;
134};
135
136// ── Presence and status ──────────────────────────────────────────────────
137//
138// Presence is worked out live by the person's feed from their open tabs:
139// `active` while a tab has been used in the last few minutes, `away` when
140// every tab has sat idle (or they set themselves away), `offline` once no
141// tab is open. Status is what they say about themselves: an emoji and a
142// few words, cleared at a time they chose. Do Not Disturb silences toasts
143// and pushes until a time; counts still move.
144//
145// Both are kept by the notify service (the feed, one per person) and told
146// to everyone who shares a workspace with them, over the same socket,
147// through one presence room per workspace. Nothing polls.
148
149export type Presence = "active" | "away" | "offline";
150
151/**
152 * Who set a status: the person, or something acting for them. Calendars
153 * and other integrations set `calendar` or `integration` (with their own
154 * `clear_at`); a status the person set by hand is never replaced by one.
155 */
156export type StatusSource = "manual" | "calendar" | "integration";
157
158export const STATUS_SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"];
159
160export type PersonStatus = {
161 /** One emoji, or a custom emoji's `:name:`; null for none. */
162 emoji: string | null;
163 /** A few words: "In a meeting". At most 100 characters. */
164 text: string;
165 /** When it clears itself (RFC 3339); null keeps it until changed. */
166 clear_at: string | null;
167 source: StatusSource;
168 /** When it was set (RFC 3339). */
169 set_at: string;
170};
171
172/** How a person shows to the people who share a workspace with them. */
173export type PresenceEntry = {
174 user_id: string;
175 username: string;
176 presence: Presence;
177 /** Do Not Disturb, until then (RFC 3339); null when off. */
178 dnd_until: string | null;
179 status: PersonStatus | null;
180 /** When this last changed, in ms: a later word wins. */
181 at: number;
182};
183
184/** The signed-in person's own: what everyone sees, and whether they set themselves away. */
185export type OwnPresence = PresenceEntry & { away_manual: boolean };
186
187/**
188 * A change to your own. `status: null` clears it; `away` sets (or ends)
189 * being away by hand; `dnd_until: null` resumes notifications.
190 */
191export type PresenceChange = {
192 status?: { emoji?: string | null; text: string; clear_at?: string | null; source?: StatusSource } | null;
193 away?: boolean;
194 dnd_until?: string | null;
195};
196
197// ── Live repository pages ────────────────────────────────────────────────
198//
199// A tab on a repository's page asks to watch it (`watchRepo`, through the
200// site, which first checks the person may read it). The events service
201// then tells notify of everything that happens there that a page shows
202// (`repoChanged`, from crates/contracts/src/live.rs), and the actions
203// service of runs' jobs starting and finishing; notify passes each change
204// to the feeds watching that repository (one topic per repository), whose
205// tabs refresh the page (apps/web/app/lib/repo-live.ts).
206
207/**
208 * Something that happened in a repository, as live pages hear of it: what
209 * kind of thing moved and where, never anything written in it.
210 */
211export type RepoChange = {
212 repo_id: string;
213 /** `owner/name`, as the person watching it asked for it; filled in by notify. */
214 repo?: string | null;
215 /** The event's type (`git.push`, `pull.updated`, `checks.completed`, …), or `run.updated` for a run's job starting or finishing. */
216 kind: string;
217 /** The full ref a push or deletion moved (`refs/heads/main`), or a run's. */
218 ref?: string | null;
219 /** The issue or pull request it is on, or the pull request a run ran for. */
220 number?: number | null;
221 /** The commit: where a push left its ref, the one checked or reported on. */
222 sha?: string | null;
223 /** A workflow run's id. */
224 run?: string | null;
225 /** How many commits a push brought to its branch, when it was counted. */
226 commits?: number | null;
227 /** RFC 3339. */
228 at: string;
229};
230
231/** How long one `watchRepo` lasts: a page open longer asks again before it runs out. */
232export const REPO_WATCH_MS = 15 * 60_000;
233
234/** What the feed socket sends a tab. */
235export type FeedEvent =
236 | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences }
237 | { type: "notification"; notification: FeedNotification; toast: boolean }
238 | ({ type: "counts" } & FeedCounts)
239 | { type: "preferences"; preferences: NotifyPreferences }
240 /**
241 * The person's inbox count as it now is, after items arrive or are
242 * marked anywhere; `settled`, the threads of items that settled by
243 * themselves, whose notifications tabs drop.
244 */
245 | { type: "inbox"; unread: number; settled?: string[] }
246 /**
247 * People in a workspace: everyone known on connect (`full`), then each
248 * one as they change.
249 */
250 | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean }
251 /** Your own presence, status and Do Not Disturb, on connect and after every change. */
252 | { type: "me"; me: OwnPresence }
253 /** What changed in repositories you are watching the pages of (`watchRepo`). */
254 | { type: "repo"; changes: RepoChange[] }
255 /**
256 * The person cleared a conversation (their direct message with @g1t):
257 * the notifications from it are gone, so tabs drop them too.
258 */
259 | { type: "forgotten"; workspace: string; channel_id: string };
260
261/**
262 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
263 * without waking the feed), and a state frame whenever it gains or loses
264 * focus, moves to another page, or goes idle (no input for a while) or
265 * back, and the inbox count the page last read.
266 */
267export type FeedClientFrame =
268 | { type: "state"; focused: boolean; path: string; idle?: boolean }
269 | { type: "inbox"; unread: number };
270
271/**
272 * One delivery from chat: counts to move for one person in one
273 * conversation, and maybe a notification. `set` replaces the counts (after
274 * reading, or writing); otherwise they are added.
275 */
276export type FeedDelivery = {
277 user_id: string;
278 workspace: string;
279 counts?: {
280 channel_id: string;
281 unread: number;
282 mentions: number;
283 muted?: boolean | null;
284 set?: boolean;
285 } | null;
286 notification?: FeedNotification | null;
287};
288
289/** What the site hands the feed with a socket: who, and the counts read from chat and the inbox just now. */
290export type FeedSeed = {
291 workspace: string | null;
292 per_channel: ChannelCounts[] | null;
293 inbox_unread: number | null;
294 /** Every workspace the person belongs to, by slug: whose rooms hear of their presence. */
295 workspaces?: string[] | null;
296};
297
298/** Headers the site sets on a forwarded feed socket. */
299export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer";
300export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed";
301
302export type NotifyStatus = {
303 preferences: NotifyPreferences;
304 /** How many browsers get pushes. */
305 subscriptions: number;
306 /** How many phones the g1t app pushes to (absent from a service older than them). */
307 devices?: number;
308 /** Whether this browser's endpoint is one of them, when it was asked about. */
309 subscribed: boolean;
310 /** The public key a browser subscribes with; null when push is not set up. */
311 vapid_public_key: string | null;
312};
313
314export type NotifyApi = {
315 /** Tells a person: their open tabs at once, and a push when none is in front of them. */
316 notify(target: { user_id?: string; username?: string }, notification: FeedNotification): Promise<{ ok: boolean }>;
317 /** Many at once, as chat sends them for a message. */
318 deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>;
319 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
320 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
321 /** A phone the app runs on gets pushes too; the newest 10 are kept. `ok` is false for a token that is not Expo's. */
322 /** `off`: this g1t sends phones nothing (PHONE_PUSH=off). */
323 registerDevice(user: User, device: PushDevice): Promise<{ ok: boolean; off?: boolean }>;
324 /** That phone stops getting them: the app signed out. */
325 unregisterDevice(user: User, token: string): Promise<{ ok: boolean }>;
326 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
327 setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>;
328 /** Sends the person a test notification, toasted and pushed whatever their focus. */
329 test(user: User): Promise<{ ok: boolean; pushed: number }>;
330 /** Your own presence, status and Do Not Disturb. */
331 presence(user: User): Promise<OwnPresence>;
332 /**
333 * Changes your own, and tells everyone who shares a workspace with you.
334 * Integrations set a status for someone the same way, with their own
335 * `source`; one the person set by hand is kept over theirs.
336 */
337 setPresence(user: Pick<User, "id" | "username">, change: PresenceChange): Promise<OwnPresence>;
338 /**
339 * For services: how a workspace's people show now (`userIds` narrows it).
340 * Someone the room has never heard from is not listed: they are offline.
341 */
342 workspacePresence(workspace: string, userIds?: string[] | null): Promise<PresenceEntry[]>;
343 /**
344 * When the person was last on the workspace's Home page (RFC 3339), or
345 * null when they never were. Kept with them, so it is the same on every
346 * device.
347 */
348 lastVisit(user: Pick<User, "id">, workspace: string): Promise<{ seen_at: string | null }>;
349 /**
350 * Marks that visit at `at` (RFC 3339): Home sends the time the page
351 * loaded, once the person has looked at it. It only moves forward, never
352 * past now nor more than a day behind it.
353 */
354 markVisit(user: Pick<User, "id">, workspace: string, at: string): Promise<{ seen_at: string | null }>;
355 /**
356 * The person's tabs hear what changes in the repository, for
357 * `REPO_WATCH_MS`. The caller must have checked they may read it: notify
358 * checks nothing. `repo` is its `owner/name`, passed on with each change.
359 */
360 watchRepo(user: Pick<User, "id">, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }>;
361 /** For services: what changed in repositories (`crates/contracts/src/live.rs`), told to whoever watches each. */
362 repoChanged(changes: RepoChange[]): Promise<{ ok: boolean }>;
363 /**
364 * For chat, after the person cleared a conversation: the notifications
365 * kept from it are deleted, its counts go to nothing, and a
366 * notification of a message from before the clear that arrives late is
367 * dropped. `workspace` is the slug.
368 */
369 forgetConversation(user: Pick<User, "id">, workspace: string, channelId: string): Promise<{ ok: boolean }>;
370};
371
372async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
373 const response = await service.fetch(`https://service/rpc/${method}`, {
374 method: "POST",
375 headers: { "content-type": "application/json" },
376 body: JSON.stringify(args),
377 });
378 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
379 return (await response.json()) as T;
380}
381
382export function notifyClient(service: ServiceBinding): NotifyApi {
383 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
384 return {
385 notify: (target, notification) => call("notify", { user_id: target.user_id ?? null, username: target.username ?? null, notification }),
386 deliver: (items) => call("deliver", { items }),
387 subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }),
388 unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }),
389 registerDevice: (user, device) => call("register_device", { user_id: user.id, device }),
390 unregisterDevice: (user, token) => call("unregister_device", { user_id: user.id, token }),
391 status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }),
392 setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }),
393 test: (user) => call("test", { user_id: user.id, username: user.username }),
394 presence: (user) => call("presence", { user_id: user.id, username: user.username }),
395 setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }),
396 workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? null }),
397 lastVisit: (user, workspace) => call("last_visit", { user_id: user.id, workspace }),
398 markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }),
399 watchRepo: (user, repoId, repo) => call("watch_repo", { user_id: user.id, repo_id: repoId, repo }),
400 repoChanged: (changes) => call("repo_changed", { changes }),
401 forgetConversation: (user, workspace, channelId) => call("forget_conversation", { user_id: user.id, workspace, channel_id: channelId }),
402 };
403}