Skip to content
147 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1/**
2 * The notify service: live notifications, unread counts and browser push.
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.3 * One feed per person (src/feed.ts).
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)4 *
5 * Reached through service bindings only: `POST /rpc/<method>` with
6 * snake_case bodies (`notifyClient` in @g1t/contracts), and `GET /live`,
7 * the person's feed socket, which the site forwards after checking the
8 * session (NOTIFY_VIEWER_HEADER), with the counts it read (NOTIFY_SEED_HEADER).
9 *
10 * Who sends what:
11 * - chat: `deliver`, for every message (counts for each person in the
12 * conversation, a notification for those it is for) and every read;
13 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
14 * items arrive or are marked, naming the person by username;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers15 * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`,
16 * `test`, `presence` and `set_presence`, for the person signed in;
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.17 * - agents and the site: `workspace_presence`, how a workspace's people
18 * show now (away, in Do Not Disturb, offline, and their statuses);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers19 * - integrations, later: `set_presence` with a status of `source`
20 * `calendar` or `integration`, for the person they act for.
21 *
22 * Presence: each feed works out its person's from their tabs and tells
23 * one room per workspace (src/room.ts), which tells everyone there who is
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.24 * online. See docs.g1t.sh/guides/chat/, "Presence and status".
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)25 */
26import {
27 NOTIFY_VIEWER_HEADER,
28 identityClient,
29 type FeedDelivery,
30 type ServiceBinding,
31 type Viewer,
32} from "@g1t/contracts";
33
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers34import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts";
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.35import { onlyPeople } from "./presence.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)36
37export { Feed } from "./feed.ts";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers38export { Room } from "./room.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)39
40type Env = FeedEnv & {
41 IDENTITY: ServiceBinding;
42 FEEDS: DurableObjectNamespace<Feed>;
43};
44
45function feed(env: Env, userId: string) {
46 return env.FEEDS.get(env.FEEDS.idFromName(userId));
47}
48
49/** A user id from the arguments, or one looked up by username. */
Agents' budget alerts and sessions waiting for approval reach the person they're for: notifications finds who to tell under target as well as at the top level.50async function userIdOf(env: Env, given: { user_id?: unknown; username?: unknown; target?: unknown }): Promise<string | null> {
51 // Who it's for, at the top level or under `target` as the agents service sends it.
52 const target = given.target && typeof given.target === "object" ? (given.target as { user_id?: unknown; username?: unknown }) : null;
53 const args = { user_id: given.user_id ?? target?.user_id, username: given.username ?? target?.username };
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)54 if (typeof args.user_id === "string" && args.user_id) return args.user_id;
55 if (typeof args.username !== "string" || !args.username) return null;
56 const user = await identityClient(env.IDENTITY)
57 .userByUsername(args.username)
58 .catch(() => null);
59 return user?.id ?? null;
60}
61
62/** The most deliveries in one call; chat sends one per person in the conversation. */
63const MAX_DELIVERIES = 1000;
64
65async function answer(env: Env, method: string, args: any): Promise<Response> {
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.66 if (method === "workspace_presence") {
67 // For services: how a workspace's people show now, as its room keeps them.
68 const slug = typeof args?.workspace === "string" ? args.workspace.trim().toLowerCase() : "";
69 if (!slug || !env.ROOMS) return Response.json([]);
70 const everyone = await env.ROOMS.get(env.ROOMS.idFromName(slug)).people();
71 return Response.json(onlyPeople(everyone, Array.isArray(args.user_ids) ? args.user_ids : null));
72 }
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)73 if (method === "deliver") {
74 const items = (Array.isArray(args?.items) ? args.items : []).slice(0, MAX_DELIVERIES) as FeedDelivery[];
75 const byUser = new Map<string, FeedDelivery[]>();
76 for (const item of items) {
77 if (typeof item?.user_id !== "string" || !item.user_id) continue;
78 byUser.set(item.user_id, [...(byUser.get(item.user_id) ?? []), item]);
79 }
80 const results = await Promise.allSettled([...byUser].map(([id, mine]) => feed(env, id).deliver(mine)));
81 for (const result of results) if (result.status === "rejected") console.error("notify: a delivery failed", result.reason);
82 return Response.json({ ok: true });
83 }
84 const userId = await userIdOf(env, args ?? {});
85 if (!userId) return Response.json({ ok: false });
86 const stub = feed(env, userId);
87 switch (method) {
88 case "notify":
89 return Response.json(await stub.notify(args.notification));
90 case "set_inbox":
91 return Response.json(await stub.setInbox(Number(args.unread)));
92 case "subscribe":
93 return Response.json(await stub.subscribe(args.subscription, typeof args.user_agent === "string" ? args.user_agent : null));
94 case "unsubscribe":
95 return Response.json(await stub.unsubscribe(String(args.endpoint ?? "")));
96 case "status":
97 return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null));
98 case "set_preferences":
99 return Response.json(await stub.setPreferences(args.preferences));
100 case "test":
101 return Response.json(await stub.test(typeof args.username === "string" ? args.username : ""));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers102 case "presence":
103 return Response.json(await stub.own({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }));
104 case "set_presence":
105 return Response.json(
106 await stub.setPresence({ user_id: userId, username: typeof args.username === "string" ? args.username : "" }, args.change ?? {}),
107 );
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)108 default:
109 return new Response("Unknown method\n", { status: 404 });
110 }
111}
112
113/**
114 * `GET /live`, upgraded: the viewer comes in NOTIFY_VIEWER_HEADER, set by
115 * the site after checking the session; trusted only because this Worker
116 * is reachable through service bindings alone.
117 */
118async function live(request: Request, env: Env): Promise<Response> {
119 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
120 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
121 }
122 let viewer: Viewer = null;
123 try {
124 viewer = JSON.parse(request.headers.get(NOTIFY_VIEWER_HEADER) ?? "null") as Viewer;
125 } catch {
126 viewer = null;
127 }
128 if (!viewer?.id) return new Response("Sign in first\n", { status: 401 });
129 const headers = new Headers(request.headers);
130 headers.delete(NOTIFY_VIEWER_HEADER);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers131 // Who the feed is for, so it can name them to the presence rooms.
132 headers.set(FEED_USER_ID_HEADER, viewer.id);
133 headers.set(FEED_USERNAME_HEADER, viewer.username);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)134 return feed(env, viewer.id).fetch(new Request(request.url, { method: "GET", headers }));
135}
136
137export default {
138 async fetch(request: Request, env: Env): Promise<Response> {
139 const url = new URL(request.url);
140 if (request.method === "GET" && url.pathname === "/live") return live(request, env);
141 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
142 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
143 const args = (await request.json().catch(() => ({}))) as any;
144 return answer(env, match[1], args);
145 },
146} satisfies ExportedHandler<Env>;
147

This file's history is long; its oldest lines are credited to the oldest commit read.