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
323 linesCodeBlameRaw
1/**
2 * One channel's live room, as a Durable Object named by the channel id.
3 *
4 * Why an object per channel: everyone looking at a channel must see the
5 * same messages in the same order the moment they are written, and one
6 * object per channel sees every socket open on it. It holds the sockets
7 * with the WebSocket Hibernation API, so a quiet channel with people
8 * looking at it costs nothing between messages: the object is evicted
9 * from memory and the sockets stay open at the edge. Each socket carries
10 * who is behind it (`serializeAttachment`), which survives hibernation.
11 *
12 * The room authorizes nothing. The Worker checks a viewer may read the
13 * channel before forwarding their socket here (src/index.ts, `live`), and
14 * tells the room to drop someone's sockets when they leave a private one.
15 */
16import { DurableObject } from "cloudflare:workers";
17
18import {
19 G1T_KEY,
20 integrationsClient,
21 principalKey,
22 type AskMessage,
23 type AskPage,
24 type ChatAnswer,
25 type ChatLiveEvent,
26 type ChatMessage,
27 type MemberProfile,
28 type ServiceBinding,
29 type User,
30} from "@g1t/contracts";
31
32import { runAnswer } from "./answer.ts";
33import { G1T_PROFILE, answerAllowed, metaColumn } from "./g1t.ts";
34import { PERSON_TYPING_MS, isTypingFrame } from "./messages.ts";
35import { notifyMessage } from "./notify.ts";
36
37/** Header the Worker sets on a socket it forwards: who it is, as JSON (`RoomMember`). */
38export const ROOM_MEMBER_HEADER = "x-g1t-chat-member";
39
40export type RoomMember = { channel_id: string; member: MemberProfile };
41
42/** The shortest time between two "is typing" events from one socket. */
43const TYPING_EVERY_MS = 2_000;
44/** How long g1t shows as typing before its first words, at most. */
45const G1T_TYPING_MS = 30_000;
46
47type RoomEnv = {
48 DB: D1Database;
49 /** Ask (services/integrations): where @g1t's answers come from. */
50 INTEGRATIONS?: ServiceBinding;
51 NOTIFY?: ServiceBinding;
52};
53
54/**
55 * A question for @g1t to answer, as src/index.ts hands it over: who asked
56 * (the person, as their session had them), the conversation, the page, and
57 * the message the answer is written into.
58 */
59export type AnswerJob = {
60 slug: string;
61 channel: { id: string; kind: "channel" | "dm"; name: string | null };
62 actor: User;
63 messages: AskMessage[];
64 page: AskPage;
65 message: ChatMessage;
66 /**
67 * The person's newest message the answer read. Gone when it is looked
68 * for: the conversation was cleared after it was read, and the answer
69 * is thrown away (`clear`). Absent on a job handed over before it was.
70 */
71 question_id?: string;
72};
73
74export class ChannelRoom extends DurableObject<RoomEnv> {
75 /** When each socket last said it was typing; lost on hibernation, which only lets one more through. */
76 private typedAt = new WeakMap<WebSocket, number>();
77 /** The answer being written now, to stop; one at a time per room. */
78 private running: { id: string; stop: AbortController; superseded: boolean; cleared: boolean } | null = null;
79
80 constructor(ctx: DurableObjectState, env: RoomEnv) {
81 super(ctx, env);
82 // Keepalives are answered without waking the object.
83 ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
84 }
85
86 override async fetch(request: Request): Promise<Response> {
87 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
88 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
89 }
90 let who: RoomMember;
91 try {
92 who = JSON.parse(request.headers.get(ROOM_MEMBER_HEADER) ?? "") as RoomMember;
93 } catch {
94 return new Response("Missing member\n", { status: 400 });
95 }
96 const pair = new WebSocketPair();
97 const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
98 // Tagged with the member's key, so their sockets can be found to drop.
99 this.ctx.acceptWebSocket(server, [principalKey(who.member)]);
100 server.serializeAttachment(who);
101 return new Response(null, { status: 101, webSocket: client });
102 }
103
104 /** Sends `event` to every socket in the room, except those of `except` (a principal key). */
105 broadcast(event: ChatLiveEvent, except: string | null = null): void {
106 const text = JSON.stringify(event);
107 for (const socket of this.ctx.getWebSockets()) {
108 if (except && this.ctx.getTags(socket).includes(except)) continue;
109 try {
110 socket.send(text);
111 } catch {
112 // Closing already; webSocketClose tidies up.
113 }
114 }
115 }
116
117 /** Closes a member's sockets: they left a private channel and may no longer read it. */
118 drop(principal: string): void {
119 for (const socket of this.ctx.getWebSockets(principal)) {
120 try {
121 socket.close(4403, "No longer a member");
122 } catch {
123 // Already closed.
124 }
125 }
126 }
127
128 // ── @g1t's answers (src/answer.ts) ──────────────────────────────────────
129
130 /**
131 * Answers a question in this direct message with g1t. One answer at a
132 * time: a newer question takes over, stopping the one being written
133 * (what came of it stays; one that had not started is taken away), and
134 * the new answer reads both questions. Run from an alarm, so a long
135 * answer is not cut short when the request that asked ends.
136 */
137 async startAnswer(job: AnswerJob): Promise<void> {
138 // Read before a clear that has deleted its question since: thrown away, never asked.
139 if (await this.questionGone(job)) {
140 await this.discard(job.channel.id, job.message.id);
141 return;
142 }
143 const times = answerAllowed((await this.ctx.storage.get<number[]>("answered")) ?? [], Date.now());
144 await this.ctx.storage.put("answered", times.times);
145 if (!times.ok) {
146 const answer: ChatAnswer = { state: "error", error: "Too many questions in a minute." };
147 await this.end(job, "You've asked a lot in the last minute. Wait a moment, then ask again.", answer, false);
148 return;
149 }
150 const pending = await this.ctx.storage.get<AnswerJob>("job");
151 if (pending) await this.dropAnswer(pending);
152 if (this.running) {
153 this.running.superseded = true;
154 this.running.stop.abort();
155 }
156 await this.ctx.storage.put("job", job);
157 await this.ctx.storage.setAlarm(Date.now());
158 }
159
160 /** Stops the answer being written, or about to be; what came stays. */
161 async stopAnswer(): Promise<void> {
162 const pending = await this.ctx.storage.get<AnswerJob>("job");
163 if (pending) {
164 await this.ctx.storage.delete("job");
165 await this.end(pending, "_Stopped before answering._", { ...pending.message.answer, state: "stopped" }, false);
166 }
167 this.running?.stop.abort();
168 }
169
170 override async alarm(): Promise<void> {
171 const job = await this.ctx.storage.get<AnswerJob>("job");
172 if (!job) return;
173 await this.ctx.storage.delete("job");
174 const run = { id: job.message.id, stop: new AbortController(), superseded: false, cleared: false };
175 // Running before it looks again, so a clear from now on finds it and stops it.
176 this.running = run;
177 if (await this.questionGone(job)) {
178 if (this.running === run) this.running = null;
179 await this.discard(job.channel.id, job.message.id);
180 return;
181 }
182 const ask = integrationsClient(this.env.INTEGRATIONS as ServiceBinding);
183 try {
184 await runAnswer(
185 {
186 slug: job.slug,
187 setup: async () => {
188 if (!this.env.INTEGRATIONS) return null;
189 const found = await ask.askSettings(job.slug, job.actor);
190 return found.ok ? found.value : { connection_id: "unknown", connection_name: null, provider: null, model: null, problem: found.error.message };
191 },
192 ask: (signal) => ask.ask(job.actor, job.slug, job.messages, job.page, signal),
193 typing: () =>
194 this.broadcast({ type: "typing", channel_id: job.channel.id, member: G1T_PROFILE, until: new Date(Date.now() + G1T_TYPING_MS).toISOString() }),
195 show: (body, answer) => {
196 if (!run.cleared) this.broadcast({ type: "message.streaming", channel_id: job.channel.id, id: job.message.id, body, answer });
197 },
198 save: (body, answer) => (run.cleared ? Promise.resolve() : this.store(job.message.id, body, answer)),
199 finish: (body, answer) => (run.cleared ? this.discard(job.channel.id, job.message.id) : this.end(job, body, answer, run.superseded)),
200 now: () => Date.now(),
201 },
202 run.stop.signal,
203 );
204 } catch (error) {
205 console.error("g1t could not answer in", job.channel.id, error);
206 } finally {
207 if (this.running === run) this.running = null;
208 }
209 }
210
211 /**
212 * The conversation was just cleared (src/index.ts `g1tClear`, after its
213 * rows were deleted): an answer waiting to start is dropped, one being
214 * written is stopped and thrown away, not saved, so nothing it read
215 * comes back; and every tab looking empties. What it had already sent
216 * the workspace's AI provider has gone to that provider.
217 */
218 async clear(channelId: string): Promise<void> {
219 const pending = await this.ctx.storage.get<AnswerJob>("job");
220 await this.ctx.storage.delete("job");
221 const ids = new Set<string>();
222 if (pending) ids.add(pending.message.id);
223 if (this.running) {
224 this.running.cleared = true;
225 this.running.stop.abort();
226 ids.add(this.running.id);
227 }
228 // Written after the rows went, an answer's own message would outlive the clear.
229 for (const id of ids) await this.env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
230 this.broadcast({ type: "channel.cleared", channel_id: channelId });
231 }
232
233 /** Whether the question a job answers has been deleted by a clear. */
234 private async questionGone(job: AnswerJob): Promise<boolean> {
235 if (!job.question_id) return false;
236 const row = await this.env.DB.prepare("SELECT id FROM messages WHERE id = ?").bind(job.question_id).first<{ id: string }>();
237 return !row;
238 }
239
240 /** An answer to a cleared conversation: its message goes for good, and tabs drop it. */
241 private async discard(channelId: string, id: string): Promise<void> {
242 await this.env.DB.prepare("DELETE FROM messages WHERE id = ?").bind(id).run();
243 this.broadcast({ type: "message.deleted", channel_id: channelId, id });
244 }
245
246 /** Saves an answer as it is now. */
247 private async store(id: string, body: string, answer: ChatAnswer): Promise<void> {
248 await this.env.DB.prepare("UPDATE messages SET body = ?, meta = ? WHERE id = ? AND deleted_at IS NULL")
249 .bind(body, metaColumn({ answer }), id)
250 .run();
251 }
252
253 /** A question taken over before its answer started: its empty message goes. */
254 private async dropAnswer(job: AnswerJob): Promise<void> {
255 await this.ctx.storage.delete("job");
256 await this.env.DB.prepare("UPDATE messages SET deleted_at = ?, body = '' WHERE id = ?").bind(new Date().toISOString(), job.message.id).run();
257 this.broadcast({ type: "message.deleted", channel_id: job.channel.id, id: job.message.id });
258 }
259
260 /**
261 * Ends an answer: stored as a normal message, sent to the room, and the
262 * person notified (their direct message notifies them, as any would).
263 * One taken over by a newer question before a word of it came is taken
264 * away instead.
265 */
266 private async end(job: AnswerJob, body: string, answer: ChatAnswer, superseded: boolean): Promise<void> {
267 const { message, channel } = job;
268 if (superseded && answer.state === "stopped" && !message.body && body.startsWith("_Stopped")) {
269 await this.dropAnswer(job);
270 return;
271 }
272 const at = new Date().toISOString();
273 const [saved] = await this.env.DB.batch([
274 this.env.DB.prepare("UPDATE messages SET body = ?, meta = ? WHERE id = ?").bind(body, metaColumn({ answer }), message.id),
275 this.env.DB.prepare("UPDATE channels SET last_message_at = ? WHERE id = ? AND EXISTS (SELECT 1 FROM messages WHERE id = ?)").bind(at, channel.id, message.id),
276 ]);
277 // Its message is gone: the conversation was cleared meanwhile. Nobody is shown or told it.
278 if (saved && saved.meta && saved.meta.changes === 0) return;
279 this.broadcast({ type: "message.updated", message: { ...message, body, answer } });
280 if (answer.state === "stopped") return;
281 await notifyMessage(
282 this.env.DB,
283 this.env.NOTIFY,
284 // The person's name only matters for mentions, which g1t's answers do not notify by.
285 async (keys) => new Map(keys.map((key) => [key, key === G1T_KEY ? G1T_PROFILE : ({ kind: "user", id: key.slice(5), name: "", display_name: "", avatar: null, role: null } as MemberProfile)])),
286 { slug: job.slug, channel, row: { id: message.id, author: G1T_KEY, body, thread_root: null, created_at: message.created_at }, handles: [] },
287 ).catch((error) => console.error("chat could not notify about", message.id, error));
288 }
289
290 override async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
291 const who = socket.deserializeAttachment() as RoomMember | null;
292 if (!who) return;
293 // The only thing a client says over the socket: it is typing
294 // (`{"type":"typing","channel_id":"<id>"}`, at most every 3 s).
295 // Everything else (posting, reading) goes through the site, which
296 // checks it.
297 if (!isTypingFrame(message, who.channel_id)) return;
298 const now = Date.now();
299 if (now - (this.typedAt.get(socket) ?? 0) < TYPING_EVERY_MS) return;
300 this.typedAt.set(socket, now);
301 this.broadcast(
302 {
303 type: "typing",
304 channel_id: who.channel_id,
305 member: who.member,
306 until: new Date(now + PERSON_TYPING_MS).toISOString(),
307 },
308 principalKey(who.member),
309 );
310 }
311
312 override async webSocketClose(socket: WebSocket, code: number, reason: string): Promise<void> {
313 try {
314 socket.close(code, reason);
315 } catch {
316 // Already closed.
317 }
318 }
319
320 override async webSocketError(): Promise<void> {
321 // Nothing to keep: the socket is gone, and getWebSockets no longer lists it.
322 }
323}