Skip to content
2,322 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.

Docs: a workspace knowledge base people and agents write together1/**
2 * The docs service: a workspace's spaces and pages, their live documents,
3 * history, comments, agents' suggestions, templates and search. Plan:
4 * docs/WORKSPACE.md, "Docs".
5 *
6 * Reached through service bindings: `POST /rpc/<method>` with snake_case
7 * bodies (`docsClient` in @g1t/contracts); `GET /live` for a page's socket
8 * and `PUT /files` for uploads, which the site forwards after checking the
9 * session; `GET /files/<key>` for the usercontent origin to serve a file.
10 *
11 * Each page has a room (src/room.ts), a Durable Object that owns its Yjs
12 * document. Everything that changes a page's content goes through the
13 * room; this Worker decides who may ask.
14 */
15
16import {
17 DOCS_VIEWER_HEADER,
18 DOC_MAX_FILE_BYTES,
19 fail,
20 identityClient,
21 newId,
22 notifyClient,
23 ok,
24 openD1,
25 parsePrincipalKey,
26 principalKey,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store27 reposClient,
Docs: a workspace knowledge base people and agents write together28 workspaceAgentsClient,
29 type DocAgentAbilities,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store30 type DocCitation,
31 type DocDescribes,
32 type DocRepoPage,
33 type DocRepoSpace,
34 type DocStaleChange,
35 type DocStalePage,
36 type DocStaleness,
37 type G1tEvent,
38 type Repo,
Docs: a workspace knowledge base people and agents write together39 type DocAgentEditResult,
40 type DocAgentMode,
41 type DocAgentPage,
42 type DocAudience,
43 type DocEditTarget,
44 type DocFile,
45 type DocMove,
46 type DocPage,
47 type DocPageChange,
48 type DocPageDetail,
49 type DocPageRef,
50 type DocRole,
51 type DocSearchHit,
52 type DocSearchQuery,
53 type DocSpace,
54 type DocSpaceChange,
55 type DocSpaceKind,
56 type DocSpaceMember,
57 type DocSuggestion,
58 type DocTemplate,
59 type DocThread,
60 type DocThreadAction,
61 type DocTreeNode,
62 type DocVersion,
63 type DocVersionDetail,
64 type DocsHome,
65 type DocsLiveEvent,
66 type DocsSidebar,
67 type Member,
68 type MemberProfile,
69 type NewDocPage,
70 type NewDocSpace,
71 type Principal,
72 type Result,
73 type ServiceBinding,
74 type User,
75 type Viewer,
76 type Workspace,
77 type WorkspaceAgent,
78} from "@g1t/contracts";
79
80import { RANK, agentAbilities, atLeast, isRole, leavesNoManager, memberKey, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
81import { diffLines } from "./diff.ts";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store82import { cleanDescribes } from "./citations.ts";
83import { publishDocEvent } from "./events.ts";
84import { fileStore, safeName, servedType, type FileStoreEnv } from "./files.ts";
Docs: a workspace knowledge base people and agents write together85import { excerpt, searchText } from "./markdown.ts";
86import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store87import { indexRepoSpace, reindexRepo, type RepoSpaceRow } from "./repo-spaces.ts";
Docs: a workspace knowledge base people and agents write together88import { ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
89import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts";
90import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts";
91import type { ThreadResult } from "./threads.ts";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store92import { onEvent } from "./staleness.ts";
Docs: a workspace knowledge base people and agents write together93import { descendants, exportPaths, lastPosition, placeBefore, wouldCycle, ancestors } from "./tree.ts";
94
95export { PageRoom } from "./room.ts";
96
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store97type Env = FileStoreEnv & {
Docs: a workspace knowledge base people and agents write together98 DB: D1Database;
99 IDENTITY: ServiceBinding;
100 AGENTS: ServiceBinding;
101 NOTIFY?: ServiceBinding;
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store102 /** Repositories: who may read one, what a change touched, a project's docs (src/staleness.ts, src/repo-spaces.ts). */
103 REPOS?: ServiceBinding;
104 /** Pull requests: what a merged one changed. */
105 WORK?: ServiceBinding;
106 /** The bus: `doc.page.*` events (src/events.ts). */
107 EVENTS?: ServiceBinding;
Docs: a workspace knowledge base people and agents write together108 PAGES: DurableObjectNamespace<PageRoom>;
109};
110
111type SpaceRow = {
112 id: string;
113 workspace_id: string;
114 slug: string;
115 name: string;
116 description: string | null;
117 icon: string | null;
118 kind: DocSpaceKind;
119 team: string | null;
120 default_role: DocRole | null;
121 agent_mode: DocAgentMode;
122 is_default: number;
123 created_by: string;
124 created_at: string;
125 archived_at: string | null;
126};
127
128type PageRow = {
129 id: string;
130 workspace_id: string;
131 space_id: string;
132 parent_id: string | null;
133 position: number;
134 title: string;
135 icon: string | null;
136 cover: string | null;
137 markdown: string;
138 created_by: string;
139 created_at: string;
140 updated_by: string | null;
141 updated_at: string;
142 archived_at: string | null;
143 archived_by: string | null;
144};
145
146type SuggestionRow = {
147 id: string;
148 page_id: string;
149 author: string;
150 asked_by: string | null;
151 target: string;
152 before_markdown: string;
153 after_markdown: string;
154 note: string | null;
155 status: DocSuggestion["status"];
156 created_at: string;
157 decided_by: string | null;
158 decided_at: string | null;
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store159 marks_current?: number;
Docs: a workspace knowledge base people and agents write together160};
161
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store162type ChangeRow = {
163 page_id: string;
164 repo: string;
165 repo_id: string;
166 commit_sha: string;
167 pull_number: number | null;
168 pull_title: string | null;
169 paths: string;
170 detected_at: string;
171 cleared_at: string | null;
172 cleared_by: string | null;
173};
174
Docs: a workspace knowledge base people and agents write together175type VersionRow = { id: string; page_id: string; created_at: string; kind: DocVersion["kind"]; authors: string; note: string | null; markdown: string; state: ArrayBuffer | null };
176
177/** A space, with who is in it and the viewer's role. */
178type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
179
180/** The page columns lists read: everything but the Markdown. */
181const PAGE_COLUMNS = "id, workspace_id, space_id, parent_id, position, title, icon, cover, '' AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by";
182
183const MAX_TITLE = 200;
184const MAX_MARKDOWN = 512 * 1024;
185const MAX_NOTE = 500;
186
187const now = () => new Date().toISOString();
188
189function isMember(viewer: Viewer, workspace: string): boolean {
190 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
191}
192
193function rulesOf(space: Space): SpaceRules {
194 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
195}
196
197function cleanTitle(title: unknown): string {
198 return String(title ?? "")
199 .replace(/\s+/g, " ")
200 .trim()
201 .slice(0, MAX_TITLE);
202}
203
204/** One emoji (or a few characters), or null. */
205function cleanIcon(icon: unknown): string | null {
206 const s = String(icon ?? "").trim();
207 if (!s) return null;
208 return [...s].slice(0, 4).join("");
209}
210
211function cleanCover(cover: unknown): string | null {
212 const s = String(cover ?? "").trim();
213 if (!s) return null;
214 if (/^gradient:\d{1,2}$/.test(s)) return s;
215 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
216 return null;
217}
218
219function cleanProjects(list: unknown): string[] {
220 if (!Array.isArray(list)) return [];
221 return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
222}
223
224function cleanTarget(target: unknown): DocEditTarget | null {
225 const t = target as DocEditTarget | null;
226 if (!t || typeof t !== "object") return null;
227 switch (t.kind) {
228 case "append":
229 case "document":
230 return { kind: t.kind };
231 case "section":
232 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
233 case "blocks":
234 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
235 default:
236 return null;
237 }
238}
239
240class Docs {
241 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
242 private readonly people = new Map<string, Promise<Map<string, Member>>>();
243 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
244 private readonly usernames = new Map<string, string>();
245 private readonly agents = new Map<string, WorkspaceAgent | null>();
246
247 constructor(
248 private readonly env: Env,
249 private readonly defer: (work: Promise<unknown>) => void = () => {},
250 ) {}
251
252 private get db() {
253 return this.env.DB;
254 }
255
256 // ── Who and where ───────────────────────────────────────────────────────
257
258 private workspace(slug: string): Promise<Workspace | null> {
259 const key = String(slug ?? "").toLowerCase();
260 let found = this.workspaces.get(key);
261 if (!found) {
262 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
263 this.workspaces.set(key, found);
264 }
265 return found;
266 }
267
268 /** The workspace acting for itself: how this service asks identity about its members. */
269 private actor(workspace: Workspace): User {
270 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
271 }
272
273 /** The workspace's people by username. */
274 private members(workspace: Workspace): Promise<Map<string, Member>> {
275 let found = this.people.get(workspace.id);
276 if (!found) {
277 found = identityClient(this.env.IDENTITY)
278 .listMembers(workspace.slug, this.actor(workspace))
279 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
280 .catch(() => new Map<string, Member>());
281 this.people.set(workspace.id, found);
282 }
283 return found;
284 }
285
286 /** Each member's teams (slugs, lowercased), by username. */
287 private teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
288 let found = this.teams.get(workspace.id);
289 if (!found) {
290 found = identityClient(this.env.IDENTITY)
291 .teamMemberships(this.actor(workspace), workspace.slug)
292 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
293 .catch(() => new Map<string, Set<string>>());
294 this.teams.set(workspace.id, found);
295 }
296 return found;
297 }
298
299 private async nameUsers(ids: string[]): Promise<void> {
300 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
301 if (!unnamed.length) return;
302 const named = await identityClient(this.env.IDENTITY)
303 .usernames(unnamed)
304 .catch(() => ({}) as Record<string, string>);
305 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
306 }
307
308 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
309 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
310 if (wanted.length) {
311 let found: WorkspaceAgent[] = [];
312 try {
313 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
314 } catch (error) {
315 console.error("docs could not resolve agents", error);
316 }
317 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
318 }
319 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
320 }
321
322 /** How member keys show. Teams show by slug. */
323 private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
324 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
325 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
326 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
327 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
328 const out = new Map<string, MemberProfile>();
329 for (const p of principals) {
330 if (p.kind === "user") {
331 const username = this.usernames.get(p.id) ?? null;
332 const person = username ? people.get(username.toLowerCase()) : undefined;
333 out.set(principalKey(p), {
334 ...p,
335 name: username ?? "ghost",
336 display_name: person?.name || username || "Former member",
337 avatar: person?.avatar ?? null,
338 role: null,
339 title: null,
340 avatar_seed: null,
341 });
342 } else {
343 const agent = agents.get(p.id) ?? null;
344 out.set(principalKey(p), {
345 ...p,
346 name: agent?.handle ?? p.id,
347 display_name: agent?.display_name ?? "Former agent",
348 avatar: agent?.avatar ?? null,
349 role: agent?.role ?? null,
350 title: agent?.title || null,
351 avatar_seed: agent?.avatar_seed ?? null,
352 });
353 }
354 }
355 // Anything else (system, ghost keys): a plain name.
356 for (const key of keys) {
357 if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null });
358 }
359 return out;
360 }
361
362 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
363 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Docs.");
364 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Docs.");
365 const workspace = await this.workspace(slug);
366 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
367 }
368
369 /** A person as access sees them. */
370 private async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean, needTeams: boolean): Promise<Person> {
371 const teams = needTeams ? ((await this.teamsOf(workspace)).get(user.username.toLowerCase()) ?? new Set<string>()) : new Set<string>();
372 return { user_id: user.id, owner, teams };
373 }
374
375 private viewerOwner(viewer: User, slug: string): boolean {
376 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
377 }
378
379 /** Every space in the workspace (archived ones too when asked), with members and projects. */
380 private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> {
381 const [spaces, members, projects] = await Promise.all([
382 this.db
383 .prepare(`SELECT * FROM spaces WHERE workspace_id = ? ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
384 .bind(workspace.id)
385 .all<SpaceRow>(),
386 this.db
387 .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?")
388 .bind(workspace.id)
389 .all<{ space_id: string; principal: string; role: DocRole }>(),
390 this.db
391 .prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?")
392 .bind(workspace.id)
393 .all<{ space_id: string; repo: string }>(),
394 ]);
395 return spaces.results.map((row) => ({
396 row,
397 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
398 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
399 }));
400 }
401
402 /** Whether any space's access depends on teams. */
403 private needsTeams(spaces: Omit<Space, "role">[]): boolean {
404 return spaces.some((s) => s.row.kind === "team" || s.members.some((m) => m.principal.startsWith("team:")));
405 }
406
407 /** The spaces, each with the viewer's role (null: they can't read it). */
408 private async spacesFor(workspace: Workspace, viewer: User, archived = false): Promise<Space[]> {
409 const spaces = await this.allSpaces(workspace, archived);
410 const person = await this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug), this.needsTeams(spaces));
411 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) }));
412 }
413
414 /** Makes the workspace's General space, once. */
415 private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> {
416 const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
417 if (found) return false;
418 const taken = new Set((await this.db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
419 const id = newId("spc");
420 await this.db
421 .prepare(
422 "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)",
423 )
424 .bind(id, workspace.id, freeSlug("general", taken), principalKey({ kind: "user", id: viewer.id }), now())
425 .run();
426 return true;
427 }
428
429 private toSpace(space: Space, pageCount = 0): DocSpace {
430 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
431 return {
432 id: space.row.id,
433 workspace_id: space.row.workspace_id,
434 slug: space.row.slug,
435 name: space.row.name,
436 description: space.row.description,
437 icon: space.row.icon,
438 kind: space.row.kind,
439 team: space.row.team,
440 default_role: space.row.kind === "private" ? null : space.row.default_role,
441 agent_mode: space.row.agent_mode,
442 is_default: !!space.row.is_default,
443 projects: space.projects,
444 created_by: created,
445 created_at: space.row.created_at,
446 archived_at: space.row.archived_at,
447 viewer_role: space.role ?? "view",
448 page_count: pageCount,
449 };
450 }
451
452 private ref(slug: string, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">): DocPageRef {
453 const s = pageSlug(row.title, row.id);
454 return { id: row.id, space_id: space.id, space_slug: space.slug, title: row.title, icon: row.icon, slug: s, path: `/${slug}/-/docs/${space.slug}/${s}` };
455 }
456
457 /** Pages as the site shows them, with owners, projects and people resolved. */
458 private async toPages(workspace: Workspace, spaces: Map<string, SpaceRow>, rows: PageRow[]): Promise<DocPage[]> {
459 if (!rows.length) return [];
460 const ids = rows.map((r) => r.id);
461 const marks = ids.map(() => "?").join(",");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store462 const [owners, projects, kids, stale] = await Promise.all([
Docs: a workspace knowledge base people and agents write together463 this.db.prepare(`SELECT page_id, principal FROM page_owners WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; principal: string }>(),
464 this.db.prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; repo: string }>(),
465 this.db
466 .prepare(`SELECT DISTINCT parent_id FROM pages WHERE parent_id IN (${marks}) AND archived_at IS NULL`)
467 .bind(...ids)
468 .all<{ parent_id: string }>(),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store469 this.staleIds(ids),
Docs: a workspace knowledge base people and agents write together470 ]);
471 const keys = [...rows.flatMap((r) => [r.created_by, r.updated_by ?? r.created_by]), ...owners.results.map((o) => o.principal)];
472 const people = await this.profiles(workspace, keys);
473 const parents = new Set(kids.results.map((k) => k.parent_id));
474 return rows.map((row) => {
475 const space = spaces.get(row.space_id)!;
476 return {
477 ...this.ref(workspace.slug, space, row),
478 parent_id: row.parent_id,
479 position: row.position,
480 cover: row.cover,
481 created_by: people.get(row.created_by)!,
482 created_at: row.created_at,
483 updated_by: row.updated_by ? (people.get(row.updated_by) ?? null) : null,
484 updated_at: row.updated_at,
485 archived_at: row.archived_at,
486 has_children: parents.has(row.id),
487 projects: projects.results.filter((p) => p.page_id === row.id).map((p) => p.repo),
488 owners: owners.results.filter((o) => o.page_id === row.id).map((o) => people.get(o.principal)!),
489 excerpt: excerpt(row.markdown ?? ""),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store490 stale: stale.has(row.id),
Docs: a workspace knowledge base people and agents write together491 };
492 });
493 }
494
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store495 /** Of these pages, those possibly out of date. */
496 private async staleIds(ids: string[]): Promise<Set<string>> {
497 if (!ids.length) return new Set();
498 const found = new Set<string>();
499 for (let i = 0; i < ids.length; i += 90) {
500 const part = ids.slice(i, i + 90);
501 const rows = await this.db
502 .prepare(`SELECT DISTINCT page_id FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")})`)
503 .bind(...part)
504 .all<{ page_id: string }>();
505 for (const r of rows.results) found.add(r.page_id);
506 }
507 return found;
508 }
509
Docs: a workspace knowledge base people and agents write together510 /** A page and its space, with the viewer's role; not found when they can't read it. */
511 private async pageFor(slug: string, pageId: string, viewer: Viewer, need: DocRole): Promise<Result<{ workspace: Workspace; page: PageRow; space: Space; spaces: Space[] }>> {
512 const found = await this.viewerWorkspace(slug, viewer);
513 if (!found.ok) return found;
514 const workspace = found.value;
515 const page = await this.db.prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ?").bind(String(pageId ?? ""), workspace.id).first<PageRow>();
516 if (!page) return fail("not_found", "No such page.");
517 const spaces = await this.spacesFor(workspace, viewer!, true);
518 const space = spaces.find((s) => s.row.id === page.space_id);
519 if (!space || !space.role) return fail("not_found", "No such page.");
520 if (!atLeast(space.role, need)) return fail("forbidden", need === "comment" ? "You can read this page but not comment on it." : "You can read this page but not change it.");
521 return ok({ workspace, page, space, spaces });
522 }
523
524 private room(pageId: string) {
525 return this.env.PAGES.get(this.env.PAGES.idFromName(pageId));
526 }
527
528 private tell(pageId: string, event: DocsLiveEvent): void {
529 this.defer(
530 this.room(pageId)
531 .notice(event)
532 .catch((error: unknown) => console.error("docs could not tell page", pageId, error)),
533 );
534 }
535
536 private userKey(viewer: User): string {
537 return principalKey({ kind: "user", id: viewer.id });
538 }
539
540 // ── Sidebar and home ────────────────────────────────────────────────────
541
542 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<DocsSidebar>> {
543 const found = await this.viewerWorkspace(a.workspace, a.viewer);
544 if (!found.ok) return found;
545 const workspace = found.value;
546 const viewer = a.viewer!;
547 await this.ensureDefault(workspace, viewer);
548 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
549 const ids = spaces.map((s) => s.row.id);
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store550 const repos = await this.repoSpacesFor(workspace, viewer).catch((error: unknown) => {
551 console.error("docs could not list projects' docs", String(error));
552 return [] as DocRepoSpace[];
553 });
554 if (!ids.length) return ok({ spaces: [], favorites: [], recent: [], can_create_space: true, trash_count: 0, stale_count: 0, repos });
Docs: a workspace knowledge base people and agents write together555 const marks = ids.map(() => "?").join(",");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store556 const [pages, favorites, recent, trash, stale] = await Promise.all([
Docs: a workspace knowledge base people and agents write together557 this.db
558 .prepare(`SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY position`)
559 .bind(...ids)
560 .all<Pick<PageRow, "id" | "space_id" | "parent_id" | "position" | "title" | "icon">>(),
561 this.db
562 .prepare(`SELECT p.id, p.space_id, p.title, p.icon FROM favorites f JOIN pages p ON p.id = f.page_id WHERE f.user_id = ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY f.created_at`)
563 .bind(viewer.id, ...ids)
564 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
565 this.db
566 .prepare(`SELECT p.id, p.space_id, p.title, p.icon FROM page_views v JOIN pages p ON p.id = v.page_id WHERE v.user_id = ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY v.viewed_at DESC LIMIT 8`)
567 .bind(viewer.id, ...ids)
568 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
569 this.db
570 .prepare(`SELECT COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL`)
571 .bind(...ids)
572 .first<{ n: number }>(),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store573 this.db
574 .prepare(`SELECT DISTINCT c.page_id FROM page_changes c JOIN pages p ON p.id = c.page_id WHERE c.cleared_at IS NULL AND p.space_id IN (${marks}) AND p.archived_at IS NULL`)
575 .bind(...ids)
576 .all<{ page_id: string }>(),
Docs: a workspace knowledge base people and agents write together577 ]);
578 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
579 const ref = (r: Pick<PageRow, "id" | "space_id" | "title" | "icon">) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r);
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store580 const staleSet = new Set(stale.results.map((r) => r.page_id));
Docs: a workspace knowledge base people and agents write together581 return ok({
582 spaces: spaces.map((s) => {
583 const mine = pages.results.filter((p) => p.space_id === s.row.id);
584 return {
585 ...this.toSpace(s, mine.length),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store586 pages: mine.map((p): DocTreeNode => ({ id: p.id, parent_id: p.parent_id, position: p.position, title: p.title, icon: p.icon, slug: pageSlug(p.title, p.id), stale: staleSet.has(p.id) })),
Docs: a workspace knowledge base people and agents write together587 };
588 }),
589 favorites: favorites.results.map(ref),
590 recent: recent.results.map(ref),
591 can_create_space: true,
592 trash_count: trash?.n ?? 0,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store593 stale_count: staleSet.size,
594 repos,
595 });
596 }
597
598 // ── A project's docs ────────────────────────────────────────────────────
599
600 /** The repository docs shown in the workspace that the viewer can read, with the repositories as they are now. */
601 private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
602 const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results;
603 if (!rows.length || !this.env.REPOS) return [];
604 const readable = await reposClient(this.env.REPOS).readable(
605 rows.map((r) => r.repo_id),
606 viewer,
607 );
608 const byId = new Map(readable.map((r) => [r.id, r]));
609 return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! }));
610 }
611
612 private async toRepoSpaces(workspace: Workspace, viewer: User, found: { row: RepoSpaceRow; repo: Repo }[]): Promise<DocRepoSpace[]> {
613 if (!found.length) return [];
614 const ids = found.map((f) => f.row.id);
615 const [files, people] = await Promise.all([
616 this.db
617 .prepare(`SELECT space_id, path, title FROM repo_files WHERE space_id IN (${ids.map(() => "?").join(",")})`)
618 .bind(...ids)
619 .all<{ space_id: string; path: string; title: string }>(),
620 this.profiles(
621 workspace,
622 found.map((f) => f.row.added_by),
623 ),
624 ]);
625 const me = this.userKey(viewer);
626 const owner = this.viewerOwner(viewer, workspace.slug);
627 const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1);
628 return found.map(({ row, repo }) => ({
629 id: row.id,
630 repo: `${repo.namespace}/${repo.name}`,
631 default_branch: repo.defaultBranch,
632 commit: row.commit_sha,
633 indexed_at: row.indexed_at,
634 added_by: people.get(row.added_by)!,
635 files: files.results
636 .filter((f) => f.space_id === row.id)
637 .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path))
638 .map((f) => ({ path: f.path, title: f.title })),
639 can_remove: row.added_by === me || owner,
640 }));
641 }
642
643 private async repoSpacesFor(workspace: Workspace, viewer: User): Promise<DocRepoSpace[]> {
644 return this.toRepoSpaces(workspace, viewer, await this.readableRepoSpaces(workspace, viewer));
645 }
646
647 async addRepoSpace(a: { workspace: string; viewer: Viewer; repo: string }): Promise<Result<DocRepoSpace>> {
648 const found = await this.viewerWorkspace(a.workspace, a.viewer);
649 if (!found.ok) return found;
650 const workspace = found.value;
651 const viewer = a.viewer!;
652 if (!this.env.REPOS) return fail("conflict", "Projects' docs aren't available here.");
653 const ref = projectRef(String(a.repo ?? ""));
654 if (!ref) return fail("invalid", "Choose a repository: owner/name.");
655 const [namespace, name] = ref.split("/") as [string, string];
656 const repo = await reposClient(this.env.REPOS).get({ namespace, name }, viewer);
657 if (!repo.ok) return fail("not_found", "No such repository, or you can't read it.");
658 const id = newId("rds");
659 const row: RepoSpaceRow = {
660 id,
661 workspace_id: workspace.id,
662 repo_id: repo.value.id,
663 repo: `${repo.value.namespace}/${repo.value.name}`.toLowerCase(),
664 default_branch: repo.value.defaultBranch,
665 commit_sha: null,
666 indexed_at: null,
667 added_by: this.userKey(viewer),
668 added_at: now(),
669 };
670 const inserted = await this.db
671 .prepare("INSERT INTO repo_spaces (id, workspace_id, repo_id, repo, default_branch, added_by, added_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workspace_id, repo_id) DO NOTHING RETURNING id")
672 .bind(row.id, row.workspace_id, row.repo_id, row.repo, row.default_branch, row.added_by, row.added_at)
673 .first<{ id: string }>();
674 if (!inserted) return fail("conflict", `${ref}'s docs are already in Docs.`);
675 try {
676 await indexRepoSpace({ DB: this.db, REPOS: this.env.REPOS }, row);
677 } catch (error) {
678 console.error("docs could not read a project's docs", row.repo, String(error));
679 }
680 const fresh = (await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ?").bind(id).first<RepoSpaceRow>()) ?? row;
681 const [space] = await this.toRepoSpaces(workspace, viewer, [{ row: fresh, repo: repo.value }]);
682 return ok(space!);
683 }
684
685 async removeRepoSpace(a: { workspace: string; viewer: Viewer; id: string }): Promise<Result<boolean>> {
686 const found = await this.viewerWorkspace(a.workspace, a.viewer);
687 if (!found.ok) return found;
688 const row = await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ? AND workspace_id = ?").bind(String(a.id ?? ""), found.value.id).first<RepoSpaceRow>();
689 if (!row) return fail("not_found", "No such project's docs.");
690 if (row.added_by !== this.userKey(a.viewer!) && !this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only whoever added a project's docs, or an owner, can remove them.");
691 await this.db.batch([this.db.prepare("DELETE FROM repo_files_fts WHERE space_id = ?").bind(row.id), this.db.prepare("DELETE FROM repo_spaces WHERE id = ?").bind(row.id)]);
692 return ok(true);
693 }
694
695 async repoPage(a: { workspace: string; viewer: Viewer; repo: string; path: string }): Promise<Result<DocRepoPage>> {
696 const found = await this.viewerWorkspace(a.workspace, a.viewer);
697 if (!found.ok) return found;
698 const workspace = found.value;
699 const ref = projectRef(String(a.repo ?? ""));
700 if (!ref) return fail("not_found", "No such file.");
701 const spaces = await this.readableRepoSpaces(workspace, a.viewer!);
702 const match = spaces.find((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === ref || s.row.repo === ref);
703 if (!match) return fail("not_found", "No such file.");
704 const path = String(a.path ?? "").replace(/^\/+/, "");
705 const file = await this.db.prepare("SELECT path, title, markdown FROM repo_files WHERE space_id = ? AND path = ?").bind(match.row.id, path).first<{ path: string; title: string; markdown: string }>();
706 if (!file) return fail("not_found", "No such file.");
707 const [space] = await this.toRepoSpaces(workspace, a.viewer!, [match]);
708 const repoPath = `${match.repo.namespace}/${match.repo.name}`;
709 const encoded = file.path.split("/").map(encodeURIComponent).join("/");
710 return ok({
711 space: space!,
712 file: {
713 path: file.path,
714 title: file.title,
715 markdown: file.markdown,
716 href: `/${workspace.slug}/-/docs/repo/${repoPath}/${encoded}`,
717 code_href: `/${repoPath}/blob/${encodeURIComponent(match.repo.defaultBranch)}/${encoded}`,
718 },
Docs: a workspace knowledge base people and agents write together719 });
720 }
721
722 async home(a: { workspace: string; viewer: Viewer; project: string | null }): Promise<Result<DocsHome>> {
723 const found = await this.viewerWorkspace(a.workspace, a.viewer);
724 if (!found.ok) return found;
725 const workspace = found.value;
726 const viewer = a.viewer!;
727 await this.ensureDefault(workspace, viewer);
728 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
729 const project = a.project ? projectRef(a.project) : null;
730 const ids = spaces.map((s) => s.row.id);
731 const allProjects = new Set(spaces.flatMap((s) => s.projects));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store732 if (!ids.length) return ok({ recent: [], mine: [], stale: [], spaces: [], projects: [...allProjects].sort(), project });
Docs: a workspace knowledge base people and agents write together733 const marks = ids.map(() => "?").join(",");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store734 const [recentRows, mineRows, pageProjects, counts, staleRows] = await Promise.all([
Docs: a workspace knowledge base people and agents write together735 this.db
736 .prepare(`SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY updated_at DESC LIMIT 60`)
737 .bind(...ids)
738 .all<PageRow>(),
739 this.db
740 .prepare(
741 `SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL AND (created_by = ? OR id IN (SELECT page_id FROM page_owners WHERE principal = ?)) ORDER BY updated_at DESC LIMIT 12`,
742 )
743 .bind(...ids, this.userKey(viewer), this.userKey(viewer))
744 .all<PageRow>(),
745 this.db
746 .prepare(`SELECT pp.page_id, pp.repo FROM page_projects pp JOIN pages p ON p.id = pp.page_id WHERE p.space_id IN (${marks})`)
747 .bind(...ids)
748 .all<{ page_id: string; repo: string }>(),
749 this.db
750 .prepare(`SELECT space_id, COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL GROUP BY space_id`)
751 .bind(...ids)
752 .all<{ space_id: string; n: number }>(),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store753 this.staleRows(ids, null, 24),
Docs: a workspace knowledge base people and agents write together754 ]);
755 for (const p of pageProjects.results) allProjects.add(p.repo);
756 const projectsOf = (pageId: string) => pageProjects.results.filter((p) => p.page_id === pageId).map((p) => p.repo);
757 const spaceProjects = new Map(spaces.map((s) => [s.row.id, s.projects]));
758 const keep = (r: PageRow) => inProject(project, projectsOf(r.id), spaceProjects.get(r.space_id) ?? []);
759 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store760 const [recent, mine, stale] = await Promise.all([
761 this.toPages(workspace, bySpace, recentRows.results.filter(keep).slice(0, 12)),
762 this.toPages(workspace, bySpace, mineRows.results.filter(keep).slice(0, 8)),
763 this.toPages(workspace, bySpace, staleRows.filter(keep).slice(0, 8)),
764 ]);
Docs: a workspace knowledge base people and agents write together765 const count = new Map(counts.results.map((c) => [c.space_id, c.n]));
766 return ok({
767 recent,
768 mine,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store769 stale,
Docs: a workspace knowledge base people and agents write together770 spaces: spaces.filter((s) => !project || s.projects.includes(project) || recentRows.results.some((r) => r.space_id === s.row.id && keep(r))).map((s) => this.toSpace(s, count.get(s.row.id) ?? 0)),
771 projects: [...allProjects].sort(),
772 project,
773 });
774 }
775
776 // ── Spaces ──────────────────────────────────────────────────────────────
777
778 private async spaceMembers(workspace: Workspace, space: Space): Promise<DocSpaceMember[]> {
779 const people = await this.profiles(
780 workspace,
781 space.members.filter((m) => !m.principal.startsWith("team:")).map((m) => m.principal),
782 );
783 return space.members
784 .map((m): DocSpaceMember => {
785 const key = memberKey(m.principal);
786 if (key?.kind === "team") return { key: m.principal, kind: "team", name: key.id, display_name: `@${workspace.slug}/${key.id}`, avatar: null, role: m.role };
787 const p = people.get(m.principal)!;
788 return { key: m.principal, kind: p.kind, name: p.name, display_name: p.display_name, avatar: p.avatar, avatar_seed: p.avatar_seed ?? null, role: m.role };
789 })
790 .sort((x, y) => RANK[y.role] - RANK[x.role] || x.display_name.localeCompare(y.display_name));
791 }
792
793 async space(a: { workspace: string; space: string; viewer: Viewer }): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>> {
794 const found = await this.viewerWorkspace(a.workspace, a.viewer);
795 if (!found.ok) return found;
796 const workspace = found.value;
797 const spaces = await this.spacesFor(workspace, a.viewer!, true);
798 const space = spaces.find((s) => s.row.slug === String(a.space ?? "").toLowerCase() || s.row.id === a.space);
799 if (!space?.role) return fail("not_found", "No such space.");
800 const rows = (
801 await this.db
802 .prepare("SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id = ? AND archived_at IS NULL ORDER BY position")
803 .bind(space.row.id)
804 .all<PageRow>()
805 ).results;
806 const [members, pages] = await Promise.all([this.spaceMembers(workspace, space), this.toPages(workspace, new Map([[space.row.id, space.row]]), rows)]);
807 return ok({ space: this.toSpace(space, rows.length), members, pages });
808 }
809
810 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
811 const found = await this.viewerWorkspace(a.workspace, a.viewer);
812 if (!found.ok) return found;
813 const workspace = found.value;
814 const viewer = a.viewer!;
815 const input = a.input ?? ({} as NewDocSpace);
816 const name = cleanTitle(input.name).slice(0, 80);
817 if (!name) return fail("invalid", "Name the space.");
818 const kind: DocSpaceKind = input.kind === "team" || input.kind === "private" ? input.kind : "workspace";
819 const team = kind === "team" ? String(input.team ?? "").trim().toLowerCase() : null;
820 if (kind === "team") {
821 if (!team) return fail("invalid", "Choose the team the space is for.");
822 const teams = await this.teamsOf(workspace);
823 const known = [...teams.values()].some((set) => set.has(team));
824 if (!known) return fail("invalid", "No such team in this workspace.");
825 }
826 const taken = new Set((await this.db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
827 let slug: string;
828 if (input.slug) {
829 const wanted = validSpaceSlug(input.slug);
830 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
831 if (taken.has(wanted)) return fail("conflict", "Another space has that address.");
832 slug = wanted;
833 } else slug = freeSlug(name, taken);
834 const defaultRole: DocRole | null = kind === "private" ? null : isRole(input.default_role) ? input.default_role : "edit";
835 const id = newId("spc");
836 const at = now();
837 const me = this.userKey(viewer);
838 const projects = cleanProjects(input.projects);
839 await this.db.batch([
840 this.db
841 .prepare(
842 "INSERT INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
843 )
844 .bind(id, workspace.id, slug, name, String(input.description ?? "").trim().slice(0, 300) || null, cleanIcon(input.icon), kind, team, defaultRole, input.agent_mode === "edit" ? "edit" : "suggest", me, at),
845 // Whoever makes a space manages it.
846 this.db.prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?)").bind(id, me, me, at),
847 ...projects.map((repo) => this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(id, repo)),
848 ]);
849 const spaces = await this.spacesFor(workspace, viewer);
850 const space = spaces.find((s) => s.row.id === id)!;
851 return ok(this.toSpace(space));
852 }
853
854 async updateSpace(a: { workspace: string; space_id: string; viewer: Viewer; change: DocSpaceChange }): Promise<Result<DocSpace>> {
855 const found = await this.viewerWorkspace(a.workspace, a.viewer);
856 if (!found.ok) return found;
857 const workspace = found.value;
858 const spaces = await this.spacesFor(workspace, a.viewer!, true);
859 const space = spaces.find((s) => s.row.id === a.space_id);
860 if (!space?.role) return fail("not_found", "No such space.");
861 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change a space.");
862 const c = a.change ?? {};
863 const sets: string[] = [];
864 const values: unknown[] = [];
865 const set = (column: string, value: unknown) => {
866 sets.push(`${column} = ?`);
867 values.push(value);
868 };
869 if (c.name !== undefined) {
870 const name = cleanTitle(c.name).slice(0, 80);
871 if (!name) return fail("invalid", "Name the space.");
872 set("name", name);
873 }
874 if (c.description !== undefined) set("description", String(c.description ?? "").trim().slice(0, 300) || null);
875 if (c.icon !== undefined) set("icon", cleanIcon(c.icon));
876 if (c.slug !== undefined && c.slug !== space.row.slug) {
877 const wanted = validSpaceSlug(String(c.slug ?? ""));
878 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
879 const clash = await this.db.prepare("SELECT 1 FROM spaces WHERE workspace_id = ? AND slug = ? AND id <> ?").bind(workspace.id, wanted, space.row.id).first();
880 if (clash) return fail("conflict", "Another space has that address.");
881 set("slug", wanted);
882 }
883 if (c.kind !== undefined && c.kind !== space.row.kind) {
884 if (space.row.is_default) return fail("invalid", "The General space is always the whole workspace's.");
885 if (c.kind !== "workspace" && c.kind !== "team" && c.kind !== "private") return fail("invalid", "Choose who the space is for.");
886 set("kind", c.kind);
887 if (c.kind === "private") set("default_role", null);
888 else if (!space.row.default_role) set("default_role", "edit");
889 if (c.kind === "private" && !space.members.some((m) => m.role === "manage")) {
890 // Someone must still manage it: whoever made it private.
891 await this.db
892 .prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?) ON CONFLICT (space_id, principal) DO UPDATE SET role = 'manage'")
893 .bind(space.row.id, this.userKey(a.viewer!), this.userKey(a.viewer!), now())
894 .run();
895 }
896 }
897 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
898 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
899 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
900 if (c.archived !== undefined) {
901 if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived.");
902 set("archived_at", c.archived ? now() : null);
903 }
904 const statements: D1PreparedStatement[] = [];
905 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
906 if (c.projects !== undefined) {
907 statements.push(this.db.prepare("DELETE FROM space_projects WHERE space_id = ?").bind(space.row.id));
908 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(space.row.id, repo));
909 }
910 if (statements.length) await this.db.batch(statements);
911 this.workspaces.clear();
912 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
913 return ok(this.toSpace(after));
914 }
915
916 async setSpaceMember(a: { workspace: string; space_id: string; viewer: Viewer; member: string; role: DocRole | null }): Promise<Result<DocSpaceMember[]>> {
917 const found = await this.viewerWorkspace(a.workspace, a.viewer);
918 if (!found.ok) return found;
919 const workspace = found.value;
920 const spaces = await this.spacesFor(workspace, a.viewer!, true);
921 const space = spaces.find((s) => s.row.id === a.space_id);
922 if (!space?.role) return fail("not_found", "No such space.");
923 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
924 const key = memberKey(String(a.member ?? ""));
925 if (!key) return fail("invalid", "Choose a person, agent or team.");
926 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
927 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
928 // They must belong to the workspace.
929 if (key.kind === "user") {
930 await this.nameUsers([key.id]);
931 const username = this.usernames.get(key.id);
932 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
933 } else if (key.kind === "agent") {
934 const agent = (await this.agentsById([key.id])).get(key.id);
935 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("invalid", "No such agent in this workspace.");
936 } else {
937 const teams = await this.teamsOf(workspace);
938 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
939 }
940 const principal = key.kind === "team" ? `team:${key.id.toLowerCase()}` : `${key.kind}:${key.id}`;
941 if (leavesNoManager(space.row.kind, space.members, principal, role)) return fail("invalid", "Someone must keep full access to a private space.");
942 if (role) {
943 await this.db
944 .prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (space_id, principal) DO UPDATE SET role = excluded.role")
945 .bind(space.row.id, principal, role, this.userKey(a.viewer!), now())
946 .run();
947 } else {
948 await this.db.prepare("DELETE FROM space_members WHERE space_id = ? AND principal = ?").bind(space.row.id, principal).run();
949 }
950 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
951 return ok(await this.spaceMembers(workspace, after));
952 }
953
954 // ── Pages ───────────────────────────────────────────────────────────────
955
956 async page(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPageDetail>> {
957 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
958 if (!found.ok) return found;
959 const { workspace, page, space, spaces } = found.value;
960 const viewer = a.viewer!;
961 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
962 const readable = new Set(spaces.filter((s) => s.role).map((s) => s.row.id));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store963 const [tree, backlinks, children, favorite, viewed, suggestions, cited, staleness] = await Promise.all([
Docs: a workspace knowledge base people and agents write together964 this.db.prepare("SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>(),
965 this.db
966 .prepare("SELECT p.id, p.space_id, p.title, p.icon FROM page_links l JOIN pages p ON p.id = l.from_page WHERE l.to_page = ? AND p.archived_at IS NULL LIMIT 50")
967 .bind(page.id)
968 .all<PageRow>(),
969 this.db.prepare("SELECT id, space_id, title, icon FROM pages WHERE parent_id = ? AND archived_at IS NULL ORDER BY position").bind(page.id).all<PageRow>(),
970 this.db.prepare("SELECT 1 AS yes FROM favorites WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ yes: number }>(),
971 this.db.prepare("SELECT viewed_at FROM page_views WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ viewed_at: string }>(),
972 this.openSuggestions(workspace, page.id),
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store973 this.citationsOf([page.id]),
974 this.stalenessFor(page.id, viewer),
Docs: a workspace knowledge base people and agents write together975 ]);
976 this.defer(
977 this.db
978 .prepare("INSERT INTO page_views (page_id, user_id, viewed_at) VALUES (?, ?, ?) ON CONFLICT (page_id, user_id) DO UPDATE SET viewed_at = excluded.viewed_at")
979 .bind(page.id, viewer.id, now())
980 .run(),
981 );
982 const [detail] = await this.toPages(workspace, bySpace, [page]);
983 return ok({
984 page: detail!,
985 space: this.toSpace(space),
986 breadcrumbs: ancestors(tree.results, page.id).map((r) => this.ref(workspace.slug, space.row, r)),
987 markdown: page.markdown,
988 role: space.role!,
989 backlinks: backlinks.results.filter((r) => readable.has(r.space_id)).map((r) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r)),
990 children: children.results.map((r) => this.ref(workspace.slug, space.row, r)),
991 favorite: !!favorite,
992 last_viewed_at: viewed?.viewed_at ?? null,
993 suggestions,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store994 citations: cited.get(page.id) ?? [],
995 describes: (cited.get(page.id) ?? []).filter((c) => c.source === "header").map((c) => ({ repo: c.repo, path: c.path })),
996 staleness,
Docs: a workspace knowledge base people and agents write together997 });
998 }
999
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1000 // ── Citations and staleness ─────────────────────────────────────────────
1001
1002 /** Each page's citations, by page. */
1003 private async citationsOf(pageIds: string[]): Promise<Map<string, DocCitation[]>> {
1004 const out = new Map<string, DocCitation[]>();
1005 if (!pageIds.length) return out;
1006 const rows = await this.db
1007 .prepare(`SELECT page_id, repo, path, kind, label, ref, source FROM citations WHERE page_id IN (${pageIds.map(() => "?").join(",")}) ORDER BY source DESC, repo, path`)
1008 .bind(...pageIds)
1009 .all<Omit<DocCitation, "label"> & { page_id: string; label: string }>();
1010 for (const r of rows.results) {
1011 const list = out.get(r.page_id) ?? [];
1012 list.push({ repo: r.repo, path: r.path, kind: r.kind, label: r.label || null, ref: r.ref, source: r.source });
1013 out.set(r.page_id, list);
1014 }
1015 return out;
1016 }
1017
1018 /** Of these repositories (`owner/name`), those the viewer can read. */
1019 private async readableRepos(viewer: User, repos: string[]): Promise<Set<string>> {
1020 const out = new Set<string>();
1021 if (!this.env.REPOS) return out;
1022 const client = reposClient(this.env.REPOS);
1023 await Promise.all(
1024 [...new Set(repos)].slice(0, 25).map(async (repo) => {
1025 const [namespace, name] = repo.split("/") as [string, string];
1026 const found = await client.get({ namespace, name }, viewer).catch(() => null);
1027 if (found?.ok) out.add(repo);
1028 }),
1029 );
1030 return out;
1031 }
1032
1033 /** Open changes on these pages, newest first. */
1034 private async openChanges(pageIds: string[]): Promise<ChangeRow[]> {
1035 if (!pageIds.length) return [];
1036 const out: ChangeRow[] = [];
1037 for (let i = 0; i < pageIds.length; i += 90) {
1038 const part = pageIds.slice(i, i + 90);
1039 const rows = await this.db
1040 .prepare(`SELECT * FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")}) ORDER BY detected_at DESC`)
1041 .bind(...part)
1042 .all<ChangeRow>();
1043 out.push(...rows.results);
1044 }
1045 return out.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1046 }
1047
1048 /** A change as a reader sees it: named only when they can read its repository. */
1049 private toChange(row: ChangeRow, readable: Set<string>): DocStaleChange {
1050 if (!readable.has(row.repo)) return { visible: false, repo: null, commit: null, pull: null, paths: [], at: row.detected_at };
1051 let paths: string[] = [];
1052 try {
1053 paths = JSON.parse(row.paths) as string[];
1054 } catch {
1055 paths = [];
1056 }
1057 return {
1058 visible: true,
1059 repo: row.repo,
1060 commit: row.commit_sha,
1061 pull: row.pull_number ? { number: row.pull_number, title: row.pull_title } : null,
1062 paths,
1063 at: row.detected_at,
1064 };
1065 }
1066
1067 /** Why a page is possibly out of date, as this viewer may see it; null when it isn't. */
1068 private async stalenessFor(pageId: string, viewer: User): Promise<DocStaleness | null> {
1069 const rows = await this.openChanges([pageId]);
1070 if (!rows.length) return null;
1071 const readable = await this.readableRepos(
1072 viewer,
1073 rows.map((r) => r.repo),
1074 );
1075 const changes = rows.slice(0, 20).map((r) => this.toChange(r, readable));
1076 return { since: rows[rows.length - 1]!.detected_at, changes };
1077 }
1078
1079 /** Live pages in these spaces that are possibly out of date, most recently flagged first; `repo` narrows to changes there. */
1080 private async staleRows(spaceIds: string[], repo: string | null, limit: number): Promise<PageRow[]> {
1081 if (!spaceIds.length) return [];
1082 const marks = spaceIds.map(() => "?").join(",");
1083 return (
1084 await this.db
1085 .prepare(
1086 `SELECT p.id, p.workspace_id, p.space_id, p.parent_id, p.position, p.title, p.icon, p.cover, substr(p.markdown, 1, 600) AS markdown, p.created_by, p.created_at, p.updated_by, p.updated_at, p.archived_at, p.archived_by
1087 FROM pages p JOIN (SELECT page_id, MAX(detected_at) AS flagged FROM page_changes WHERE cleared_at IS NULL ${repo ? "AND repo = ?" : ""} GROUP BY page_id) c ON c.page_id = p.id
1088 WHERE p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY c.flagged DESC LIMIT ?`,
1089 )
1090 .bind(...(repo ? [repo] : []), ...spaceIds, limit)
1091 .all<PageRow>()
1092 ).results;
1093 }
1094
1095 async stalePages(a: { workspace: string; viewer: Viewer; repo?: string | null }): Promise<Result<DocPage[]>> {
1096 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1097 if (!found.ok) return found;
1098 const workspace = found.value;
1099 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1100 const rows = await this.staleRows(
1101 spaces.map((s) => s.row.id),
1102 a.repo ? projectRef(a.repo) : null,
1103 200,
1104 );
1105 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1106 }
1107
1108 /** Clears every open change on a page. */
1109 private async clearStale(pageId: string, by: string): Promise<boolean> {
1110 const done = await this.db.prepare("UPDATE page_changes SET cleared_at = ?, cleared_by = ? WHERE page_id = ? AND cleared_at IS NULL").bind(now(), by, pageId).run();
1111 const cleared = (done.meta?.changes ?? 0) > 0;
1112 if (cleared) this.tell(pageId, { type: "page.staleness" });
1113 return cleared;
1114 }
1115
1116 async markCurrent(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1117 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1118 if (!found.ok) return found;
1119 await this.clearStale(found.value.page.id, this.userKey(a.viewer!));
1120 return ok(true);
1121 }
1122
1123 async stalePagesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; repo?: string | null; since?: string | null; audience: DocAudience | null }): Promise<Result<DocStalePage[]>> {
1124 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1125 if (!found.ok) return found;
1126 const { workspace, spaces } = found.value;
1127 const repo = a.repo ? projectRef(a.repo) : null;
1128 if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
1129 const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
1130 const rows = await this.staleRows(
1131 spaces.map((s) => s.row.id),
1132 repo,
1133 200,
1134 );
1135 const [changes, cited, pages] = await Promise.all([
1136 this.openChanges(rows.map((r) => r.id)),
1137 this.citationsOf(rows.map((r) => r.id)),
1138 this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows),
1139 ]);
1140 // The agent learns only of code its person can read.
1141 const readable = await this.readableRepos(a.viewer!, [...changes.map((c) => c.repo), ...[...cited.values()].flat().map((c) => c.repo)]);
1142 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1143 const out: DocStalePage[] = [];
1144 for (const row of rows) {
1145 const mine = changes.filter((c) => c.page_id === row.id && readable.has(c.repo) && (!repo || c.repo === repo));
1146 if (!mine.length) continue;
1147 const newest = mine[0]!.detected_at;
1148 if (since && newest < since) continue;
1149 const space = bySpace.get(row.space_id)!;
1150 const page = pages.find((p) => p.id === row.id)!;
1151 out.push({
1152 page: { ...this.ref(workspace.slug, space.row, row), updated_at: row.updated_at },
1153 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1154 can: space.can,
1155 owners: page.owners,
1156 citations: (cited.get(row.id) ?? []).filter((c) => readable.has(c.repo)),
1157 changes: mine.slice(0, 20).map((c) => this.toChange(c, readable)),
1158 since: mine[mine.length - 1]!.detected_at,
1159 });
1160 if (out.length >= 50) break;
1161 }
1162 return ok(out);
1163 }
1164
Docs: a workspace knowledge base people and agents write together1165 /** Where a new page in `space` from `input` starts: its Markdown and title. */
1166 private async startingPoint(workspace: Workspace, input: NewDocPage): Promise<{ markdown: string; title: string; icon: string | null }> {
1167 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
1168 let title = cleanTitle(input.title);
1169 let icon = cleanIcon(input.icon);
1170 if (input.template_id) {
1171 const template = builtinTemplate(input.template_id) ?? (await this.savedTemplate(workspace, input.template_id));
1172 if (template) {
1173 markdown = template.markdown;
1174 if (!title) title = template.name;
1175 if (!icon) icon = template.icon;
1176 }
1177 }
1178 return { markdown, title, icon };
1179 }
1180
1181 private async savedTemplate(workspace: Workspace, id: string): Promise<DocTemplate | null> {
1182 const row = await this.db
1183 .prepare("SELECT * FROM templates WHERE id = ? AND workspace_id = ?")
1184 .bind(id, workspace.id)
1185 .first<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>();
1186 if (!row) return null;
1187 return { id: row.id, name: row.name, description: row.description, icon: row.icon, markdown: row.markdown, builtin: false, created_by: parsePrincipalKey(row.created_by) };
1188 }
1189
1190 /** Inserts a page row and fills its room. */
1191 private async insertPage(
1192 workspace: Workspace,
1193 space: SpaceRow,
1194 author: string,
1195 input: { parent_id: string | null; title: string; icon: string | null; markdown: string; state?: Uint8Array | null; projects?: string[]; owners?: string[]; position?: number },
1196 ): Promise<PageRow> {
1197 const rows = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(space.id).all<PageRow>()).results;
1198 const id = newId("pag");
1199 const at = now();
1200 const position = input.position ?? lastPosition(rows, input.parent_id);
1201 const row: PageRow = {
1202 id,
1203 workspace_id: workspace.id,
1204 space_id: space.id,
1205 parent_id: input.parent_id,
1206 position,
1207 title: input.title,
1208 icon: input.icon,
1209 cover: null,
1210 markdown: input.markdown,
1211 created_by: author,
1212 created_at: at,
1213 updated_by: author,
1214 updated_at: at,
1215 archived_at: null,
1216 archived_by: null,
1217 };
1218 await this.db.batch([
1219 this.db
1220 .prepare(
1221 "INSERT INTO pages (id, workspace_id, space_id, parent_id, position, title, icon, cover, markdown, created_by, created_at, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)",
1222 )
1223 .bind(id, workspace.id, space.id, input.parent_id, position, input.title, input.icon, input.markdown, author, at, author, at),
1224 this.db.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(id, input.title, searchText(input.markdown)),
1225 this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, author),
1226 ...(input.owners ?? []).filter((o) => o !== author).map((o) => this.db.prepare("INSERT OR IGNORE INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, o)),
1227 ...(input.projects ?? []).map((repo) => this.db.prepare("INSERT OR IGNORE INTO page_projects (page_id, repo) VALUES (?, ?)").bind(id, repo)),
1228 this.db
1229 .prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)")
1230 .bind(newId("ver"), id, at, JSON.stringify([author]), input.markdown),
1231 ]);
1232 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1233 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.created", this.eventData(workspace, space, row), author));
Docs: a workspace knowledge base people and agents write together1234 return row;
1235 }
1236
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1237 /** What every `doc.page.*` event says of a page. */
1238 private eventData(workspace: Workspace, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">) {
1239 return { workspace: workspace.slug, workspaceId: workspace.id, pageId: row.id, spaceId: space.id, title: row.title, path: this.ref(workspace.slug, space, row).path };
1240 }
1241
Docs: a workspace knowledge base people and agents write together1242 async createPage(a: { workspace: string; viewer: Viewer; input: NewDocPage }): Promise<Result<DocPage>> {
1243 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1244 if (!found.ok) return found;
1245 const workspace = found.value;
1246 const input = a.input ?? ({} as NewDocPage);
1247 const spaces = await this.spacesFor(workspace, a.viewer!);
1248 const space = spaces.find((s) => s.row.id === input.space_id) ?? (input.space_id ? null : spaces.find((s) => s.row.is_default));
1249 if (!space?.role) return fail("not_found", "No such space.");
1250 if (!atLeast(space.role, "edit")) return fail("forbidden", "You can read this space but not add pages to it.");
1251 const parent = input.parent_id
1252 ? await this.db.prepare("SELECT id FROM pages WHERE id = ? AND space_id = ? AND archived_at IS NULL").bind(input.parent_id, space.row.id).first<{ id: string }>()
1253 : null;
1254 if (input.parent_id && !parent) return fail("not_found", "No such parent page.");
1255 const start = await this.startingPoint(workspace, input);
1256 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1257 parent_id: parent?.id ?? null,
1258 title: start.title,
1259 icon: start.icon,
1260 markdown: start.markdown,
1261 projects: cleanProjects(input.projects),
1262 });
1263 const [page] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1264 return ok(page!);
1265 }
1266
1267 async updatePage(a: { workspace: string; page_id: string; viewer: Viewer; change: DocPageChange }): Promise<Result<DocPage>> {
1268 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1269 if (!found.ok) return found;
1270 const { workspace, page, space } = found.value;
1271 const c = a.change ?? {};
1272 const statements: D1PreparedStatement[] = [];
1273 const sets: string[] = [];
1274 const values: unknown[] = [];
1275 if (c.title !== undefined) {
1276 sets.push("title = ?");
1277 values.push(cleanTitle(c.title));
1278 statements.push(this.db.prepare("UPDATE pages_fts SET title = ? WHERE page_id = ?").bind(cleanTitle(c.title), page.id));
1279 }
1280 if (c.icon !== undefined) {
1281 sets.push("icon = ?");
1282 values.push(cleanIcon(c.icon));
1283 }
1284 if (c.cover !== undefined) {
1285 sets.push("cover = ?");
1286 values.push(cleanCover(c.cover));
1287 }
1288 if (sets.length) {
1289 sets.push("updated_at = ?", "updated_by = ?");
1290 values.push(now(), this.userKey(a.viewer!));
1291 statements.unshift(this.db.prepare(`UPDATE pages SET ${sets.join(", ")} WHERE id = ?`).bind(...values, page.id));
1292 }
1293 if (c.projects !== undefined) {
1294 statements.push(this.db.prepare("DELETE FROM page_projects WHERE page_id = ?").bind(page.id));
1295 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO page_projects (page_id, repo) VALUES (?, ?)").bind(page.id, repo));
1296 }
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1297 if (c.describes !== undefined) {
1298 statements.push(this.db.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'header'").bind(page.id));
1299 for (const d of cleanDescribes(c.describes)) {
1300 statements.push(this.db.prepare("INSERT OR IGNORE INTO citations (page_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, 'path', '', NULL, 'header')").bind(page.id, d.repo, d.path));
1301 }
1302 }
Docs: a workspace knowledge base people and agents write together1303 if (c.owners !== undefined) {
1304 const owners = [...new Set((Array.isArray(c.owners) ? c.owners : []).map(String).filter((k) => memberKey(k)?.kind === "user" || memberKey(k)?.kind === "agent"))].slice(0, 20);
1305 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
1306 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
1307 }
1308 if (statements.length) await this.db.batch(statements);
1309 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1310 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1311 this.tell(page.id, { type: "page.updated", page: detail! });
1312 return ok(detail!);
1313 }
1314
1315 async movePage(a: { workspace: string; page_id: string; viewer: Viewer; move: DocMove }): Promise<Result<DocPage>> {
1316 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1317 if (!found.ok) return found;
1318 const { workspace, page, spaces } = found.value;
1319 const move = a.move ?? ({ parent_id: null } as DocMove);
1320 const target = move.space_id ? spaces.find((s) => s.row.id === move.space_id) : spaces.find((s) => s.row.id === page.space_id);
1321 if (!target?.role || target.row.archived_at) return fail("not_found", "No such space.");
1322 if (!atLeast(target.role, "edit")) return fail("forbidden", "You can't add pages to that space.");
1323 const [sourceRows, targetRows] = await Promise.all([
1324 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>(),
1325 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(target.row.id).all<PageRow>(),
1326 ]);
1327 const parent = move.parent_id ?? null;
1328 if (parent && !targetRows.results.some((r) => r.id === parent)) return fail("not_found", "No such parent page in that space.");
1329 if (wouldCycle(sourceRows.results, page.id, parent)) return fail("invalid", "A page can't go inside itself.");
1330 const placed = placeBefore(targetRows.results, page.id, parent, move.before_id ?? null);
1331 const statements: D1PreparedStatement[] = [this.db.prepare("UPDATE pages SET parent_id = ?, position = ? WHERE id = ?").bind(parent, placed.position, page.id)];
1332 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE pages SET position = ? WHERE id = ?").bind(position, id));
1333 if (target.row.id !== page.space_id) {
1334 // The page and everything under it move to the other space.
1335 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1336 for (const id of descendants(all, page.id)) statements.push(this.db.prepare("UPDATE pages SET space_id = ? WHERE id = ?").bind(target.row.id, id));
1337 }
1338 await this.db.batch(statements);
1339 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1340 const [detail] = await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), [after!]);
1341 this.tell(page.id, { type: "page.updated", page: detail! });
1342 return ok(detail!);
1343 }
1344
1345 async duplicatePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1346 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1347 if (!found.ok) return found;
1348 const { workspace, page, space } = found.value;
1349 const room = this.room(page.id);
1350 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1351 const [state, read] = await Promise.all([room.state(), room.read()]);
1352 const rows = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>()).results;
1353 const next = rows.filter((r) => r.parent_id === page.parent_id).sort((x, y) => x.position - y.position).find((r) => r.position > page.position);
1354 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1355 parent_id: page.parent_id,
1356 title: `${page.title || "Untitled"} (copy)`.slice(0, MAX_TITLE),
1357 icon: page.icon,
1358 markdown: read.markdown,
1359 state,
1360 position: next ? (page.position + next.position) / 2 : page.position + 1024,
1361 });
1362 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1363 return ok(detail!);
1364 }
1365
1366 async archivePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1367 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1368 if (!found.ok) return found;
1369 const { workspace, page, space } = found.value;
1370 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>()).results;
1371 const ids = descendants(all, page.id);
1372 const at = now();
1373 await this.db.batch(ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL").bind(at, this.userKey(a.viewer!), id)));
1374 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1375 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!)));
Docs: a workspace knowledge base people and agents write together1376 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1377 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1378 return ok(detail!);
1379 }
1380
1381 async restorePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1382 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1383 if (!found.ok) return found;
1384 const { workspace, page, space } = found.value;
1385 if (!page.archived_at) return fail("invalid", "That page isn't in the trash.");
1386 // It comes back with what was trashed with it; under its parent if that is still there.
1387 const all = (await this.db.prepare("SELECT id, parent_id, position, archived_at FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1388 const parent = page.parent_id ? all.find((r) => r.id === page.parent_id) : null;
1389 const parentGone = !!page.parent_id && (!parent || !!parent.archived_at);
1390 const ids = descendants(all, page.id).filter((id) => all.find((r) => r.id === id)?.archived_at === page.archived_at);
1391 const statements = ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = NULL, archived_by = NULL WHERE id = ?").bind(id));
1392 if (parentGone) statements.push(this.db.prepare("UPDATE pages SET parent_id = NULL WHERE id = ?").bind(page.id));
1393 await this.db.batch(statements);
1394 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1395 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1396 return ok(detail!);
1397 }
1398
1399 async deletePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1400 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "manage");
1401 if (!found.ok) return found;
1402 const { page } = found.value;
1403 if (!page.archived_at) return fail("invalid", "Move the page to the trash first.");
1404 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1405 const ids = descendants(all, page.id);
1406 await this.db.batch(ids.flatMap((id) => [this.db.prepare("DELETE FROM pages_fts WHERE page_id = ?").bind(id), this.db.prepare("DELETE FROM pages WHERE id = ?").bind(id)]));
1407 return ok(true);
1408 }
1409
1410 async trash(a: { workspace: string; viewer: Viewer }): Promise<Result<DocPage[]>> {
1411 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1412 if (!found.ok) return found;
1413 const workspace = found.value;
1414 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => atLeast(s.role, "edit"));
1415 if (!spaces.length) return ok([]);
1416 const marks = spaces.map(() => "?").join(",");
1417 const rows = (
1418 await this.db
1419 .prepare(`SELECT ${PAGE_COLUMNS} FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 200`)
1420 .bind(...spaces.map((s) => s.row.id))
1421 .all<PageRow>()
1422 ).results;
1423 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1424 }
1425
1426 async favorite(a: { workspace: string; page_id: string; viewer: Viewer; on: boolean }): Promise<Result<boolean>> {
1427 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1428 if (!found.ok) return found;
1429 if (a.on) {
1430 await this.db.prepare("INSERT OR IGNORE INTO favorites (user_id, page_id, created_at) VALUES (?, ?, ?)").bind(a.viewer!.id, a.page_id, now()).run();
1431 } else {
1432 await this.db.prepare("DELETE FROM favorites WHERE user_id = ? AND page_id = ?").bind(a.viewer!.id, a.page_id).run();
1433 }
1434 return ok(!!a.on);
1435 }
1436
1437 // ── Search ──────────────────────────────────────────────────────────────
1438
1439 /** Full text over `spaceIds`, best first. */
1440 private async searchIn(workspace: Workspace, spaces: Space[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1441 const q = ftsQuery(query.query);
1442 const ids = searchSpaces(
1443 spaces.map((s) => s.row.id),
1444 query.space_id ?? null,
1445 );
1446 if (!ids.length) return [];
1447 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1448 const marks = ids.map(() => "?").join(",");
1449 const project = query.project ? projectRef(query.project) : null;
1450 type Hit = { id: string; space_id: string; title: string; icon: string | null; updated_at: string; snippet: string };
1451 let rows: Hit[];
1452 if (q) {
1453 rows = (
1454 await this.db
1455 .prepare(
1456 `SELECT p.id, p.space_id, p.title, p.icon, p.updated_at, snippet(pages_fts, 2, '[[', ']]', '…', 16) AS snippet
1457 FROM pages_fts JOIN pages p ON p.id = pages_fts.page_id
1458 WHERE pages_fts MATCH ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL
1459 ORDER BY bm25(pages_fts, 0, 8.0, 1.0) LIMIT ?`,
1460 )
1461 .bind(q, ...ids, limit * 3)
1462 .all<Hit>()
1463 ).results;
1464 } else {
1465 rows = (
1466 await this.db
1467 .prepare(`SELECT id, space_id, title, icon, updated_at, substr(markdown, 1, 160) AS snippet FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY updated_at DESC LIMIT ?`)
1468 .bind(...ids, limit * 3)
1469 .all<Hit>()
1470 ).results;
1471 }
1472 const pageIds = rows.map((r) => r.id);
1473 const projects = pageIds.length
1474 ? (
1475 await this.db
1476 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1477 .bind(...pageIds)
1478 .all<{ page_id: string; repo: string }>()
1479 ).results
1480 : [];
1481 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1482 return rows
1483 .map((r) => {
1484 const space = bySpace.get(r.space_id)!;
1485 const own = projects.filter((p) => p.page_id === r.id).map((p) => p.repo);
1486 return { r, space, own };
1487 })
1488 .filter(({ space, own }) => inProject(project, own, space.projects))
1489 .slice(0, limit)
1490 .map(({ r, space, own }) => ({
1491 ...this.ref(workspace.slug, space.row, r),
1492 space_name: space.row.name,
1493 snippet: q ? r.snippet : excerpt(r.snippet, 140),
1494 updated_at: r.updated_at,
1495 projects: [...new Set([...own, ...space.projects])],
1496 }));
1497 }
1498
1499 async search(a: { workspace: string; viewer: Viewer; query: DocSearchQuery }): Promise<Result<DocSearchHit[]>> {
1500 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1501 if (!found.ok) return found;
1502 const workspace = found.value;
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1503 const query = a.query ?? { query: "" };
Docs: a workspace knowledge base people and agents write together1504 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1505 const [pages, files] = await Promise.all([
1506 this.searchIn(workspace, spaces, query),
1507 // A project's docs, when the search isn't narrowed to one of the workspace's spaces.
1508 query.space_id
1509 ? Promise.resolve([] as DocSearchHit[])
1510 : this.searchRepoFiles(workspace, a.viewer!, query).catch((error: unknown) => {
1511 console.error("docs could not search projects' docs", String(error));
1512 return [] as DocSearchHit[];
1513 }),
1514 ]);
1515 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1516 // Pages first, then files, as many as asked for.
1517 return ok([...pages, ...files].slice(0, limit));
1518 }
1519
1520 /** Full text over the projects' docs the viewer can read. */
1521 private async searchRepoFiles(workspace: Workspace, viewer: User, query: DocSearchQuery): Promise<DocSearchHit[]> {
1522 const q = ftsQuery(query.query);
1523 if (!q) return [];
1524 let spaces = await this.readableRepoSpaces(workspace, viewer);
1525 const project = query.project ? projectRef(query.project) : null;
1526 if (project) spaces = spaces.filter((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === project);
1527 if (!spaces.length) return [];
1528 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1529 const rows = (
1530 await this.db
1531 .prepare(
1532 `SELECT space_id, path, title, snippet(repo_files_fts, 3, '[[', ']]', '…', 16) AS snippet FROM repo_files_fts
1533 WHERE repo_files_fts MATCH ? AND space_id IN (${spaces.map(() => "?").join(",")}) ORDER BY bm25(repo_files_fts, 0, 0, 8.0, 1.0) LIMIT ?`,
1534 )
1535 .bind(q, ...spaces.map((s) => s.row.id), limit)
1536 .all<{ space_id: string; path: string; title: string; snippet: string }>()
1537 ).results;
1538 const byId = new Map(spaces.map((s) => [s.row.id, s]));
1539 return rows.map((r) => {
1540 const s = byId.get(r.space_id)!;
1541 const repo = `${s.repo.namespace}/${s.repo.name}`;
1542 return {
1543 id: `repo:${r.space_id}:${r.path}`,
1544 space_id: r.space_id,
1545 space_slug: "repo",
1546 title: r.title,
1547 icon: null,
1548 slug: r.path,
1549 path: `/${workspace.slug}/-/docs/repo/${repo}/${r.path.split("/").map(encodeURIComponent).join("/")}`,
1550 space_name: repo,
1551 snippet: r.snippet,
1552 updated_at: s.row.indexed_at ?? s.row.added_at,
1553 projects: [repo.toLowerCase()],
1554 repo_file: { repo, path: r.path },
1555 };
1556 });
Docs: a workspace knowledge base people and agents write together1557 }
1558
1559 // ── History ─────────────────────────────────────────────────────────────
1560
1561 private async toVersions(workspace: Workspace, rows: Omit<VersionRow, "markdown" | "state">[]): Promise<DocVersion[]> {
1562 const authors = rows.map((r) => {
1563 try {
1564 return JSON.parse(r.authors) as string[];
1565 } catch {
1566 return [];
1567 }
1568 });
1569 const people = await this.profiles(workspace, authors.flat());
1570 return rows.map((r, i) => ({ id: r.id, page_id: r.page_id, created_at: r.created_at, kind: r.kind, note: r.note, authors: authors[i]!.map((k) => people.get(k)!).filter(Boolean) }));
1571 }
1572
1573 async versions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocVersion[]>> {
1574 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1575 if (!found.ok) return found;
1576 // Whatever is unsaved goes in first, so the newest version is now.
1577 await this.room(a.page_id)
1578 .flush()
1579 .catch(() => undefined);
1580 const rows = (
1581 await this.db
1582 .prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE page_id = ? ORDER BY created_at DESC LIMIT 200")
1583 .bind(a.page_id)
1584 .all<Omit<VersionRow, "markdown" | "state">>()
1585 ).results;
1586 return ok(await this.toVersions(found.value.workspace, rows));
1587 }
1588
1589 async version(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersionDetail>> {
1590 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1591 if (!found.ok) return found;
1592 const row = await this.db
1593 .prepare("SELECT id, page_id, created_at, kind, authors, note, markdown FROM page_versions WHERE id = ? AND page_id = ?")
1594 .bind(a.version_id, a.page_id)
1595 .first<Omit<VersionRow, "state">>();
1596 if (!row) return fail("not_found", "No such version.");
1597 const before = await this.db
1598 .prepare("SELECT markdown FROM page_versions WHERE page_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1")
1599 .bind(a.page_id, row.created_at)
1600 .first<{ markdown: string }>();
1601 const [version] = await this.toVersions(found.value.workspace, [row]);
1602 return ok({ ...version!, markdown: row.markdown, diff: diffLines(before?.markdown ?? "", row.markdown) });
1603 }
1604
1605 async restoreVersion(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersion>> {
1606 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1607 if (!found.ok) return found;
1608 const { workspace, page } = found.value;
1609 const row = await this.db.prepare("SELECT * FROM page_versions WHERE id = ? AND page_id = ?").bind(a.version_id, page.id).first<VersionRow>();
1610 if (!row) return fail("not_found", "No such version.");
1611 const room = this.room(page.id);
1612 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1613 const when = new Date(row.created_at).toISOString().slice(0, 16).replace("T", " ");
1614 const origin: Origin = { key: this.userKey(a.viewer!), kind: "restore", note: `Restored the version of ${when} UTC` };
1615 const versionId = await room.restore({ state: row.state ? new Uint8Array(row.state) : null, markdown: row.markdown }, origin);
1616 const created = versionId
1617 ? await this.db.prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE id = ?").bind(versionId).first<Omit<VersionRow, "markdown" | "state">>()
1618 : null;
1619 if (!created) return fail("conflict", "The page could not be restored. Try again.");
1620 const [version] = await this.toVersions(workspace, [created]);
1621 this.tell(page.id, { type: "version.created", version: version! });
1622 return ok(version!);
1623 }
1624
1625 // ── Templates and export ────────────────────────────────────────────────
1626
1627 async templates(a: { workspace: string; viewer: Viewer }): Promise<Result<DocTemplate[]>> {
1628 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1629 if (!found.ok) return found;
1630 const rows = (
1631 await this.db
1632 .prepare("SELECT * FROM templates WHERE workspace_id = ? ORDER BY name COLLATE NOCASE")
1633 .bind(found.value.id)
1634 .all<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>()
1635 ).results;
1636 return ok([
1637 ...BUILTIN_TEMPLATES,
1638 ...rows.map((r) => ({ id: r.id, name: r.name, description: r.description, icon: r.icon, markdown: r.markdown, builtin: false, created_by: parsePrincipalKey(r.created_by) })),
1639 ]);
1640 }
1641
1642 async saveTemplate(a: { workspace: string; viewer: Viewer; input: { page_id: string; name: string; description?: string | null } }): Promise<Result<DocTemplate>> {
1643 const found = await this.pageFor(a.workspace, a.input?.page_id, a.viewer, "view");
1644 if (!found.ok) return found;
1645 const { workspace, page } = found.value;
1646 const name = cleanTitle(a.input.name || page.title).slice(0, 80);
1647 if (!name) return fail("invalid", "Name the template.");
1648 const room = this.room(page.id);
1649 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1650 const { markdown } = await room.read();
1651 const id = newId("tpl");
1652 const description = String(a.input.description ?? "").trim().slice(0, 200);
1653 await this.db
1654 .prepare("INSERT INTO templates (id, workspace_id, name, description, icon, markdown, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1655 .bind(id, workspace.id, name, description, page.icon ?? "📄", markdown, this.userKey(a.viewer!), now())
1656 .run();
1657 return ok({ id, name, description, icon: page.icon ?? "📄", markdown, builtin: false, created_by: { kind: "user", id: a.viewer!.id } });
1658 }
1659
1660 async deleteTemplate(a: { workspace: string; template_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1661 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1662 if (!found.ok) return found;
1663 const row = await this.db.prepare("SELECT created_by FROM templates WHERE id = ? AND workspace_id = ?").bind(a.template_id, found.value.id).first<{ created_by: string }>();
1664 if (!row) return fail("not_found", "No such template.");
1665 if (row.created_by !== this.userKey(a.viewer!) && !this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only whoever saved a template, or an owner, can delete it.");
1666 await this.db.prepare("DELETE FROM templates WHERE id = ?").bind(a.template_id).run();
1667 return ok(true);
1668 }
1669
1670 async exportPage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<{ filename: string; markdown: string }>> {
1671 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1672 if (!found.ok) return found;
1673 const { workspace, page } = found.value;
1674 const room = this.room(page.id);
1675 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1676 const { markdown } = await room.read();
1677 const title = page.title || "Untitled";
1678 return ok({ filename: `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "page"}.md`, markdown: `# ${title}\n\n${markdown}` });
1679 }
1680
1681 async exportSpace(a: { workspace: string; space_id: string; viewer: Viewer }): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>> {
1682 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1683 if (!found.ok) return found;
1684 const spaces = await this.spacesFor(found.value, a.viewer!);
1685 const space = spaces.find((s) => s.row.id === a.space_id);
1686 if (!space?.role) return fail("not_found", "No such space.");
1687 const rows = (
1688 await this.db.prepare("SELECT id, parent_id, position, title, markdown FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(space.row.id).all<PageRow>()
1689 ).results;
1690 const paths = exportPaths(rows.map((r) => ({ ...r, title: r.title || "Untitled" })));
1691 return ok({
1692 name: space.row.slug,
1693 files: rows.map((r) => ({ path: paths.get(r.id)!, markdown: `# ${r.title || "Untitled"}\n\n${r.markdown}` })).sort((x, y) => x.path.localeCompare(y.path)),
1694 });
1695 }
1696
1697 // ── Suggestions ─────────────────────────────────────────────────────────
1698
1699 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<DocSuggestion[]> {
1700 const people = await this.profiles(
1701 workspace,
1702 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1703 );
1704 return rows.map((r, i) => ({
1705 id: r.id,
1706 page_id: r.page_id,
1707 author: people.get(r.author)!,
1708 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1709 target: JSON.parse(r.target) as DocEditTarget,
1710 before_markdown: r.before_markdown,
1711 after_markdown: r.after_markdown,
1712 note: r.note,
1713 status: r.status,
1714 created_at: r.created_at,
1715 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1716 decided_at: r.decided_at,
1717 block_ids: blocks[i] ?? [],
1718 }));
1719 }
1720
1721 /** A page's open suggestions, with the blocks each covers now; ones whose target is gone become stale. */
1722 private async openSuggestions(workspace: Workspace, pageId: string): Promise<DocSuggestion[]> {
1723 const rows = (await this.db.prepare("SELECT * FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(pageId).all<SuggestionRow>()).results;
1724 if (!rows.length) return [];
1725 let blocks: (string[] | null)[] = rows.map(() => []);
1726 try {
1727 blocks = await this.room(pageId).targets(rows.map((r) => JSON.parse(r.target) as DocEditTarget));
1728 } catch (error) {
1729 console.error("docs could not place suggestions", error);
1730 }
1731 const gone = rows.filter((_, i) => blocks[i] === null);
1732 if (gone.length) {
1733 await this.db.batch(gone.map((r) => this.db.prepare("UPDATE suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1734 }
1735 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1736 return this.toSuggestions(
1737 workspace,
1738 live.map((x) => x.r),
1739 live.map((x) => x.b!),
1740 );
1741 }
1742
1743 async suggestions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1744 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1745 if (!found.ok) return found;
1746 return ok(await this.openSuggestions(found.value.workspace, a.page_id));
1747 }
1748
1749 async decideSuggestion(a: { workspace: string; suggestion_id: string; viewer: Viewer; decision: "accept" | "reject" }): Promise<Result<DocSuggestion>> {
1750 const row = await this.db.prepare("SELECT * FROM suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1751 if (!row) return fail("not_found", "No such suggestion.");
1752 const found = await this.pageFor(a.workspace, row.page_id, a.viewer, "edit");
1753 if (!found.ok) return found.error.code === "forbidden" ? fail("forbidden", "Only people who can edit the page can accept or reject a suggestion.") : found;
1754 const { workspace, page } = found.value;
1755 if (row.status !== "open") return fail("conflict", "That suggestion was already decided.");
1756 const me = this.userKey(a.viewer!);
1757 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1758 if (a.decision === "accept") {
1759 const people = await this.profiles(workspace, [row.author, me]);
1760 const room = this.room(page.id);
1761 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1762 const result = await room.edit(JSON.parse(row.target) as DocEditTarget, row.after_markdown, {
1763 key: me,
1764 kind: "suggestion",
1765 note: `Suggested by @${people.get(row.author)!.name}, accepted by @${people.get(me)!.name}`,
1766 authors: [row.author, me],
1767 });
1768 if (!result.applied) status = "stale";
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1769 else if (row.marks_current) await this.clearStale(page.id, row.author);
Docs: a workspace knowledge base people and agents write together1770 }
1771 await this.db.prepare("UPDATE suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, me, now(), row.id).run();
1772 const [after] = await this.toSuggestions(workspace, [{ ...row, status, decided_by: me, decided_at: now() }]);
1773 this.tell(page.id, { type: "suggestion.updated", suggestion: after! });
1774 if (status === "stale") return fail("conflict", "The part of the page this suggestion changes is gone, so it can't be applied.");
1775 return ok(after!);
1776 }
1777
1778 async acceptAll(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1779 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1780 if (!found.ok) return found;
1781 const rows = (await this.db.prepare("SELECT id FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(a.page_id).all<{ id: string }>()).results;
1782 const out: DocSuggestion[] = [];
1783 for (const r of rows) {
1784 const decided = await this.decideSuggestion({ workspace: a.workspace, suggestion_id: r.id, viewer: a.viewer, decision: "accept" });
1785 if (decided.ok) out.push(decided.value);
1786 }
1787 return ok(out);
1788 }
1789
1790 // ── Comments ────────────────────────────────────────────────────────────
1791
1792 async thread(a: { workspace: string; page_id: string; viewer: Viewer; action: DocThreadAction }): Promise<Result<unknown>> {
1793 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "comment");
1794 if (!found.ok) return found;
1795 const { workspace, page, space } = found.value;
1796 const me = this.userKey(a.viewer!);
1797 const room = this.room(page.id);
1798 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1799 const result = (await room.thread(me, space.role!, a.action)) as ThreadResult;
1800 if (!result.ok) return fail(result.code, result.message);
1801 if (result.mentions?.length) this.defer(this.notifyMentioned(workspace, page, space.row, me, result.mentions, result.text ?? "", result.thread_id ?? null));
1802 return ok(result.value);
1803 }
1804
1805 /** People mentioned in a comment hear of it, if they can read the page. */
1806 private async notifyMentioned(workspace: Workspace, page: PageRow, space: SpaceRow, author: string, mentions: string[], text: string, threadId: string | null): Promise<void> {
1807 if (!this.env.NOTIFY) return;
1808 // Comment mentions name people by username (`user:<username>`).
1809 const me = author.startsWith("user:") ? (this.usernames.get(author.slice(5)) ?? "").toLowerCase() : "";
1810 const names = [...new Set(mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()))].filter((n) => n && n !== me);
1811 if (!names.length) return;
1812 const [spaces, people] = await Promise.all([this.allSpaces(workspace), this.profiles(workspace, [author])]);
1813 const s = spaces.find((x) => x.row.id === space.id);
1814 if (!s) return;
1815 await this.nameUsers(s.members.filter((m) => m.principal.startsWith("user:")).map((m) => m.principal.slice(5)));
1816 const teams = this.needsTeams([s]) ? await this.teamsOf(workspace) : new Map<string, Set<string>>();
1817 const members = await this.members(workspace);
1818 const who = people.get(author)!;
1819 const href = `${this.ref(workspace.slug, space, page).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
1820 const notify = notifyClient(this.env.NOTIFY);
1821 await Promise.all(
1822 names.map(async (username) => {
1823 const member = members.get(username);
1824 if (!member) return;
1825 // Their id is not needed: access by username's teams and role is enough to decide.
1826 const person: Person = { user_id: `name:${username}`, owner: member.role === "owner", teams: teams.get(username) ?? new Set() };
1827 const listed = s.members.some((m) => m.principal.startsWith("user:") && this.usernames.get(m.principal.slice(5))?.toLowerCase() === username);
1828 if (!listed && !atLeast(roleOf(rulesOf({ ...s, role: null }), person), "view")) return;
1829 await notify
1830 .notify(
1831 { username },
1832 {
1833 id: `doc-comment:${page.id}:${threadId ?? ""}:${username}:${Date.now()}`,
1834 kind: "mention",
1835 workspace: workspace.slug,
1836 title: `${who.display_name} mentioned you on ${page.title || "Untitled"}`,
1837 body: text.slice(0, 140),
1838 href,
1839 actor: { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null },
1840 created_at: now(),
1841 },
1842 )
1843 .catch(() => undefined);
1844 }),
1845 );
1846 }
1847
1848 private async resolveThreads(workspace: Workspace, threads: Awaited<ReturnType<PageRoom["threads"]>>): Promise<DocThread[]> {
1849 const people = await this.profiles(
1850 workspace,
1851 threads.flatMap((t) => t.comments.map((c) => c.author)),
1852 );
1853 return threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }));
1854 }
1855
1856 async threads(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocThread[]>> {
1857 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1858 if (!found.ok) return found;
1859 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
1860 }
1861
1862 // ── Agents ──────────────────────────────────────────────────────────────
1863
1864 /**
1865 * What an agent may read and do for `viewer`, space by space: the
1866 * viewer's own access, narrowed to what every person in the audience can
1867 * read. Never wider than the viewer.
1868 */
1869 private async agentSpaces(slug: string, agentId: string, viewer: Viewer, audience: DocAudience | null): Promise<Result<{ workspace: Workspace; agent: WorkspaceAgent; spaces: (Space & { can: DocAgentAbilities })[] }>> {
1870 const found = await this.viewerWorkspace(slug, viewer);
1871 if (!found.ok) return found;
1872 const workspace = found.value;
1873 const agent = (await this.agentsById([String(agentId ?? "")])).get(String(agentId ?? ""));
1874 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
1875 const spaces = await this.spacesFor(workspace, viewer!);
1876 let readable = (s: Space) => !!s.role;
1877 if (audience?.kind === "workspace") {
1878 readable = (s) => !!s.role && readableByWorkspace(rulesOf(s));
1879 } else if (audience?.kind === "people" && Array.isArray(audience.user_ids) && audience.user_ids.length) {
1880 const ids = [...new Set(audience.user_ids.map(String))].slice(0, 200);
1881 await this.nameUsers(ids);
1882 const [members, teams] = await Promise.all([this.members(workspace), this.needsTeams(spaces) ? this.teamsOf(workspace) : Promise.resolve(new Map<string, Set<string>>())]);
1883 const people: Person[] = ids.map((id) => {
1884 const username = this.usernames.get(id)?.toLowerCase() ?? "";
1885 const member = members.get(username);
1886 // Someone who is not a member reads nothing: a person with no teams who owns nothing.
1887 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: teams.get(username) ?? new Set() };
1888 });
1889 readable = (s) => !!s.role && readableByAll(rulesOf(s), people);
1890 }
1891 return ok({
1892 workspace,
1893 agent,
1894 spaces: spaces.filter(readable).map((s) => ({ ...s, can: agentAbilities(s.role, s.row.agent_mode) })),
1895 });
1896 }
1897
1898 async spacesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; audience: DocAudience | null }) {
1899 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1900 if (!found.ok) return found;
1901 return ok(
1902 found.value.spaces.map((s) => ({
1903 id: s.row.id,
1904 slug: s.row.slug,
1905 name: s.row.name,
1906 description: s.row.description,
1907 kind: s.row.kind,
1908 agent_mode: s.row.agent_mode,
1909 projects: s.projects,
1910 can: s.can,
1911 })),
1912 );
1913 }
1914
1915 /** A page an agent may read for the viewer and audience, or not found. */
1916 private async agentPage(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience?: DocAudience | null }) {
1917 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
1918 if (!found.ok) return found;
1919 const page = await this.db
1920 .prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ? AND archived_at IS NULL")
1921 .bind(String(a.page_id ?? ""), found.value.workspace.id)
1922 .first<PageRow>();
1923 const space = page ? found.value.spaces.find((s) => s.row.id === page.space_id) : null;
1924 if (!page || !space) return fail("not_found", "No such page.");
1925 return ok({ ...found.value, page, space });
1926 }
1927
1928 async pageMarkdown(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocAgentPage>> {
1929 const found = await this.agentPage(a);
1930 if (!found.ok) return found;
1931 const { workspace, page, space } = found.value;
1932 const room = this.room(page.id);
1933 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1934 const read = await room.read();
1935 return ok({
1936 page: { ...this.ref(workspace.slug, space.row, page), updated_at: page.updated_at },
1937 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1938 markdown: read.markdown,
1939 blocks: read.blocks,
1940 can: space.can,
1941 });
1942 }
1943
1944 async searchForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; query: DocSearchQuery; audience: DocAudience | null }): Promise<Result<DocSearchHit[]>> {
1945 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1946 if (!found.ok) return found;
1947 return ok(await this.searchIn(found.value.workspace, found.value.spaces, { ...(a.query ?? { query: "" }), limit: Math.min(Number(a.query?.limit) || 10, 20) }));
1948 }
1949
1950 private async fileSuggestion(
1951 workspace: Workspace,
1952 page: PageRow,
1953 space: SpaceRow,
1954 agent: WorkspaceAgent,
1955 viewer: User,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1956 edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
Docs: a workspace knowledge base people and agents write together1957 ): Promise<Result<DocSuggestion>> {
1958 const room = this.room(page.id);
1959 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1960 const current = await room.target(edit.target);
1961 if (!current) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
1962 const id = newId("sug");
1963 const row: SuggestionRow = {
1964 id,
1965 page_id: page.id,
1966 author: principalKey({ kind: "agent", id: agent.id }),
1967 asked_by: this.userKey(viewer),
1968 target: JSON.stringify(edit.target),
1969 before_markdown: current.markdown,
1970 after_markdown: edit.markdown,
1971 note: edit.note,
1972 status: "open",
1973 created_at: now(),
1974 decided_by: null,
1975 decided_at: null,
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1976 marks_current: edit.marks_current ? 1 : 0,
Docs: a workspace knowledge base people and agents write together1977 };
1978 await this.db
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store1979 .prepare("INSERT INTO suggestions (id, page_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
1980 .bind(row.id, row.page_id, row.author, row.asked_by, row.target, row.before_markdown, row.after_markdown, row.note, row.created_at, row.marks_current)
Docs: a workspace knowledge base people and agents write together1981 .run();
1982 const [suggestion] = await this.toSuggestions(workspace, [row], [current.block_ids]);
1983 this.tell(page.id, { type: "suggestion.created", suggestion: suggestion! });
1984 this.defer(room.announce(row.author, agent.display_name).catch(() => undefined));
1985 this.defer(this.notifyOwners(workspace, page, space, suggestion!));
1986 return ok(suggestion!);
1987 }
1988
1989 /** A page's owners (people) hear of a suggestion waiting for them. */
1990 private async notifyOwners(workspace: Workspace, page: PageRow, space: SpaceRow, suggestion: DocSuggestion): Promise<void> {
1991 if (!this.env.NOTIFY) return;
1992 const owners = (await this.db.prepare("SELECT principal FROM page_owners WHERE page_id = ?").bind(page.id).all<{ principal: string }>()).results
1993 .map((o) => o.principal)
1994 .filter((k) => k.startsWith("user:"))
1995 .map((k) => k.slice(5));
1996 if (!owners.length) return;
1997 const notify = notifyClient(this.env.NOTIFY);
1998 const href = this.ref(workspace.slug, space, page).path;
1999 await Promise.all(
2000 owners.map((id) =>
2001 notify
2002 .notify(
2003 { user_id: id },
2004 {
2005 id: `doc-suggestion:${suggestion.id}:${id}`,
2006 kind: "inbox",
2007 workspace: workspace.slug,
2008 title: `${suggestion.author.display_name} suggested a change to ${page.title || "Untitled"}`,
2009 body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
2010 href,
2011 actor: { kind: "agent", id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null },
2012 created_at: suggestion.created_at,
2013 },
2014 )
2015 .catch(() => undefined),
2016 ),
2017 );
2018 }
2019
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2020 private cleanEdit(edit: unknown): Result<{ target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }> {
2021 const e = (edit ?? {}) as { target?: unknown; markdown?: unknown; note?: unknown; marks_current?: unknown };
Docs: a workspace knowledge base people and agents write together2022 const target = cleanTarget(e.target);
2023 if (!target) return fail("invalid", "Say what to change: append, document, a section by its heading, or blocks by id.");
2024 const markdown = String(e.markdown ?? "");
2025 if (markdown.length > MAX_MARKDOWN) return fail("invalid", "That edit is too long.");
2026 if (target.kind === "append" && !markdown.trim()) return fail("invalid", "Nothing to add.");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2027 return ok({ target, markdown, note: e.note ? String(e.note).trim().slice(0, MAX_NOTE) || null : null, marks_current: e.marks_current === true });
Docs: a workspace knowledge base people and agents write together2028 }
2029
2030 async suggestEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocSuggestion>> {
2031 const edit = this.cleanEdit(a.edit);
2032 if (!edit.ok) return edit;
2033 const found = await this.agentPage(a);
2034 if (!found.ok) return found;
2035 const { workspace, agent, page, space } = found.value;
2036 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so no change can be suggested for them.`);
2037 return this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2038 }
2039
2040 async applyEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocAgentEditResult>> {
2041 const edit = this.cleanEdit(a.edit);
2042 if (!edit.ok) return edit;
2043 const found = await this.agentPage(a);
2044 if (!found.ok) return found;
2045 const { workspace, agent, page, space } = found.value;
2046 const ref = this.ref(workspace.slug, space.row, page);
2047 if (!space.can.edit) {
2048 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so it can't be changed for them.`);
2049 const suggestion = await this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2050 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, page: ref }) : suggestion;
2051 }
2052 const room = this.room(page.id);
2053 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2054 const result = await room.edit(edit.value.target, edit.value.markdown, {
2055 key: principalKey({ kind: "agent", id: agent.id }),
2056 kind: "agent",
2057 note: edit.value.note ? `@${agent.handle} for @${a.viewer!.username}: ${edit.value.note}` : `@${agent.handle} for @${a.viewer!.username}`,
2058 authors: [principalKey({ kind: "agent", id: agent.id })],
2059 });
2060 if (!result.applied) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2061 if (edit.value.marks_current) await this.clearStale(page.id, principalKey({ kind: "agent", id: agent.id }));
Docs: a workspace knowledge base people and agents write together2062 this.defer(room.announce(principalKey({ kind: "agent", id: agent.id }), agent.display_name).catch(() => undefined));
2063 return ok({ mode: "applied", version_id: result.version_id, page: ref });
2064 }
2065
2066 async createPageAsAgent(a: {
2067 workspace: string;
2068 agent_id: string;
2069 viewer: Viewer;
2070 input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null };
2071 }): Promise<Result<DocPageRef>> {
2072 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, null);
2073 if (!found.ok) return found;
2074 const { workspace, agent, spaces } = found.value;
2075 const input = a.input ?? ({} as typeof a.input);
2076 const space = input.space_id ? spaces.find((s) => s.row.id === input.space_id) : spaces.find((s) => s.row.is_default);
2077 if (!space) return fail("not_found", "No such space.");
2078 if (!atLeast(space.role, "edit")) return fail("forbidden", `${a.viewer!.username} can't add pages to ${space.row.name}.`);
2079 const title = cleanTitle(input.title);
2080 if (!title) return fail("invalid", "Give the page a title.");
2081 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
2082 const source = input.source && typeof input.source.href === "string" && input.source.href.startsWith("/") ? input.source : null;
2083 if (source) markdown = `> [!NOTE]\n> Written up from [${String(source.title || "a conversation").replace(/[[\]]/g, "")}](${source.href}).\n\n${markdown}`;
2084 const parent = input.parent_id
2085 ? await this.db.prepare("SELECT id FROM pages WHERE id = ? AND space_id = ? AND archived_at IS NULL").bind(input.parent_id, space.row.id).first<{ id: string }>()
2086 : null;
2087 const agentKey = principalKey({ kind: "agent", id: agent.id });
2088 const row = await this.insertPage(workspace, space.row, agentKey, {
2089 parent_id: parent?.id ?? null,
2090 title,
2091 icon: cleanIcon(input.icon),
2092 markdown,
2093 owners: [this.userKey(a.viewer!)],
2094 });
2095 return ok(this.ref(workspace.slug, space.row, row));
2096 }
2097
2098 async threadsForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocThread[]>> {
2099 const found = await this.agentPage(a);
2100 if (!found.ok) return found;
2101 return ok(await this.resolveThreads(found.value.workspace, await this.room(found.value.page.id).threads()));
2102 }
2103
2104 // ── Sockets and files ───────────────────────────────────────────────────
2105
2106 private viewerFrom(request: Request): Viewer {
2107 try {
2108 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2109 } catch {
2110 return null;
2111 }
2112 }
2113
2114 /**
2115 * `GET /live?workspace=<slug>&page=<id>`, upgraded to a WebSocket. The
2116 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking the
2117 * session; trusted only because this Worker is reachable through service
2118 * bindings alone. Checked like any read, then handed to the page's room
2119 * with the viewer's role, which the room enforces.
2120 */
2121 async live(request: Request): Promise<Response> {
2122 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2123 const viewer = this.viewerFrom(request);
2124 if (!viewer?.id) return new Response("Sign in to use Docs\n", { status: 401 });
2125 const url = new URL(request.url);
2126 const slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
2127 const found = await this.pageFor(slug, url.searchParams.get("page") ?? "", viewer, "view");
2128 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2129 const { workspace, page, space } = found.value;
2130 if (page.archived_at) return new Response("That page is in the trash\n", { status: 410 });
2131 const room = this.room(page.id);
2132 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2133 const key = this.userKey(viewer);
2134 const who: RoomMember = { page_id: page.id, workspace_slug: workspace.slug, key, member: (await this.profiles(workspace, [key])).get(key)!, role: space.role! };
2135 const headers = new Headers(request.headers);
2136 headers.delete(DOCS_VIEWER_HEADER);
2137 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
2138 return room.fetch(new Request(request.url, { method: "GET", headers }));
2139 }
2140
2141 /** `PUT /files?workspace=&page=&name=`: a file for a page, from someone who can edit it. */
2142 async upload(request: Request): Promise<Response> {
2143 const viewer = this.viewerFrom(request);
2144 const url = new URL(request.url);
2145 const found = await this.pageFor((url.searchParams.get("workspace") ?? "").toLowerCase(), url.searchParams.get("page") ?? "", viewer, "edit");
2146 if (!found.ok) return Response.json(found);
2147 const bytes = Number(request.headers.get("content-length") ?? "0");
2148 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2149 const name = safeName(url.searchParams.get("name") ?? "file");
2150 const contentType = servedType(request.headers.get("content-type") ?? "");
2151 const random = crypto.getRandomValues(new Uint8Array(32));
2152 const key = [...random].map((b) => b.toString(16).padStart(2, "0")).join("");
2153 const id = newId("fil");
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2154 await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
Docs: a workspace knowledge base people and agents write together2155 await this.db
2156 .prepare("INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2157 .bind(id, found.value.workspace.id, found.value.page.id, key, name, contentType, bytes, this.userKey(viewer!), now())
2158 .run();
2159 const file: DocFile = { id, url: `/docs-files/${key}`, name, content_type: contentType, bytes };
2160 return Response.json(ok(file));
2161 }
2162
2163 /**
2164 * `GET /files/<key>`: a page's file, for the usercontent origin. The key
2165 * is 256 random bits, so knowing it is the permission, as with any
2166 * shared link; it is never on the site's own origin.
2167 */
2168 async file(key: string): Promise<Response> {
2169 const row = await this.db.prepare("SELECT name, content_type FROM files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2170 if (!row) return new Response("Not found\n", { status: 404 });
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2171 const stored = await fileStore(this.env).get(`docs/${key}`);
Docs: a workspace knowledge base people and agents write together2172 if (!stored) return new Response("Not found\n", { status: 404 });
2173 const inline = row.content_type !== "application/octet-stream";
2174 return new Response(stored.body, {
2175 headers: {
2176 "content-type": row.content_type,
2177 "content-length": String(stored.bytes),
2178 etag: stored.etag,
2179 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2180 "cache-control": "private, max-age=31536000, immutable",
2181 },
2182 });
2183 }
2184}
2185
2186/** One RPC method's answer. */
2187async function answer(service: Docs, method: string, args: any): Promise<Response> {
2188 switch (method) {
2189 case "sidebar":
2190 return Response.json(await service.sidebar(args));
2191 case "home":
2192 return Response.json(await service.home(args));
2193 case "space":
2194 return Response.json(await service.space(args));
2195 case "create_space":
2196 return Response.json(await service.createSpace(args));
2197 case "update_space":
2198 return Response.json(await service.updateSpace(args));
2199 case "set_space_member":
2200 return Response.json(await service.setSpaceMember(args));
2201 case "page":
2202 return Response.json(await service.page(args));
2203 case "create_page":
2204 return Response.json(await service.createPage(args));
2205 case "update_page":
2206 return Response.json(await service.updatePage(args));
2207 case "move_page":
2208 return Response.json(await service.movePage(args));
2209 case "duplicate_page":
2210 return Response.json(await service.duplicatePage(args));
2211 case "archive_page":
2212 return Response.json(await service.archivePage(args));
2213 case "restore_page":
2214 return Response.json(await service.restorePage(args));
2215 case "delete_page":
2216 return Response.json(await service.deletePage(args));
2217 case "trash":
2218 return Response.json(await service.trash(args));
2219 case "favorite":
2220 return Response.json(await service.favorite(args));
2221 case "search":
2222 return Response.json(await service.search(args));
2223 case "versions":
2224 return Response.json(await service.versions(args));
2225 case "version":
2226 return Response.json(await service.version(args));
2227 case "restore_version":
2228 return Response.json(await service.restoreVersion(args));
2229 case "templates":
2230 return Response.json(await service.templates(args));
2231 case "save_template":
2232 return Response.json(await service.saveTemplate(args));
2233 case "delete_template":
2234 return Response.json(await service.deleteTemplate(args));
2235 case "export_page":
2236 return Response.json(await service.exportPage(args));
2237 case "export_space":
2238 return Response.json(await service.exportSpace(args));
2239 case "suggestions":
2240 return Response.json(await service.suggestions(args));
2241 case "decide_suggestion":
2242 return Response.json(await service.decideSuggestion(args));
2243 case "accept_all":
2244 return Response.json(await service.acceptAll(args));
2245 case "thread":
2246 return Response.json(await service.thread(args));
2247 case "threads":
2248 return Response.json(await service.threads(args));
2249 case "spaces_for_agent":
2250 return Response.json(await service.spacesForAgent(args));
2251 case "page_markdown":
2252 return Response.json(await service.pageMarkdown(args));
2253 case "search_for_agent":
2254 return Response.json(await service.searchForAgent(args));
2255 case "suggest_edit":
2256 return Response.json(await service.suggestEdit(args));
2257 case "apply_edit":
2258 return Response.json(await service.applyEdit(args));
2259 case "create_page_as_agent":
2260 return Response.json(await service.createPageAsAgent(args));
2261 case "threads_for_agent":
2262 return Response.json(await service.threadsForAgent(args));
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2263 case "stale_pages_for_agent":
2264 return Response.json(await service.stalePagesForAgent(args));
2265 case "mark_current":
2266 return Response.json(await service.markCurrent(args));
2267 case "stale_pages":
2268 return Response.json(await service.stalePages(args));
2269 case "add_repo_space":
2270 return Response.json(await service.addRepoSpace(args));
2271 case "remove_repo_space":
2272 return Response.json(await service.removeRepoSpace(args));
2273 case "repo_page":
2274 return Response.json(await service.repoPage(args));
Docs: a workspace knowledge base people and agents write together2275 default:
2276 return new Response("Unknown method\n", { status: 404 });
2277 }
2278}
2279
2280export default {
2281 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2282 const url = new URL(request.url);
2283 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
2284 if (request.method === "GET" && url.pathname === "/live") return new Docs(env, defer).live(request);
2285 if (request.method === "PUT" && url.pathname === "/files") return new Docs(env, defer).upload(request);
2286 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
2287 if ((request.method === "GET" || request.method === "HEAD") && file) return new Docs(env, defer).file(file[1]!);
2288 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2289 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2290 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2291 const opened = openD1(env.DB, request);
2292 const service = new Docs(Object.create(env, { DB: { value: opened.db } }) as Env, defer);
2293 const args = (await request.json().catch(() => ({}))) as any;
2294 try {
2295 return opened.finish(await answer(service, match[1]!, args));
2296 } catch (error) {
2297 console.error("docs:", match[1], error);
2298 return opened.finish(Response.json(fail("conflict", "Docs couldn't do that just now. Try again.")));
2299 }
2300 },
Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store2301
2302 /**
2303 * Events from the events service (SUBSCRIBER_DOCS): pages whose cited
2304 * code changed become possibly out of date, and projects' docs are read
2305 * again after a push (src/staleness.ts). One failing event is retried on
2306 * its own.
2307 */
2308 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2309 const reindex = async (repoId: string) => {
2310 if (env.REPOS) await reindexRepo({ DB: env.DB, REPOS: env.REPOS }, repoId);
2311 };
2312 for (const message of batch.messages) {
2313 try {
2314 await onEvent(env, message.body, reindex);
2315 message.ack();
2316 } catch (error) {
2317 console.error("docs could not handle", message.body?.type, String(error));
2318 message.retry();
2319 }
2320 }
2321 },
2322} satisfies ExportedHandler<Env, G1tEvent>;