Skip to content
295 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.

The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.1/**
2 * Comment threads, kept in the page's Yjs document (the `threads` map) in
3 * the shape BlockNote's Yjs thread stores read, so every open editor shows
4 * a new comment the moment it is made. Writes come here, never from the
5 * editor itself: the room applies them after checking who is asking.
6 * Pure (yjs only).
7 *
8 * A thread's metadata carries `page_level: true` for a comment on the
9 * whole page (no anchor in the text) and `quote`, the text it was on.
10 * Comment authors are member keys (`user:<id>`, `agent:<id>`).
11 */
12import type { DocRole, DocThread, DocThreadAction } from "@g1t/contracts";
13import * as Y from "yjs";
14
15import { atLeast } from "./access.ts";
16
17export type ThreadResult = { ok: true; value: unknown; mentions?: string[]; text?: string; thread_id?: string } | { ok: false; code: "not_found" | "forbidden" | "invalid"; message: string };
18
19const MAX_BODY_BYTES = 64 * 1024;
20
21function newId(): string {
22 return crypto.randomUUID();
23}
24
25function commentMap(id: string, author: string, body: unknown, metadata: unknown, now: number): Y.Map<unknown> {
26 const m = new Y.Map<unknown>();
27 m.set("id", id);
28 m.set("userId", author);
29 m.set("createdAt", now);
30 m.set("updatedAt", now);
31 m.set("body", body);
32 m.set("reactionsByUser", new Y.Map());
33 m.set("metadata", metadata ?? null);
34 return m;
35}
36
37function indexOf(comments: Y.Array<Y.Map<unknown>>, id: string): number {
38 for (let i = 0; i < comments.length; i++) if (comments.get(i).get("id") === id) return i;
39 return -1;
40}
41
42/** A comment body's text: BlockNote blocks' inline text, joined. */
43export function bodyText(body: unknown): string {
44 const out: string[] = [];
45 const walk = (value: unknown) => {
46 if (Array.isArray(value)) value.forEach(walk);
47 else if (value && typeof value === "object") {
48 const v = value as Record<string, unknown>;
49 if (v.type === "text" && typeof v.text === "string") out.push(v.text);
50 else if (v.type === "mention" && v.props && typeof v.props === "object") out.push(`@${(v.props as Record<string, string>).name ?? ""}`);
51 else {
52 if (v.content) walk(v.content);
53 if (v.children) walk(v.children);
54 }
55 } else if (typeof value === "string") out.push(value);
56 };
57 walk(body);
58 return out.join(" ").replace(/\s+/g, " ").trim();
59}
60
61/** Member keys a comment body mentions (inline `mention` content). */
62export function bodyMentions(body: unknown): string[] {
63 const out = new Set<string>();
64 const walk = (value: unknown) => {
65 if (Array.isArray(value)) value.forEach(walk);
66 else if (value && typeof value === "object") {
67 const v = value as Record<string, unknown>;
68 if (v.type === "mention" && v.props && typeof v.props === "object") {
69 const p = v.props as Record<string, string>;
70 if ((p.kind === "user" || p.kind === "agent") && p.id) out.add(`${p.kind}:${p.id}`);
71 }
72 if (v.content) walk(v.content);
73 if (v.children) walk(v.children);
74 }
75 };
76 walk(body);
77 return [...out];
78}
79
80function tooBig(body: unknown): boolean {
81 return new TextEncoder().encode(JSON.stringify(body ?? null)).length > MAX_BODY_BYTES;
82}
83
84/**
85 * Applies one comment operation for `actor` (a member key) whose role in
86 * the space is `role`. Anyone who can comment may start threads, reply,
87 * resolve and react; only a comment's author edits it; its author or an
88 * editor deletes it; only editors delete whole threads. `anchor` is done
89 * by the room (edits.ts `anchorThread`), not here.
90 */
91export function applyThreadAction(doc: Y.Doc, actor: string, role: DocRole, action: DocThreadAction, now = Date.now()): ThreadResult {
92 if (!atLeast(role, "comment")) return { ok: false, code: "forbidden", message: "You can read this page but not comment on it." };
93 const threads = doc.getMap<Y.Map<unknown>>("threads");
94 let result: ThreadResult = { ok: false, code: "invalid", message: "Unknown comment operation." };
95 doc.transact(() => {
96 switch (action.op) {
97 case "create": {
98 if (tooBig(action.body)) {
99 result = { ok: false, code: "invalid", message: "That comment is too long." };
100 return;
101 }
102 const id = newId();
103 const thread = new Y.Map<unknown>();
104 thread.set("id", id);
105 thread.set("createdAt", now);
106 thread.set("updatedAt", now);
107 const comments = new Y.Array<Y.Map<unknown>>();
108 const comment = commentMap(newId(), actor, action.body, action.metadata, now);
109 comments.push([comment]);
110 thread.set("comments", comments);
111 thread.set("resolved", false);
112 const metadata = { ...((action.metadata as object | null) ?? {}), ...(action.page_level ? { page_level: true } : {}) };
113 thread.set("metadata", metadata);
114 threads.set(id, thread);
115 result = { ok: true, value: snapshot(thread), mentions: bodyMentions(action.body), text: bodyText(action.body), thread_id: id };
116 return;
117 }
118 case "comment": {
119 const thread = threads.get(action.thread_id);
120 if (!thread) {
121 result = { ok: false, code: "not_found", message: "That thread is gone." };
122 return;
123 }
124 if (tooBig(action.body)) {
125 result = { ok: false, code: "invalid", message: "That comment is too long." };
126 return;
127 }
128 const comment = commentMap(newId(), actor, action.body, action.metadata, now);
129 (thread.get("comments") as Y.Array<Y.Map<unknown>>).push([comment]);
130 thread.set("updatedAt", now);
131 result = { ok: true, value: commentSnapshot(comment), mentions: bodyMentions(action.body), text: bodyText(action.body), thread_id: action.thread_id };
132 return;
133 }
134 case "edit_comment": {
135 const found = findComment(threads, action.thread_id, action.comment_id);
136 if (!found) {
137 result = { ok: false, code: "not_found", message: "That comment is gone." };
138 return;
139 }
140 if (found.comment.get("userId") !== actor) {
141 result = { ok: false, code: "forbidden", message: "Only its author can edit a comment." };
142 return;
143 }
144 found.comment.set("body", action.body);
145 found.comment.set("updatedAt", now);
146 found.comment.set("metadata", action.metadata ?? found.comment.get("metadata") ?? null);
147 result = { ok: true, value: null };
148 return;
149 }
150 case "delete_comment": {
151 const found = findComment(threads, action.thread_id, action.comment_id);
152 if (!found) {
153 result = { ok: false, code: "not_found", message: "That comment is gone." };
154 return;
155 }
156 if (found.comment.get("userId") !== actor && !atLeast(role, "edit")) {
157 result = { ok: false, code: "forbidden", message: "Only its author or an editor can delete a comment." };
158 return;
159 }
160 const comments = found.thread.get("comments") as Y.Array<Y.Map<unknown>>;
161 if (action.soft) {
162 found.comment.set("deletedAt", now);
163 found.comment.set("body", undefined);
164 } else comments.delete(found.index, 1);
165 const left = comments.toArray().filter((c) => !c.get("deletedAt"));
166 if (!left.length) {
167 if (action.soft) found.thread.set("deletedAt", now);
168 else threads.delete(action.thread_id);
169 }
170 found.thread.set("updatedAt", now);
171 result = { ok: true, value: null };
172 return;
173 }
174 case "delete_thread": {
175 if (!threads.get(action.thread_id)) {
176 result = { ok: false, code: "not_found", message: "That thread is gone." };
177 return;
178 }
179 if (!atLeast(role, "edit")) {
180 result = { ok: false, code: "forbidden", message: "Only editors can delete a thread." };
181 return;
182 }
183 threads.delete(action.thread_id);
184 result = { ok: true, value: null };
185 return;
186 }
187 case "resolve":
188 case "unresolve": {
189 const thread = threads.get(action.thread_id);
190 if (!thread) {
191 result = { ok: false, code: "not_found", message: "That thread is gone." };
192 return;
193 }
194 thread.set("resolved", action.op === "resolve");
195 thread.set("resolvedUpdatedAt", now);
196 if (action.op === "resolve") thread.set("resolvedBy", actor);
197 result = { ok: true, value: null };
198 return;
199 }
200 case "react":
201 case "unreact": {
202 const found = findComment(threads, action.thread_id, action.comment_id);
203 if (!found) {
204 result = { ok: false, code: "not_found", message: "That comment is gone." };
205 return;
206 }
207 const emoji = String(action.emoji ?? "").slice(0, 64);
208 if (!emoji) {
209 result = { ok: false, code: "invalid", message: "Choose an emoji." };
210 return;
211 }
212 const reactions = found.comment.get("reactionsByUser") as Y.Map<Y.Map<unknown>>;
213 const key = `${actor}-${emoji}`;
214 if (action.op === "react" && !reactions.has(key)) {
215 const r = new Y.Map<unknown>();
216 r.set("emoji", emoji);
217 r.set("createdAt", now);
218 r.set("userId", actor);
219 reactions.set(key, r);
220 }
221 if (action.op === "unreact") reactions.delete(key);
222 result = { ok: true, value: null };
223 return;
224 }
225 default:
226 return;
227 }
228 });
229 return result;
230}
231
232function findComment(threads: Y.Map<Y.Map<unknown>>, threadId: string, commentId: string) {
233 const thread = threads.get(threadId);
234 if (!thread) return null;
235 const comments = thread.get("comments") as Y.Array<Y.Map<unknown>> | undefined;
236 if (!comments) return null;
237 const index = indexOf(comments, commentId);
238 if (index < 0) return null;
239 return { thread, comment: comments.get(index), index };
240}
241
242function commentSnapshot(c: Y.Map<unknown>) {
243 return {
244 type: "comment",
245 id: c.get("id"),
246 userId: c.get("userId"),
247 createdAt: c.get("createdAt"),
248 updatedAt: c.get("updatedAt"),
249 body: c.get("body"),
250 reactions: [],
251 metadata: c.get("metadata"),
252 };
253}
254
255function snapshot(t: Y.Map<unknown>) {
256 return {
257 type: "thread",
258 id: t.get("id"),
259 createdAt: t.get("createdAt"),
260 updatedAt: t.get("updatedAt"),
261 comments: ((t.get("comments") as Y.Array<Y.Map<unknown>>)?.toArray() ?? []).map(commentSnapshot),
262 resolved: t.get("resolved"),
263 metadata: t.get("metadata"),
264 };
265}
266
267/** Sets a thread's quote (the text it is on), once anchored. */
268export function setQuote(doc: Y.Doc, threadId: string, quote: string): void {
269 const thread = doc.getMap<Y.Map<unknown>>("threads").get(threadId);
270 if (!thread) return;
271 doc.transact(() => thread.set("metadata", { ...((thread.get("metadata") as object | null) ?? {}), quote: quote.slice(0, 500) }));
272}
273
274/** Threads as agents and lists read them; authors as member keys, resolved later. */
275export function listThreads(doc: Y.Doc): (Omit<DocThread, "comments"> & { comments: { id: string; author: string; text: string; created_at: string }[] })[] {
276 const out: (Omit<DocThread, "comments"> & { comments: { id: string; author: string; text: string; created_at: string }[] })[] = [];
277 doc.getMap<Y.Map<unknown>>("threads").forEach((t, id) => {
278 if (!(t instanceof Y.Map) || t.get("deletedAt")) return;
279 const meta = (t.get("metadata") as { quote?: string; page_level?: boolean } | null) ?? {};
280 out.push({
281 id,
282 quote: meta.page_level ? null : (meta.quote ?? null),
283 resolved: !!t.get("resolved"),
284 comments: ((t.get("comments") as Y.Array<Y.Map<unknown>>)?.toArray() ?? [])
285 .filter((c) => !c.get("deletedAt"))
286 .map((c) => ({
287 id: String(c.get("id")),
288 author: String(c.get("userId")),
289 text: bodyText(c.get("body")),
290 created_at: new Date(Number(c.get("createdAt")) || 0).toISOString(),
291 })),
292 });
293 });
294 return out.sort((a, b) => (a.comments[0]?.created_at ?? "").localeCompare(b.comments[0]?.created_at ?? ""));
295}