Skip to content
2,801 linesCodeBlameRaw

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

The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.1/**
2 * The artifacts service: a workspace's spaces and pages, their live documents,
3 * history, comments, agents' suggestions, templates and search
4 * (docs.g1t.sh/guides/artifacts/, "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 * The artifacts service also hosts folios (Artifacts mode):
16 * docs, slides, designs and dashboards, in src/folios/ with their own
17 * room (FolioRoom). `/rpc/<method>` asks the folio table (src/folios/rpc.ts)
18 * first, then Docs' own switch below; `/live?folio=` and
19 * `PUT /files?folio=` are a folio's. Docs' pages keep working on their
20 * tables until Phase 7 retires them.
21 */
22
23import {
24 DOCS_VIEWER_HEADER,
25 DOC_MAX_FILE_BYTES,
26 fail,
27 identityClient,
28 newId,
29 notifyClient,
30 ok,
31 openD1,
32 parsePrincipalKey,
33 principalKey,
34 reposClient,
35 workspaceAgentsClient,
36 type DocAgentAbilities,
37 type DocCitation,
38 type DocDescribes,
39 type DocRepoPage,
40 type DocRepoSpace,
41 type DocStaleChange,
42 type DocStalePage,
43 type DocStaleness,
44 type G1tEvent,
45 type Repo,
46 type DocAgentEditResult,
47 type DocAgentMode,
48 type DocAgentPage,
49 type DocAudience,
50 type DocEditTarget,
51 type DocFile,
52 type DocMove,
53 type DocPage,
54 type DocPageChange,
55 type DocPageDetail,
56 type DocPageRef,
57 type DocPassage,
58 type DocRole,
59 type DocSearchHit,
60 type DocSearchQuery,
61 type DocSpace,
62 type DocSpaceChange,
63 type DocSpaceKind,
64 type DocSpaceMember,
65 type DocSuggestion,
66 type DocTemplate,
67 type DocThread,
68 type DocThreadAction,
69 type DocTreeNode,
70 type DocVersion,
71 type DocVersionDetail,
72 type DocsHome,
73 type DocsLiveEvent,
74 type DocsSidebar,
75 type Member,
76 type MemberProfile,
77 type NewDocPage,
78 type NewDocSpace,
79 type Principal,
80 type Result,
81 type ServiceBinding,
82 type User,
83 type Viewer,
84 type Workspace,
85 type WorkspaceAgent,
86} from "@g1t/contracts";
87
88import { RANK, agentAbilities, atLeast, isRole, leavesNoManager, memberKey, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
89import { diffLines } from "./diff.ts";
90import { cleanDescribes } from "./citations.ts";
91import { publishDocEvent } from "./events.ts";
92import { fileStore, safeName, servedType, type FileStoreEnv } from "./files.ts";
93import { repoFileId } from "./chunks.ts";
94import { adapters, ensureIndexed, forgetDocs, indexPage, indexRepoFiles, runBackfill, startBackfill, type DocsJob } from "./indexer.ts";
95import { excerpt, searchText } from "./markdown.ts";
96import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, requiredSpaces, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "./recall.ts";
97import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts";
98import { indexRepoSpace, reindexRepo, type RepoSpaceRow } from "./repo-spaces.ts";
99import { ftsAnyQuery, ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
100import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts";
101import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts";
102import type { ThreadResult } from "./threads.ts";
103import { onEvent } from "./staleness.ts";
104import { descendants, exportPaths, lastPosition, placeBefore, wouldCycle, ancestors } from "./tree.ts";
105import type { FolioRoom } from "./folios/room.ts";
106import { folioHandler } from "./folios/rpc.ts";
107import { Folios, purgeTrash, runReacl } from "./folios/service.ts";
108
109export { PageRoom } from "./room.ts";
110export { FolioRoom } from "./folios/room.ts";
111
112type Env = FileStoreEnv & {
113 DB: D1Database;
114 IDENTITY: ServiceBinding;
115 AGENTS: ServiceBinding;
116 NOTIFY?: ServiceBinding;
117 /** Repositories: who may read one, what a change touched, a project's docs (src/staleness.ts, src/repo-spaces.ts). */
118 REPOS?: ServiceBinding;
119 /** Pull requests: what a merged one changed. */
120 WORK?: ServiceBinding;
121 /** The bus: `doc.page.*` events (src/events.ts). */
122 EVENTS?: ServiceBinding;
123 PAGES: DurableObjectNamespace<PageRoom>;
124 /** Workers AI: embeds passages and queries for the semantic index (src/vectors.ts). Without it, words only. */
125 AI?: Ai;
126 /** The semantic index, Vectorize `g1t-docs` (src/vectors.ts, src/indexer.ts). */
127 VECTORS?: Vectorize;
128 /** The artifacts service's own events queue, also carrying its backfill jobs (`docs.index`, src/indexer.ts) and folio access jobs (`folios.reacl`). */
129 JOBS?: Queue<DocsJob>;
130 /** One room per folio (Artifacts mode, src/folios/room.ts). */
131 FOLIOS: DurableObjectNamespace<FolioRoom>;
132 /** Folios' semantic index, Vectorize `g1t-folios`; optional (words only without it). */
133 FOLIO_VECTORS?: Vectorize;
134};
135
136/** Queries' embeddings, a minute per isolate (src/recall.ts). */
137const queryVectors = new QueryCache();
138
139type SpaceRow = {
140 id: string;
141 workspace_id: string;
142 slug: string;
143 name: string;
144 description: string | null;
145 icon: string | null;
146 kind: DocSpaceKind;
147 team: string | null;
148 default_role: DocRole | null;
149 agent_mode: DocAgentMode;
150 editors_can_share: number;
151 is_default: number;
152 created_by: string;
153 created_at: string;
154 archived_at: string | null;
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.155 /** Set when the space was deleted (migration 0007; src/who.ts). Docs leaves deleted spaces out everywhere. */
156 deleted_at: string | null;
157 deleted_by: string | null;
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.158};
159
160type PageRow = {
161 id: string;
162 workspace_id: string;
163 space_id: string;
164 parent_id: string | null;
165 position: number;
166 title: string;
167 icon: string | null;
168 cover: string | null;
169 markdown: string;
170 created_by: string;
171 created_at: string;
172 updated_by: string | null;
173 updated_at: string;
174 archived_at: string | null;
175 archived_by: string | null;
176};
177
178type SuggestionRow = {
179 id: string;
180 page_id: string;
181 author: string;
182 asked_by: string | null;
183 target: string;
184 before_markdown: string;
185 after_markdown: string;
186 note: string | null;
187 status: DocSuggestion["status"];
188 created_at: string;
189 decided_by: string | null;
190 decided_at: string | null;
191 marks_current?: number;
192};
193
194type ChangeRow = {
195 page_id: string;
196 repo: string;
197 repo_id: string;
198 commit_sha: string;
199 pull_number: number | null;
200 pull_title: string | null;
201 paths: string;
202 detected_at: string;
203 cleared_at: string | null;
204 cleared_by: string | null;
205};
206
207type VersionRow = { id: string; page_id: string; created_at: string; kind: DocVersion["kind"]; authors: string; note: string | null; markdown: string; state: ArrayBuffer | null };
208
209/** A passage as recall and search read it back (`passages`): its page's or file's title, and the page's space now. */
210type PassageRow = {
211 id: string;
212 page_id: string | null;
213 repo_file_id: string | null;
214 path: string | null;
215 heading: string | null;
216 text: string;
217 updated_at: string;
218 space_id: string;
219 title: string | null;
220 icon: string | null;
221 page_updated_at: string | null;
222};
223
224/** A space, with who is in it and the viewer's role. */
225type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
226
227/** The page columns lists read: everything but the Markdown. */
228const 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";
229
230const MAX_TITLE = 200;
231const MAX_MARKDOWN = 512 * 1024;
232const MAX_NOTE = 500;
233
234const now = () => new Date().toISOString();
235
236function isMember(viewer: Viewer, workspace: string): boolean {
237 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
238}
239
240function rulesOf(space: Space): SpaceRules {
241 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
242}
243
244function cleanTitle(title: unknown): string {
245 return String(title ?? "")
246 .replace(/\s+/g, " ")
247 .trim()
248 .slice(0, MAX_TITLE);
249}
250
251/** One emoji (or a few characters), or null. */
252function cleanIcon(icon: unknown): string | null {
253 const s = String(icon ?? "").trim();
254 if (!s) return null;
255 return [...s].slice(0, 4).join("");
256}
257
258function cleanCover(cover: unknown): string | null {
259 const s = String(cover ?? "").trim();
260 if (!s) return null;
261 if (/^gradient:\d{1,2}$/.test(s)) return s;
262 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
263 return null;
264}
265
266function cleanProjects(list: unknown): string[] {
267 if (!Array.isArray(list)) return [];
268 return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
269}
270
271function cleanTarget(target: unknown): DocEditTarget | null {
272 const t = target as DocEditTarget | null;
273 if (!t || typeof t !== "object") return null;
274 switch (t.kind) {
275 case "append":
276 case "document":
277 return { kind: t.kind };
278 case "section":
279 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
280 case "blocks":
281 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
282 default:
283 return null;
284 }
285}
286
287class Docs {
288 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
289 private readonly people = new Map<string, Promise<Map<string, Member>>>();
290 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
291 private readonly usernames = new Map<string, string>();
292 private readonly agents = new Map<string, WorkspaceAgent | null>();
293
294 constructor(
295 private readonly env: Env,
296 private readonly defer: (work: Promise<unknown>) => void = () => {},
297 ) {}
298
299 private get db() {
300 return this.env.DB;
301 }
302
303 // ── Who and where ───────────────────────────────────────────────────────
304
305 private workspace(slug: string): Promise<Workspace | null> {
306 const key = String(slug ?? "").toLowerCase();
307 let found = this.workspaces.get(key);
308 if (!found) {
309 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
310 this.workspaces.set(key, found);
311 }
312 return found;
313 }
314
315 /** The workspace acting for itself: how this service asks identity about its members. */
316 private actor(workspace: Workspace): User {
317 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
318 }
319
320 /** The workspace's people by username. */
321 private members(workspace: Workspace): Promise<Map<string, Member>> {
322 let found = this.people.get(workspace.id);
323 if (!found) {
324 found = identityClient(this.env.IDENTITY)
325 .listMembers(workspace.slug, this.actor(workspace))
326 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
327 .catch(() => new Map<string, Member>());
328 this.people.set(workspace.id, found);
329 }
330 return found;
331 }
332
333 /** Each member's teams (slugs, lowercased), by username. */
334 private teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
335 let found = this.teams.get(workspace.id);
336 if (!found) {
337 found = identityClient(this.env.IDENTITY)
338 .teamMemberships(this.actor(workspace), workspace.slug)
339 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
340 .catch(() => new Map<string, Set<string>>());
341 this.teams.set(workspace.id, found);
342 }
343 return found;
344 }
345
346 private async nameUsers(ids: string[]): Promise<void> {
347 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
348 if (!unnamed.length) return;
349 const named = await identityClient(this.env.IDENTITY)
350 .usernames(unnamed)
351 .catch(() => ({}) as Record<string, string>);
352 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
353 }
354
355 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
356 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
357 if (wanted.length) {
358 let found: WorkspaceAgent[] = [];
359 try {
360 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
361 } catch (error) {
362 console.error("docs could not resolve agents", error);
363 }
364 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
365 }
366 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
367 }
368
369 /** How member keys show. Teams show by slug. */
370 private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
371 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
372 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
373 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
374 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
375 const out = new Map<string, MemberProfile>();
376 for (const p of principals) {
377 if (p.kind === "user") {
378 const username = this.usernames.get(p.id) ?? null;
379 const person = username ? people.get(username.toLowerCase()) : undefined;
380 out.set(principalKey(p), {
381 ...p,
382 name: username ?? "ghost",
383 display_name: person?.name || username || "Former member",
384 avatar: person?.avatar ?? null,
385 role: null,
386 title: null,
387 avatar_seed: null,
388 });
389 } else {
390 const agent = agents.get(p.id) ?? null;
391 out.set(principalKey(p), {
392 ...p,
393 name: agent?.handle ?? p.id,
394 display_name: agent?.display_name ?? "Former agent",
395 avatar: agent?.avatar ?? null,
396 role: agent?.role ?? null,
397 title: agent?.title || null,
398 avatar_seed: agent?.avatar_seed ?? null,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.399 look: agent?.look ?? null,
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.400 });
401 }
402 }
403 // Anything else (system, ghost keys): a plain name.
404 for (const key of keys) {
405 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 });
406 }
407 return out;
408 }
409
410 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
411 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Docs.");
412 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Docs.");
413 const workspace = await this.workspace(slug);
414 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
415 }
416
417 /** A person as access sees them. */
418 private async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean, needTeams: boolean): Promise<Person> {
419 const teams = needTeams ? ((await this.teamsOf(workspace)).get(user.username.toLowerCase()) ?? new Set<string>()) : new Set<string>();
420 return { user_id: user.id, owner, teams };
421 }
422
423 private viewerOwner(viewer: User, slug: string): boolean {
424 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
425 }
426
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.427 /** Every space in the workspace (archived ones too when asked; never deleted ones), with members and projects. */
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.428 private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> {
429 const [spaces, members, projects] = await Promise.all([
430 this.db
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.431 .prepare(`SELECT * FROM spaces WHERE workspace_id = ? AND deleted_at IS NULL ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.432 .bind(workspace.id)
433 .all<SpaceRow>(),
434 this.db
435 .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 = ?")
436 .bind(workspace.id)
437 .all<{ space_id: string; principal: string; role: DocRole }>(),
438 this.db
439 .prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?")
440 .bind(workspace.id)
441 .all<{ space_id: string; repo: string }>(),
442 ]);
443 return spaces.results.map((row) => ({
444 row,
445 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
446 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
447 }));
448 }
449
450 /** Whether any space's access depends on teams. */
451 private needsTeams(spaces: Omit<Space, "role">[]): boolean {
452 return spaces.some((s) => s.row.kind === "team" || s.members.some((m) => m.principal.startsWith("team:")));
453 }
454
455 /** The spaces, each with the viewer's role (null: they can't read it). */
456 private async spacesFor(workspace: Workspace, viewer: User, archived = false): Promise<Space[]> {
457 const spaces = await this.allSpaces(workspace, archived);
458 const person = await this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug), this.needsTeams(spaces));
459 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) }));
460 }
461
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.462 /** Makes the workspace's General space, once; a deleted General (its row kept, `deleted_at` set) is not made again. */
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.463 private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> {
464 const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
465 if (found) return false;
466 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));
467 const id = newId("spc");
468 await this.db
469 .prepare(
470 "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, ?, ?)",
471 )
472 .bind(id, workspace.id, freeSlug("general", taken), principalKey({ kind: "user", id: viewer.id }), now())
473 .run();
474 return true;
475 }
476
477 private toSpace(space: Space, pageCount = 0): DocSpace {
478 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
479 return {
480 id: space.row.id,
481 workspace_id: space.row.workspace_id,
482 slug: space.row.slug,
483 name: space.row.name,
484 description: space.row.description,
485 icon: space.row.icon,
486 kind: space.row.kind,
487 team: space.row.team,
488 default_role: space.row.kind === "private" ? null : space.row.default_role,
489 agent_mode: space.row.agent_mode,
490 editors_can_share: !!space.row.editors_can_share,
491 is_default: !!space.row.is_default,
492 projects: space.projects,
493 created_by: created,
494 created_at: space.row.created_at,
495 archived_at: space.row.archived_at,
496 viewer_role: space.role ?? "view",
497 page_count: pageCount,
498 };
499 }
500
501 private ref(slug: string, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">): DocPageRef {
502 const s = pageSlug(row.title, row.id);
503 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}` };
504 }
505
506 /** Pages as the site shows them, with owners, projects and people resolved. */
507 private async toPages(workspace: Workspace, spaces: Map<string, SpaceRow>, rows: PageRow[]): Promise<DocPage[]> {
508 if (!rows.length) return [];
509 const ids = rows.map((r) => r.id);
510 const marks = ids.map(() => "?").join(",");
511 const [owners, projects, kids, stale] = await Promise.all([
512 this.db.prepare(`SELECT page_id, principal FROM page_owners WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; principal: string }>(),
513 this.db.prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; repo: string }>(),
514 this.db
515 .prepare(`SELECT DISTINCT parent_id FROM pages WHERE parent_id IN (${marks}) AND archived_at IS NULL`)
516 .bind(...ids)
517 .all<{ parent_id: string }>(),
518 this.staleIds(ids),
519 ]);
520 const keys = [...rows.flatMap((r) => [r.created_by, r.updated_by ?? r.created_by]), ...owners.results.map((o) => o.principal)];
521 const people = await this.profiles(workspace, keys);
522 const parents = new Set(kids.results.map((k) => k.parent_id));
523 return rows.map((row) => {
524 const space = spaces.get(row.space_id)!;
525 return {
526 ...this.ref(workspace.slug, space, row),
527 parent_id: row.parent_id,
528 position: row.position,
529 cover: row.cover,
530 created_by: people.get(row.created_by)!,
531 created_at: row.created_at,
532 updated_by: row.updated_by ? (people.get(row.updated_by) ?? null) : null,
533 updated_at: row.updated_at,
534 archived_at: row.archived_at,
535 has_children: parents.has(row.id),
536 projects: projects.results.filter((p) => p.page_id === row.id).map((p) => p.repo),
537 owners: owners.results.filter((o) => o.page_id === row.id).map((o) => people.get(o.principal)!),
538 excerpt: excerpt(row.markdown ?? ""),
539 stale: stale.has(row.id),
540 };
541 });
542 }
543
544 /** Of these pages, those possibly out of date. */
545 private async staleIds(ids: string[]): Promise<Set<string>> {
546 if (!ids.length) return new Set();
547 const found = new Set<string>();
548 for (let i = 0; i < ids.length; i += 90) {
549 const part = ids.slice(i, i + 90);
550 const rows = await this.db
551 .prepare(`SELECT DISTINCT page_id FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")})`)
552 .bind(...part)
553 .all<{ page_id: string }>();
554 for (const r of rows.results) found.add(r.page_id);
555 }
556 return found;
557 }
558
559 /** A page and its space, with the viewer's role; not found when they can't read it. */
560 private async pageFor(slug: string, pageId: string, viewer: Viewer, need: DocRole): Promise<Result<{ workspace: Workspace; page: PageRow; space: Space; spaces: Space[] }>> {
561 const found = await this.viewerWorkspace(slug, viewer);
562 if (!found.ok) return found;
563 const workspace = found.value;
564 const page = await this.db.prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ?").bind(String(pageId ?? ""), workspace.id).first<PageRow>();
565 if (!page) return fail("not_found", "No such page.");
566 const spaces = await this.spacesFor(workspace, viewer!, true);
567 const space = spaces.find((s) => s.row.id === page.space_id);
568 if (!space || !space.role) return fail("not_found", "No such page.");
569 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.");
570 return ok({ workspace, page, space, spaces });
571 }
572
573 private room(pageId: string) {
574 return this.env.PAGES.get(this.env.PAGES.idFromName(pageId));
575 }
576
577 private tell(pageId: string, event: DocsLiveEvent): void {
578 this.defer(
579 this.room(pageId)
580 .notice(event)
581 .catch((error: unknown) => console.error("docs could not tell page", pageId, error)),
582 );
583 }
584
585 private userKey(viewer: User): string {
586 return principalKey({ kind: "user", id: viewer.id });
587 }
588
589 // ── Sidebar and home ────────────────────────────────────────────────────
590
591 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<DocsSidebar>> {
592 const found = await this.viewerWorkspace(a.workspace, a.viewer);
593 if (!found.ok) return found;
594 const workspace = found.value;
595 const viewer = a.viewer!;
596 await this.ensureDefault(workspace, viewer);
597 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
598 const ids = spaces.map((s) => s.row.id);
599 const repos = await this.repoSpacesFor(workspace, viewer).catch((error: unknown) => {
600 console.error("docs could not list projects' docs", String(error));
601 return [] as DocRepoSpace[];
602 });
603 if (!ids.length) return ok({ spaces: [], favorites: [], recent: [], can_create_space: true, trash_count: 0, stale_count: 0, repos });
604 const marks = ids.map(() => "?").join(",");
605 const [pages, favorites, recent, trash, stale] = await Promise.all([
606 this.db
607 .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`)
608 .bind(...ids)
609 .all<Pick<PageRow, "id" | "space_id" | "parent_id" | "position" | "title" | "icon">>(),
610 this.db
611 .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`)
612 .bind(viewer.id, ...ids)
613 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
614 this.db
615 .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`)
616 .bind(viewer.id, ...ids)
617 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
618 this.db
619 .prepare(`SELECT COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL`)
620 .bind(...ids)
621 .first<{ n: number }>(),
622 this.db
623 .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`)
624 .bind(...ids)
625 .all<{ page_id: string }>(),
626 ]);
627 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
628 const ref = (r: Pick<PageRow, "id" | "space_id" | "title" | "icon">) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r);
629 const staleSet = new Set(stale.results.map((r) => r.page_id));
630 return ok({
631 spaces: spaces.map((s) => {
632 const mine = pages.results.filter((p) => p.space_id === s.row.id);
633 return {
634 ...this.toSpace(s, mine.length),
635 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) })),
636 };
637 }),
638 favorites: favorites.results.map(ref),
639 recent: recent.results.map(ref),
640 can_create_space: true,
641 trash_count: trash?.n ?? 0,
642 stale_count: staleSet.size,
643 repos,
644 });
645 }
646
647 // ── A project's docs ────────────────────────────────────────────────────
648
649 /** The repository docs shown in the workspace that the viewer can read, with the repositories as they are now. */
650 private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
651 const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results;
652 if (!rows.length || !this.env.REPOS) return [];
653 const readable = await reposClient(this.env.REPOS).readable(
654 rows.map((r) => r.repo_id),
655 viewer,
656 );
657 const byId = new Map(readable.map((r) => [r.id, r]));
658 return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! }));
659 }
660
661 private async toRepoSpaces(workspace: Workspace, viewer: User, found: { row: RepoSpaceRow; repo: Repo }[]): Promise<DocRepoSpace[]> {
662 if (!found.length) return [];
663 const ids = found.map((f) => f.row.id);
664 const [files, people] = await Promise.all([
665 this.db
666 .prepare(`SELECT space_id, path, title FROM repo_files WHERE space_id IN (${ids.map(() => "?").join(",")})`)
667 .bind(...ids)
668 .all<{ space_id: string; path: string; title: string }>(),
669 this.profiles(
670 workspace,
671 found.map((f) => f.row.added_by),
672 ),
673 ]);
674 const me = this.userKey(viewer);
675 const owner = this.viewerOwner(viewer, workspace.slug);
676 const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1);
677 return found.map(({ row, repo }) => ({
678 id: row.id,
679 repo: `${repo.namespace}/${repo.name}`,
680 default_branch: repo.defaultBranch,
681 commit: row.commit_sha,
682 indexed_at: row.indexed_at,
683 added_by: people.get(row.added_by)!,
684 files: files.results
685 .filter((f) => f.space_id === row.id)
686 .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path))
687 .map((f) => ({ path: f.path, title: f.title })),
688 can_remove: row.added_by === me || owner,
689 }));
690 }
691
692 private async repoSpacesFor(workspace: Workspace, viewer: User): Promise<DocRepoSpace[]> {
693 return this.toRepoSpaces(workspace, viewer, await this.readableRepoSpaces(workspace, viewer));
694 }
695
696 async addRepoSpace(a: { workspace: string; viewer: Viewer; repo: string }): Promise<Result<DocRepoSpace>> {
697 const found = await this.viewerWorkspace(a.workspace, a.viewer);
698 if (!found.ok) return found;
699 const workspace = found.value;
700 const viewer = a.viewer!;
701 if (!this.env.REPOS) return fail("conflict", "Projects' docs aren't available here.");
702 const ref = projectRef(String(a.repo ?? ""));
703 if (!ref) return fail("invalid", "Choose a repository: owner/name.");
704 const [namespace, name] = ref.split("/") as [string, string];
705 const repo = await reposClient(this.env.REPOS).get({ namespace, name }, viewer);
706 if (!repo.ok) return fail("not_found", "No such repository, or you can't read it.");
707 const id = newId("rds");
708 const row: RepoSpaceRow = {
709 id,
710 workspace_id: workspace.id,
711 repo_id: repo.value.id,
712 repo: `${repo.value.namespace}/${repo.value.name}`.toLowerCase(),
713 default_branch: repo.value.defaultBranch,
714 commit_sha: null,
715 indexed_at: null,
716 added_by: this.userKey(viewer),
717 added_at: now(),
718 };
719 const inserted = await this.db
720 .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")
721 .bind(row.id, row.workspace_id, row.repo_id, row.repo, row.default_branch, row.added_by, row.added_at)
722 .first<{ id: string }>();
723 if (!inserted) return fail("conflict", `${ref}'s docs are already in Docs.`);
724 try {
725 const read = await indexRepoSpace({ DB: this.db, REPOS: this.env.REPOS }, row);
726 this.defer(indexRepoFiles(this.env, row.id, read.changed, read.gone));
727 } catch (error) {
728 console.error("docs could not read a project's docs", row.repo, String(error));
729 }
730 const fresh = (await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ?").bind(id).first<RepoSpaceRow>()) ?? row;
731 const [space] = await this.toRepoSpaces(workspace, viewer, [{ row: fresh, repo: repo.value }]);
732 return ok(space!);
733 }
734
735 async removeRepoSpace(a: { workspace: string; viewer: Viewer; id: string }): Promise<Result<boolean>> {
736 const found = await this.viewerWorkspace(a.workspace, a.viewer);
737 if (!found.ok) return found;
738 const row = await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ? AND workspace_id = ?").bind(String(a.id ?? ""), found.value.id).first<RepoSpaceRow>();
739 if (!row) return fail("not_found", "No such project's docs.");
740 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.");
741 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)]);
742 this.defer(forgetDocs(this.env, { space_id: row.id }));
743 return ok(true);
744 }
745
746 async repoPage(a: { workspace: string; viewer: Viewer; repo: string; path: string }): Promise<Result<DocRepoPage>> {
747 const found = await this.viewerWorkspace(a.workspace, a.viewer);
748 if (!found.ok) return found;
749 const workspace = found.value;
750 const ref = projectRef(String(a.repo ?? ""));
751 if (!ref) return fail("not_found", "No such file.");
752 const spaces = await this.readableRepoSpaces(workspace, a.viewer!);
753 const match = spaces.find((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === ref || s.row.repo === ref);
754 if (!match) return fail("not_found", "No such file.");
755 const path = String(a.path ?? "").replace(/^\/+/, "");
756 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 }>();
757 if (!file) return fail("not_found", "No such file.");
758 const [space] = await this.toRepoSpaces(workspace, a.viewer!, [match]);
759 const repoPath = `${match.repo.namespace}/${match.repo.name}`;
760 const encoded = file.path.split("/").map(encodeURIComponent).join("/");
761 return ok({
762 space: space!,
763 file: {
764 path: file.path,
765 title: file.title,
766 markdown: file.markdown,
767 href: `/${workspace.slug}/-/artifacts/repo/${repoPath}/${encoded}`,
768 code_href: `/${repoPath}/blob/${encodeURIComponent(match.repo.defaultBranch)}/${encoded}`,
769 },
770 });
771 }
772
773 async home(a: { workspace: string; viewer: Viewer; project: string | null }): Promise<Result<DocsHome>> {
774 const found = await this.viewerWorkspace(a.workspace, a.viewer);
775 if (!found.ok) return found;
776 const workspace = found.value;
777 const viewer = a.viewer!;
778 await this.ensureDefault(workspace, viewer);
779 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
780 const project = a.project ? projectRef(a.project) : null;
781 const ids = spaces.map((s) => s.row.id);
782 const allProjects = new Set(spaces.flatMap((s) => s.projects));
783 if (!ids.length) return ok({ recent: [], mine: [], stale: [], spaces: [], projects: [...allProjects].sort(), project });
784 const marks = ids.map(() => "?").join(",");
785 const [recentRows, mineRows, pageProjects, counts, staleRows] = await Promise.all([
786 this.db
787 .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`)
788 .bind(...ids)
789 .all<PageRow>(),
790 this.db
791 .prepare(
792 `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`,
793 )
794 .bind(...ids, this.userKey(viewer), this.userKey(viewer))
795 .all<PageRow>(),
796 this.db
797 .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})`)
798 .bind(...ids)
799 .all<{ page_id: string; repo: string }>(),
800 this.db
801 .prepare(`SELECT space_id, COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL GROUP BY space_id`)
802 .bind(...ids)
803 .all<{ space_id: string; n: number }>(),
804 this.staleRows(ids, null, 24),
805 ]);
806 for (const p of pageProjects.results) allProjects.add(p.repo);
807 const projectsOf = (pageId: string) => pageProjects.results.filter((p) => p.page_id === pageId).map((p) => p.repo);
808 const spaceProjects = new Map(spaces.map((s) => [s.row.id, s.projects]));
809 const keep = (r: PageRow) => inProject(project, projectsOf(r.id), spaceProjects.get(r.space_id) ?? []);
810 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
811 const [recent, mine, stale] = await Promise.all([
812 this.toPages(workspace, bySpace, recentRows.results.filter(keep).slice(0, 12)),
813 this.toPages(workspace, bySpace, mineRows.results.filter(keep).slice(0, 8)),
814 this.toPages(workspace, bySpace, staleRows.filter(keep).slice(0, 8)),
815 ]);
816 const count = new Map(counts.results.map((c) => [c.space_id, c.n]));
817 return ok({
818 recent,
819 mine,
820 stale,
821 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)),
822 projects: [...allProjects].sort(),
823 project,
824 });
825 }
826
827 // ── Spaces ──────────────────────────────────────────────────────────────
828
829 private async spaceMembers(workspace: Workspace, space: Space): Promise<DocSpaceMember[]> {
830 const people = await this.profiles(
831 workspace,
832 space.members.filter((m) => !m.principal.startsWith("team:")).map((m) => m.principal),
833 );
834 return space.members
835 .map((m): DocSpaceMember => {
836 const key = memberKey(m.principal);
837 if (key?.kind === "team") return { key: m.principal, kind: "team", name: key.id, display_name: `@${workspace.slug}/${key.id}`, avatar: null, role: m.role };
838 const p = people.get(m.principal)!;
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.839 return { key: m.principal, kind: p.kind, name: p.name, display_name: p.display_name, avatar: p.avatar, avatar_seed: p.avatar_seed ?? null, look: p.look ?? null, role: m.role };
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.840 })
841 .sort((x, y) => RANK[y.role] - RANK[x.role] || x.display_name.localeCompare(y.display_name));
842 }
843
844 async space(a: { workspace: string; space: string; viewer: Viewer }): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>> {
845 const found = await this.viewerWorkspace(a.workspace, a.viewer);
846 if (!found.ok) return found;
847 const workspace = found.value;
848 const spaces = await this.spacesFor(workspace, a.viewer!, true);
849 const space = spaces.find((s) => s.row.slug === String(a.space ?? "").toLowerCase() || s.row.id === a.space);
850 if (!space?.role) return fail("not_found", "No such space.");
851 const rows = (
852 await this.db
853 .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")
854 .bind(space.row.id)
855 .all<PageRow>()
856 ).results;
857 const [members, pages] = await Promise.all([this.spaceMembers(workspace, space), this.toPages(workspace, new Map([[space.row.id, space.row]]), rows)]);
858 return ok({ space: this.toSpace(space, rows.length), members, pages });
859 }
860
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.861 /** Every space the viewer can open, archived ones included (the Spaces page lists those apart), General first. */
862 async spaces(a: { workspace: string; viewer: Viewer }): Promise<Result<DocSpace[]>> {
863 const found = await this.viewerWorkspace(a.workspace, a.viewer);
864 if (!found.ok) return found;
865 const spaces = await this.spacesFor(found.value, a.viewer!, true);
866 return ok(spaces.filter((s) => s.role).map((s) => this.toSpace(s)));
867 }
868
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.869 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
870 const found = await this.viewerWorkspace(a.workspace, a.viewer);
871 if (!found.ok) return found;
872 const workspace = found.value;
873 const viewer = a.viewer!;
874 const input = a.input ?? ({} as NewDocSpace);
875 const name = cleanTitle(input.name).slice(0, 80);
876 if (!name) return fail("invalid", "Name the space.");
877 const kind: DocSpaceKind = input.kind === "team" || input.kind === "private" ? input.kind : "workspace";
878 const team = kind === "team" ? String(input.team ?? "").trim().toLowerCase() : null;
879 if (kind === "team") {
880 if (!team) return fail("invalid", "Choose the team the space is for.");
881 const teams = await this.teamsOf(workspace);
882 const known = [...teams.values()].some((set) => set.has(team));
883 if (!known) return fail("invalid", "No such team in this workspace.");
884 }
885 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));
886 let slug: string;
887 if (input.slug) {
888 const wanted = validSpaceSlug(input.slug);
889 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
890 if (taken.has(wanted)) return fail("conflict", "Another space has that address.");
891 slug = wanted;
892 } else slug = freeSlug(name, taken);
893 const defaultRole: DocRole | null = kind === "private" ? null : isRole(input.default_role) ? input.default_role : "edit";
894 const id = newId("spc");
895 const at = now();
896 const me = this.userKey(viewer);
897 const projects = cleanProjects(input.projects);
898 await this.db.batch([
899 this.db
900 .prepare(
901 "INSERT INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
902 )
903 .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),
904 // Whoever makes a space manages it.
905 this.db.prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?)").bind(id, me, me, at),
906 ...projects.map((repo) => this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(id, repo)),
907 ]);
908 const spaces = await this.spacesFor(workspace, viewer);
909 const space = spaces.find((s) => s.row.id === id)!;
910 return ok(this.toSpace(space));
911 }
912
913 async updateSpace(a: { workspace: string; space_id: string; viewer: Viewer; change: DocSpaceChange }): Promise<Result<DocSpace>> {
914 const found = await this.viewerWorkspace(a.workspace, a.viewer);
915 if (!found.ok) return found;
916 const workspace = found.value;
917 const spaces = await this.spacesFor(workspace, a.viewer!, true);
918 const space = spaces.find((s) => s.row.id === a.space_id);
919 if (!space?.role) return fail("not_found", "No such space.");
920 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change a space.");
921 const c = a.change ?? {};
922 const sets: string[] = [];
923 const values: unknown[] = [];
924 const set = (column: string, value: unknown) => {
925 sets.push(`${column} = ?`);
926 values.push(value);
927 };
928 if (c.name !== undefined) {
929 const name = cleanTitle(c.name).slice(0, 80);
930 if (!name) return fail("invalid", "Name the space.");
931 set("name", name);
932 }
933 if (c.description !== undefined) set("description", String(c.description ?? "").trim().slice(0, 300) || null);
934 if (c.icon !== undefined) set("icon", cleanIcon(c.icon));
935 if (c.slug !== undefined && c.slug !== space.row.slug) {
936 const wanted = validSpaceSlug(String(c.slug ?? ""));
937 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
938 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();
939 if (clash) return fail("conflict", "Another space has that address.");
940 set("slug", wanted);
941 }
942 if (c.kind !== undefined && c.kind !== space.row.kind) {
943 if (space.row.is_default) return fail("invalid", "The General space is always the whole workspace's.");
944 if (c.kind !== "workspace" && c.kind !== "team" && c.kind !== "private") return fail("invalid", "Choose who the space is for.");
945 set("kind", c.kind);
946 if (c.kind === "private") set("default_role", null);
947 else if (!space.row.default_role) set("default_role", "edit");
948 if (c.kind === "private" && !space.members.some((m) => m.role === "manage")) {
949 // Someone must still manage it: whoever made it private.
950 await this.db
951 .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'")
952 .bind(space.row.id, this.userKey(a.viewer!), this.userKey(a.viewer!), now())
953 .run();
954 }
955 }
956 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
957 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
958 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
959 if (c.editors_can_share !== undefined) set("editors_can_share", c.editors_can_share ? 1 : 0);
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.960 // Any space archives, General too: a space is not required to exist.
961 if (c.archived !== undefined) set("archived_at", c.archived ? now() : null);
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.962 const statements: D1PreparedStatement[] = [];
963 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
964 if (c.projects !== undefined) {
965 statements.push(this.db.prepare("DELETE FROM space_projects WHERE space_id = ?").bind(space.row.id));
966 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));
967 }
968 if (statements.length) await this.db.batch(statements);
969 this.workspaces.clear();
970 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
971 return ok(this.toSpace(after));
972 }
973
974 async setSpaceMember(a: { workspace: string; space_id: string; viewer: Viewer; member: string; role: DocRole | null }): Promise<Result<DocSpaceMember[]>> {
975 const found = await this.viewerWorkspace(a.workspace, a.viewer);
976 if (!found.ok) return found;
977 const workspace = found.value;
978 const spaces = await this.spacesFor(workspace, a.viewer!, true);
979 const space = spaces.find((s) => s.row.id === a.space_id);
980 if (!space?.role) return fail("not_found", "No such space.");
981 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
982 const key = memberKey(String(a.member ?? ""));
983 if (!key) return fail("invalid", "Choose a person, agent or team.");
984 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
985 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
986 // They must belong to the workspace.
987 if (key.kind === "user") {
988 await this.nameUsers([key.id]);
989 const username = this.usernames.get(key.id);
990 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
991 } else if (key.kind === "agent") {
992 const agent = (await this.agentsById([key.id])).get(key.id);
993 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("invalid", "No such agent in this workspace.");
994 } else {
995 const teams = await this.teamsOf(workspace);
996 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
997 }
998 const principal = key.kind === "team" ? `team:${key.id.toLowerCase()}` : `${key.kind}:${key.id}`;
999 if (leavesNoManager(space.row.kind, space.members, principal, role)) return fail("invalid", "Someone must keep full access to a private space.");
1000 if (role) {
1001 await this.db
1002 .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")
1003 .bind(space.row.id, principal, role, this.userKey(a.viewer!), now())
1004 .run();
1005 } else {
1006 await this.db.prepare("DELETE FROM space_members WHERE space_id = ? AND principal = ?").bind(space.row.id, principal).run();
1007 }
1008 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
1009 return ok(await this.spaceMembers(workspace, after));
1010 }
1011
1012 // ── Pages ───────────────────────────────────────────────────────────────
1013
1014 async page(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPageDetail>> {
1015 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1016 if (!found.ok) return found;
1017 const { workspace, page, space, spaces } = found.value;
1018 const viewer = a.viewer!;
1019 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
1020 const readable = new Set(spaces.filter((s) => s.role).map((s) => s.row.id));
1021 const [tree, backlinks, children, favorite, viewed, suggestions, cited, staleness] = await Promise.all([
1022 this.db.prepare("SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>(),
1023 this.db
1024 .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")
1025 .bind(page.id)
1026 .all<PageRow>(),
1027 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>(),
1028 this.db.prepare("SELECT 1 AS yes FROM favorites WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ yes: number }>(),
1029 this.db.prepare("SELECT viewed_at FROM page_views WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ viewed_at: string }>(),
1030 this.openSuggestions(workspace, page.id),
1031 this.citationsOf([page.id]),
1032 this.stalenessFor(page.id, viewer),
1033 ]);
1034 this.defer(
1035 this.db
1036 .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")
1037 .bind(page.id, viewer.id, now())
1038 .run(),
1039 );
1040 const [detail] = await this.toPages(workspace, bySpace, [page]);
1041 return ok({
1042 page: detail!,
1043 space: this.toSpace(space),
1044 breadcrumbs: ancestors(tree.results, page.id).map((r) => this.ref(workspace.slug, space.row, r)),
1045 markdown: page.markdown,
1046 role: space.role!,
1047 backlinks: backlinks.results.filter((r) => readable.has(r.space_id)).map((r) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r)),
1048 children: children.results.map((r) => this.ref(workspace.slug, space.row, r)),
1049 favorite: !!favorite,
1050 last_viewed_at: viewed?.viewed_at ?? null,
1051 suggestions,
1052 citations: cited.get(page.id) ?? [],
1053 describes: (cited.get(page.id) ?? []).filter((c) => c.source === "header").map((c) => ({ repo: c.repo, path: c.path })),
1054 staleness,
1055 });
1056 }
1057
1058 // ── Citations and staleness ─────────────────────────────────────────────
1059
1060 /** Each page's citations, by page. */
1061 private async citationsOf(pageIds: string[]): Promise<Map<string, DocCitation[]>> {
1062 const out = new Map<string, DocCitation[]>();
1063 if (!pageIds.length) return out;
1064 const rows = await this.db
1065 .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`)
1066 .bind(...pageIds)
1067 .all<Omit<DocCitation, "label"> & { page_id: string; label: string }>();
1068 for (const r of rows.results) {
1069 const list = out.get(r.page_id) ?? [];
1070 list.push({ repo: r.repo, path: r.path, kind: r.kind, label: r.label || null, ref: r.ref, source: r.source });
1071 out.set(r.page_id, list);
1072 }
1073 return out;
1074 }
1075
1076 /** Of these repositories (`owner/name`), those the viewer can read. */
1077 private async readableRepos(viewer: User, repos: string[]): Promise<Set<string>> {
1078 const out = new Set<string>();
1079 if (!this.env.REPOS) return out;
1080 const client = reposClient(this.env.REPOS);
1081 await Promise.all(
1082 [...new Set(repos)].slice(0, 25).map(async (repo) => {
1083 const [namespace, name] = repo.split("/") as [string, string];
1084 const found = await client.get({ namespace, name }, viewer).catch(() => null);
1085 if (found?.ok) out.add(repo);
1086 }),
1087 );
1088 return out;
1089 }
1090
1091 /** Open changes on these pages, newest first. */
1092 private async openChanges(pageIds: string[]): Promise<ChangeRow[]> {
1093 if (!pageIds.length) return [];
1094 const out: ChangeRow[] = [];
1095 for (let i = 0; i < pageIds.length; i += 90) {
1096 const part = pageIds.slice(i, i + 90);
1097 const rows = await this.db
1098 .prepare(`SELECT * FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")}) ORDER BY detected_at DESC`)
1099 .bind(...part)
1100 .all<ChangeRow>();
1101 out.push(...rows.results);
1102 }
1103 return out.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1104 }
1105
1106 /** A change as a reader sees it: named only when they can read its repository. */
1107 private toChange(row: ChangeRow, readable: Set<string>): DocStaleChange {
1108 if (!readable.has(row.repo)) return { visible: false, repo: null, commit: null, pull: null, paths: [], at: row.detected_at };
1109 let paths: string[] = [];
1110 try {
1111 paths = JSON.parse(row.paths) as string[];
1112 } catch {
1113 paths = [];
1114 }
1115 return {
1116 visible: true,
1117 repo: row.repo,
1118 commit: row.commit_sha,
1119 pull: row.pull_number ? { number: row.pull_number, title: row.pull_title } : null,
1120 paths,
1121 at: row.detected_at,
1122 };
1123 }
1124
1125 /** Why a page is possibly out of date, as this viewer may see it; null when it isn't. */
1126 private async stalenessFor(pageId: string, viewer: User): Promise<DocStaleness | null> {
1127 const rows = await this.openChanges([pageId]);
1128 if (!rows.length) return null;
1129 const readable = await this.readableRepos(
1130 viewer,
1131 rows.map((r) => r.repo),
1132 );
1133 const changes = rows.slice(0, 20).map((r) => this.toChange(r, readable));
1134 return { since: rows[rows.length - 1]!.detected_at, changes };
1135 }
1136
1137 /** Live pages in these spaces that are possibly out of date, most recently flagged first; `repo` narrows to changes there. */
1138 private async staleRows(spaceIds: string[], repo: string | null, limit: number): Promise<PageRow[]> {
1139 if (!spaceIds.length) return [];
1140 const marks = spaceIds.map(() => "?").join(",");
1141 return (
1142 await this.db
1143 .prepare(
1144 `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
1145 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
1146 WHERE p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY c.flagged DESC LIMIT ?`,
1147 )
1148 .bind(...(repo ? [repo] : []), ...spaceIds, limit)
1149 .all<PageRow>()
1150 ).results;
1151 }
1152
1153 async stalePages(a: { workspace: string; viewer: Viewer; repo?: string | null }): Promise<Result<DocPage[]>> {
1154 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1155 if (!found.ok) return found;
1156 const workspace = found.value;
1157 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1158 const rows = await this.staleRows(
1159 spaces.map((s) => s.row.id),
1160 a.repo ? projectRef(a.repo) : null,
1161 200,
1162 );
1163 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1164 }
1165
1166 /** Clears every open change on a page. */
1167 private async clearStale(pageId: string, by: string): Promise<boolean> {
1168 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();
1169 const cleared = (done.meta?.changes ?? 0) > 0;
1170 if (cleared) this.tell(pageId, { type: "page.staleness" });
1171 return cleared;
1172 }
1173
1174 async markCurrent(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1175 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1176 if (!found.ok) return found;
1177 await this.clearStale(found.value.page.id, this.userKey(a.viewer!));
1178 return ok(true);
1179 }
1180
1181 async stalePagesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; repo?: string | null; since?: string | null; audience: DocAudience | null }): Promise<Result<DocStalePage[]>> {
1182 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1183 if (!found.ok) return found;
1184 const { workspace, spaces } = found.value;
1185 const repo = a.repo ? projectRef(a.repo) : null;
1186 if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
1187 const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
1188 const rows = await this.staleRows(
1189 spaces.map((s) => s.row.id),
1190 repo,
1191 200,
1192 );
1193 const [changes, cited, pages] = await Promise.all([
1194 this.openChanges(rows.map((r) => r.id)),
1195 this.citationsOf(rows.map((r) => r.id)),
1196 this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows),
1197 ]);
1198 // The agent learns only of code its person can read.
1199 const readable = await this.readableRepos(a.viewer!, [...changes.map((c) => c.repo), ...[...cited.values()].flat().map((c) => c.repo)]);
1200 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1201 const out: DocStalePage[] = [];
1202 for (const row of rows) {
1203 const mine = changes.filter((c) => c.page_id === row.id && readable.has(c.repo) && (!repo || c.repo === repo));
1204 if (!mine.length) continue;
1205 const newest = mine[0]!.detected_at;
1206 if (since && newest < since) continue;
1207 const space = bySpace.get(row.space_id)!;
1208 const page = pages.find((p) => p.id === row.id)!;
1209 out.push({
1210 page: { ...this.ref(workspace.slug, space.row, row), updated_at: row.updated_at },
1211 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1212 can: space.can,
1213 owners: page.owners,
1214 citations: (cited.get(row.id) ?? []).filter((c) => readable.has(c.repo)),
1215 changes: mine.slice(0, 20).map((c) => this.toChange(c, readable)),
1216 since: mine[mine.length - 1]!.detected_at,
1217 });
1218 if (out.length >= 50) break;
1219 }
1220 return ok(out);
1221 }
1222
1223 /** Where a new page in `space` from `input` starts: its Markdown and title. */
1224 private async startingPoint(workspace: Workspace, input: NewDocPage): Promise<{ markdown: string; title: string; icon: string | null }> {
1225 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
1226 let title = cleanTitle(input.title);
1227 let icon = cleanIcon(input.icon);
1228 if (input.template_id) {
1229 const template = builtinTemplate(input.template_id) ?? (await this.savedTemplate(workspace, input.template_id));
1230 if (template) {
1231 markdown = template.markdown;
1232 if (!title) title = template.name;
1233 if (!icon) icon = template.icon;
1234 }
1235 }
1236 return { markdown, title, icon };
1237 }
1238
1239 private async savedTemplate(workspace: Workspace, id: string): Promise<DocTemplate | null> {
1240 const row = await this.db
1241 .prepare("SELECT * FROM templates WHERE id = ? AND workspace_id = ?")
1242 .bind(id, workspace.id)
1243 .first<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>();
1244 if (!row) return null;
1245 return { id: row.id, name: row.name, description: row.description, icon: row.icon, markdown: row.markdown, builtin: false, created_by: parsePrincipalKey(row.created_by) };
1246 }
1247
1248 /** Inserts a page row and fills its room. */
1249 private async insertPage(
1250 workspace: Workspace,
1251 space: SpaceRow,
1252 author: string,
1253 input: { parent_id: string | null; title: string; icon: string | null; markdown: string; state?: Uint8Array | null; projects?: string[]; owners?: string[]; position?: number },
1254 ): Promise<PageRow> {
1255 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;
1256 const id = newId("pag");
1257 const at = now();
1258 const position = input.position ?? lastPosition(rows, input.parent_id);
1259 const row: PageRow = {
1260 id,
1261 workspace_id: workspace.id,
1262 space_id: space.id,
1263 parent_id: input.parent_id,
1264 position,
1265 title: input.title,
1266 icon: input.icon,
1267 cover: null,
1268 markdown: input.markdown,
1269 created_by: author,
1270 created_at: at,
1271 updated_by: author,
1272 updated_at: at,
1273 archived_at: null,
1274 archived_by: null,
1275 };
1276 await this.db.batch([
1277 this.db
1278 .prepare(
1279 "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, ?, ?, ?, ?, ?)",
1280 )
1281 .bind(id, workspace.id, space.id, input.parent_id, position, input.title, input.icon, input.markdown, author, at, author, at),
1282 this.db.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(id, input.title, searchText(input.markdown)),
1283 this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, author),
1284 ...(input.owners ?? []).filter((o) => o !== author).map((o) => this.db.prepare("INSERT OR IGNORE INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, o)),
1285 ...(input.projects ?? []).map((repo) => this.db.prepare("INSERT OR IGNORE INTO page_projects (page_id, repo) VALUES (?, ?)").bind(id, repo)),
1286 this.db
1287 .prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)")
1288 .bind(newId("ver"), id, at, JSON.stringify([author]), input.markdown),
1289 ]);
1290 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
1291 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.created", this.eventData(workspace, space, row), author));
1292 // Its passages, for agents' recall (src/indexer.ts); later edits are indexed by its room.
1293 if (input.markdown.trim()) this.defer(indexPage(this.env, id));
1294 return row;
1295 }
1296
1297 /** What every `doc.page.*` event says of a page. */
1298 private eventData(workspace: Workspace, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">) {
1299 return { workspace: workspace.slug, workspaceId: workspace.id, pageId: row.id, spaceId: space.id, title: row.title, path: this.ref(workspace.slug, space, row).path };
1300 }
1301
1302 async createPage(a: { workspace: string; viewer: Viewer; input: NewDocPage }): Promise<Result<DocPage>> {
1303 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1304 if (!found.ok) return found;
1305 const workspace = found.value;
1306 const input = a.input ?? ({} as NewDocPage);
1307 const spaces = await this.spacesFor(workspace, a.viewer!);
1308 const space = spaces.find((s) => s.row.id === input.space_id) ?? (input.space_id ? null : spaces.find((s) => s.row.is_default));
1309 if (!space?.role) return fail("not_found", "No such space.");
1310 if (!atLeast(space.role, "edit")) return fail("forbidden", "You can read this space but not add pages to it.");
1311 const parent = input.parent_id
1312 ? 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 }>()
1313 : null;
1314 if (input.parent_id && !parent) return fail("not_found", "No such parent page.");
1315 const start = await this.startingPoint(workspace, input);
1316 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1317 parent_id: parent?.id ?? null,
1318 title: start.title,
1319 icon: start.icon,
1320 markdown: start.markdown,
1321 projects: cleanProjects(input.projects),
1322 });
1323 const [page] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1324 return ok(page!);
1325 }
1326
1327 async updatePage(a: { workspace: string; page_id: string; viewer: Viewer; change: DocPageChange }): Promise<Result<DocPage>> {
1328 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1329 if (!found.ok) return found;
1330 const { workspace, page, space } = found.value;
1331 const c = a.change ?? {};
1332 const statements: D1PreparedStatement[] = [];
1333 const sets: string[] = [];
1334 const values: unknown[] = [];
1335 if (c.title !== undefined) {
1336 sets.push("title = ?");
1337 values.push(cleanTitle(c.title));
1338 statements.push(this.db.prepare("UPDATE pages_fts SET title = ? WHERE page_id = ?").bind(cleanTitle(c.title), page.id));
1339 }
1340 if (c.icon !== undefined) {
1341 sets.push("icon = ?");
1342 values.push(cleanIcon(c.icon));
1343 }
1344 if (c.cover !== undefined) {
1345 sets.push("cover = ?");
1346 values.push(cleanCover(c.cover));
1347 }
1348 if (sets.length) {
1349 sets.push("updated_at = ?", "updated_by = ?");
1350 values.push(now(), this.userKey(a.viewer!));
1351 statements.unshift(this.db.prepare(`UPDATE pages SET ${sets.join(", ")} WHERE id = ?`).bind(...values, page.id));
1352 }
1353 if (c.projects !== undefined) {
1354 statements.push(this.db.prepare("DELETE FROM page_projects WHERE page_id = ?").bind(page.id));
1355 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO page_projects (page_id, repo) VALUES (?, ?)").bind(page.id, repo));
1356 }
1357 if (c.describes !== undefined) {
1358 statements.push(this.db.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'header'").bind(page.id));
1359 for (const d of cleanDescribes(c.describes)) {
1360 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));
1361 }
1362 }
1363 if (c.owners !== undefined) {
1364 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);
1365 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
1366 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
1367 }
1368 if (statements.length) await this.db.batch(statements);
1369 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1370 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1371 this.tell(page.id, { type: "page.updated", page: detail! });
1372 // The title is part of what each passage is embedded with.
1373 if (c.title !== undefined && cleanTitle(c.title) !== page.title) this.defer(indexPage(this.env, page.id));
1374 return ok(detail!);
1375 }
1376
1377 async movePage(a: { workspace: string; page_id: string; viewer: Viewer; move: DocMove }): Promise<Result<DocPage>> {
1378 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1379 if (!found.ok) return found;
1380 const { workspace, page, spaces } = found.value;
1381 const move = a.move ?? ({ parent_id: null } as DocMove);
1382 const target = move.space_id ? spaces.find((s) => s.row.id === move.space_id) : spaces.find((s) => s.row.id === page.space_id);
1383 if (!target?.role || target.row.archived_at) return fail("not_found", "No such space.");
1384 if (!atLeast(target.role, "edit")) return fail("forbidden", "You can't add pages to that space.");
1385 const [sourceRows, targetRows] = await Promise.all([
1386 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>(),
1387 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(target.row.id).all<PageRow>(),
1388 ]);
1389 const parent = move.parent_id ?? null;
1390 if (parent && !targetRows.results.some((r) => r.id === parent)) return fail("not_found", "No such parent page in that space.");
1391 if (wouldCycle(sourceRows.results, page.id, parent)) return fail("invalid", "A page can't go inside itself.");
1392 const placed = placeBefore(targetRows.results, page.id, parent, move.before_id ?? null);
1393 const statements: D1PreparedStatement[] = [this.db.prepare("UPDATE pages SET parent_id = ?, position = ? WHERE id = ?").bind(parent, placed.position, page.id)];
1394 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE pages SET position = ? WHERE id = ?").bind(position, id));
1395 let moved: string[] = [];
1396 if (target.row.id !== page.space_id) {
1397 // The page and everything under it move to the other space.
1398 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1399 moved = descendants(all, page.id);
1400 for (const id of moved) statements.push(this.db.prepare("UPDATE pages SET space_id = ? WHERE id = ?").bind(target.row.id, id));
1401 }
1402 await this.db.batch(statements);
1403 // Their passages are filed under the new space (no new embeddings: they only moved).
1404 if (moved.length) this.defer(this.reindexPages(moved));
1405 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1406 const [detail] = await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), [after!]);
1407 this.tell(page.id, { type: "page.updated", page: detail! });
1408 return ok(detail!);
1409 }
1410
1411 async duplicatePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1412 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1413 if (!found.ok) return found;
1414 const { workspace, page, space } = found.value;
1415 const room = this.room(page.id);
1416 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1417 const [state, read] = await Promise.all([room.state(), room.read()]);
1418 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;
1419 const next = rows.filter((r) => r.parent_id === page.parent_id).sort((x, y) => x.position - y.position).find((r) => r.position > page.position);
1420 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1421 parent_id: page.parent_id,
1422 title: `${page.title || "Untitled"} (copy)`.slice(0, MAX_TITLE),
1423 icon: page.icon,
1424 markdown: read.markdown,
1425 state,
1426 position: next ? (page.position + next.position) / 2 : page.position + 1024,
1427 });
1428 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1429 return ok(detail!);
1430 }
1431
1432 async archivePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1433 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1434 if (!found.ok) return found;
1435 const { workspace, page, space } = found.value;
1436 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;
1437 const ids = descendants(all, page.id);
1438 const at = now();
1439 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)));
1440 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1441 // Out of agents' recall while in the trash; restoring indexes them again.
1442 this.defer(forgetDocs(this.env, { page_ids: ids }));
1443 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!)));
1444 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1445 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1446 return ok(detail!);
1447 }
1448
1449 async restorePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1450 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1451 if (!found.ok) return found;
1452 const { workspace, page, space } = found.value;
1453 if (!page.archived_at) return fail("invalid", "That page isn't in the trash.");
1454 // It comes back with what was trashed with it; under its parent if that is still there.
1455 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;
1456 const parent = page.parent_id ? all.find((r) => r.id === page.parent_id) : null;
1457 const parentGone = !!page.parent_id && (!parent || !!parent.archived_at);
1458 const ids = descendants(all, page.id).filter((id) => all.find((r) => r.id === id)?.archived_at === page.archived_at);
1459 const statements = ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = NULL, archived_by = NULL WHERE id = ?").bind(id));
1460 if (parentGone) statements.push(this.db.prepare("UPDATE pages SET parent_id = NULL WHERE id = ?").bind(page.id));
1461 await this.db.batch(statements);
1462 this.defer(this.reindexPages(ids));
1463 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1464 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1465 return ok(detail!);
1466 }
1467
1468 async deletePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1469 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "manage");
1470 if (!found.ok) return found;
1471 const { page } = found.value;
1472 if (!page.archived_at) return fail("invalid", "Move the page to the trash first.");
1473 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1474 const ids = descendants(all, page.id);
1475 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)]));
1476 this.defer(forgetDocs(this.env, { page_ids: ids }));
1477 return ok(true);
1478 }
1479
1480 async trash(a: { workspace: string; viewer: Viewer }): Promise<Result<DocPage[]>> {
1481 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1482 if (!found.ok) return found;
1483 const workspace = found.value;
1484 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => atLeast(s.role, "edit"));
1485 if (!spaces.length) return ok([]);
1486 const marks = spaces.map(() => "?").join(",");
1487 const rows = (
1488 await this.db
1489 .prepare(`SELECT ${PAGE_COLUMNS} FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 200`)
1490 .bind(...spaces.map((s) => s.row.id))
1491 .all<PageRow>()
1492 ).results;
1493 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1494 }
1495
1496 async favorite(a: { workspace: string; page_id: string; viewer: Viewer; on: boolean }): Promise<Result<boolean>> {
1497 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1498 if (!found.ok) return found;
1499 if (a.on) {
1500 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();
1501 } else {
1502 await this.db.prepare("DELETE FROM favorites WHERE user_id = ? AND page_id = ?").bind(a.viewer!.id, a.page_id).run();
1503 }
1504 return ok(!!a.on);
1505 }
1506
1507 // ── Search ──────────────────────────────────────────────────────────────
1508
1509 /** Full text over `spaceIds`, best first. */
1510 private async searchIn(workspace: Workspace, spaces: Space[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1511 const q = ftsQuery(query.query);
1512 const ids = searchSpaces(
1513 spaces.map((s) => s.row.id),
1514 query.space_id ?? null,
1515 );
1516 if (!ids.length) return [];
1517 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1518 const marks = ids.map(() => "?").join(",");
1519 const project = query.project ? projectRef(query.project) : null;
1520 type Hit = { id: string; space_id: string; title: string; icon: string | null; updated_at: string; snippet: string };
1521 let rows: Hit[];
1522 if (q) {
1523 rows = (
1524 await this.db
1525 .prepare(
1526 `SELECT p.id, p.space_id, p.title, p.icon, p.updated_at, snippet(pages_fts, 2, '[[', ']]', '…', 16) AS snippet
1527 FROM pages_fts JOIN pages p ON p.id = pages_fts.page_id
1528 WHERE pages_fts MATCH ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL
1529 ORDER BY bm25(pages_fts, 0, 8.0, 1.0) LIMIT ?`,
1530 )
1531 .bind(q, ...ids, limit * 3)
1532 .all<Hit>()
1533 ).results;
1534 } else {
1535 rows = (
1536 await this.db
1537 .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 ?`)
1538 .bind(...ids, limit * 3)
1539 .all<Hit>()
1540 ).results;
1541 }
1542 const pageIds = rows.map((r) => r.id);
1543 const projects = pageIds.length
1544 ? (
1545 await this.db
1546 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1547 .bind(...pageIds)
1548 .all<{ page_id: string; repo: string }>()
1549 ).results
1550 : [];
1551 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1552 return rows
1553 .map((r) => {
1554 const space = bySpace.get(r.space_id)!;
1555 const own = projects.filter((p) => p.page_id === r.id).map((p) => p.repo);
1556 return { r, space, own };
1557 })
1558 .filter(({ space, own }) => inProject(project, own, space.projects))
1559 .slice(0, limit)
1560 .map(({ r, space, own }) => ({
1561 ...this.ref(workspace.slug, space.row, r),
1562 space_name: space.row.name,
1563 snippet: q ? r.snippet : excerpt(r.snippet, 140),
1564 updated_at: r.updated_at,
1565 projects: [...new Set([...own, ...space.projects])],
1566 }));
1567 }
1568
1569 async search(a: { workspace: string; viewer: Viewer; query: DocSearchQuery }): Promise<Result<DocSearchHit[]>> {
1570 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1571 if (!found.ok) return found;
1572 const workspace = found.value;
1573 const query = a.query ?? { query: "" };
1574 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1575 const hybrid = query.mode === "hybrid" && !!ftsQuery(query.query);
1576 // A project's docs, when the search isn't narrowed to one of the workspace's spaces.
1577 const repoSpaces = query.space_id || !ftsQuery(query.query)
1578 ? []
1579 : await this.repoSpacesMatching(workspace, a.viewer!, query).catch((error: unknown) => {
1580 console.error("docs could not list projects' docs for search", String(error));
1581 return [] as { row: RepoSpaceRow; repo: Repo }[];
1582 });
1583 const [pages, files, meaning] = await Promise.all([
1584 this.searchIn(workspace, spaces, query),
1585 this.searchRepoFiles(workspace, repoSpaces, query).catch((error: unknown) => {
1586 console.error("docs could not search projects' docs", String(error));
1587 return [] as DocSearchHit[];
1588 }),
1589 hybrid
1590 ? this.meaningHits(workspace, spaces, repoSpaces, query).catch((error: unknown) => {
1591 console.error("docs could not search by meaning", String(error));
1592 return null;
1593 })
1594 : Promise.resolve(null),
1595 ]);
1596 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1597 // Words only: pages first, then files, as many as asked for.
1598 if (!hybrid) return ok([...pages, ...files].slice(0, limit));
1599 return ok(await this.fuseHits(workspace, spaces, repoSpaces, [...pages, ...files], meaning ?? [], query, limit));
1600 }
1601
1602 /** The projects' docs the viewer can read, narrowed to the search's project. */
1603 private async repoSpacesMatching(workspace: Workspace, viewer: User, query: DocSearchQuery): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
1604 const spaces = await this.readableRepoSpaces(workspace, viewer);
1605 const project = query.project ? projectRef(query.project) : null;
1606 return project ? spaces.filter((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === project) : spaces;
1607 }
1608
1609 /** A search hit's key: a page's id, or `repo:<space>:<path>` for a project's docs file. */
1610 private hitKey(row: Pick<PassageRow, "page_id" | "space_id" | "path">): string {
1611 return row.page_id ?? `repo:${row.space_id}:${row.path}`;
1612 }
1613
1614 /** By meaning: each page's or file's closest passage above the floor, closest first. */
1615 private async meaningHits(workspace: Workspace, spaces: Space[], repoSpaces: { row: RepoSpaceRow }[], query: DocSearchQuery): Promise<{ key: string; row: PassageRow; score: number }[]> {
1616 const allowed = [...searchSpaces(spaces.map((s) => s.row.id), query.space_id ?? null), ...repoSpaces.map((r) => r.row.id)];
1617 if (!allowed.length) return [];
1618 const vector = await this.queryVector(query.query);
1619 if (!vector) return [];
1620 const matches = (await this.meaningMatches(workspace.id, allowed, vector)).filter((m) => m.score >= MEANING_FLOOR);
1621 const rows = await this.passages(workspace.id, matches.map((m) => m.id));
1622 const may = new Set(allowed);
1623 const best = new Map<string, { key: string; row: PassageRow; score: number }>();
1624 for (const m of matches) {
1625 const row = rows.get(m.id);
1626 if (!row || !may.has(row.space_id)) continue;
1627 const key = this.hitKey(row);
1628 if ((best.get(key)?.score ?? -1) < m.score) best.set(key, { key, row, score: m.score });
1629 }
1630 return [...best.values()].sort((a, b) => b.score - a.score);
1631 }
1632
1633 /**
1634 * Hybrid search's answer: word hits and meaning hits fused by rank, each
1635 * with the passage that matched and its heading. Word hits get theirs
1636 * from the passages' full text; meaning-only hits show their passage.
1637 */
1638 private async fuseHits(
1639 workspace: Workspace,
1640 spaces: Space[],
1641 repoSpaces: { row: RepoSpaceRow; repo: Repo }[],
1642 words: DocSearchHit[],
1643 meaning: { key: string; row: PassageRow; score: number }[],
1644 query: DocSearchQuery,
1645 limit: number,
1646 ): Promise<DocSearchHit[]> {
1647 const order = fuseRanks(
1648 words.map((h) => h.id),
1649 meaning.map((m) => m.key),
1650 );
1651 const byWords = new Map(words.map((h) => [h.id, h]));
1652 const byMeaning = new Map(meaning.map((m) => [m.key, m]));
1653 // The passage each word hit matched in, for its heading and a closer snippet.
1654 const docIds = new Map(words.map((h) => [h.repo_file ? repoFileId(h.space_id, h.repo_file.path) : h.id, h.id]));
1655 const passageOf = new Map<string, { heading: string; snippet: string }>();
1656 const q = ftsQuery(query.query);
1657 if (q && docIds.size) {
1658 // The best-ranked 90, within D1's bound parameters.
1659 const ids = [...docIds.keys()].slice(0, 90);
1660 const found = await this.db
1661 .prepare(
1662 `SELECT doc_id, heading, snippet(doc_chunks_fts, 4, '[[', ']]', '…', 16) AS snippet FROM doc_chunks_fts
1663 WHERE doc_chunks_fts MATCH ? AND doc_id IN (${ids.map(() => "?").join(",")}) ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 200`,
1664 )
1665 .bind(q, ...ids)
1666 .all<{ doc_id: string; heading: string; snippet: string }>()
1667 .catch(() => ({ results: [] as { doc_id: string; heading: string; snippet: string }[] }));
1668 for (const r of found.results) {
1669 const key = docIds.get(r.doc_id);
1670 if (key && !passageOf.has(key)) passageOf.set(key, { heading: r.heading, snippet: r.snippet });
1671 }
1672 }
1673 // Meaning-only pages: their projects, for the project filter and the hit.
1674 const onlyMeaning = meaning.filter((m) => !byWords.has(m.key) && m.row.page_id);
1675 const pageIds = onlyMeaning.map((m) => m.row.page_id!);
1676 const projects = pageIds.length
1677 ? (
1678 await this.db
1679 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1680 .bind(...pageIds)
1681 .all<{ page_id: string; repo: string }>()
1682 ).results
1683 : [];
1684 const project = query.project ? projectRef(query.project) : null;
1685 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1686 const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
1687 const out: DocSearchHit[] = [];
1688 for (const key of order) {
1689 if (out.length >= limit) break;
1690 const w = byWords.get(key);
1691 const m = byMeaning.get(key);
1692 if (w) {
1693 const passage = passageOf.get(key);
1694 out.push({
1695 ...w,
1696 snippet: passage?.snippet || w.snippet,
1697 heading: (passage ? passage.heading || null : null) ?? m?.row.heading ?? null,
1698 matched: m ? "both" : "words",
1699 });
1700 continue;
1701 }
1702 if (!m) continue;
1703 const row = m.row;
1704 const snippet = excerpt(row.text, 200);
1705 if (row.page_id) {
1706 const space = bySpace.get(row.space_id);
1707 if (!space) continue;
1708 const own = projects.filter((p) => p.page_id === row.page_id).map((p) => p.repo);
1709 if (!inProject(project, own, space.projects)) continue;
1710 out.push({
1711 ...this.ref(workspace.slug, space.row, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
1712 space_name: space.row.name,
1713 snippet,
1714 updated_at: row.page_updated_at ?? row.updated_at,
1715 projects: [...new Set([...own, ...space.projects])],
1716 heading: row.heading,
1717 matched: "meaning",
1718 });
1719 } else {
1720 const r = byRepo.get(row.space_id);
1721 if (!r || !row.path) continue;
1722 const repo = `${r.repo.namespace}/${r.repo.name}`;
1723 out.push({
1724 id: key,
1725 space_id: row.space_id,
1726 space_slug: "repo",
1727 title: row.title ?? row.path,
1728 icon: null,
1729 slug: row.path,
1730 path: `/${workspace.slug}/-/docs/repo/${repo}/${row.path.split("/").map(encodeURIComponent).join("/")}`,
1731 space_name: repo,
1732 snippet,
1733 updated_at: r.row.indexed_at ?? r.row.added_at,
1734 projects: [repo.toLowerCase()],
1735 repo_file: { repo, path: row.path },
1736 heading: row.heading,
1737 matched: "meaning",
1738 });
1739 }
1740 }
1741 return out;
1742 }
1743
1744 /** Full text over these projects' docs (the viewer's to read). */
1745 private async searchRepoFiles(workspace: Workspace, spaces: { row: RepoSpaceRow; repo: Repo }[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1746 const q = ftsQuery(query.query);
1747 if (!q) return [];
1748 if (!spaces.length) return [];
1749 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1750 const rows = (
1751 await this.db
1752 .prepare(
1753 `SELECT space_id, path, title, snippet(repo_files_fts, 3, '[[', ']]', '…', 16) AS snippet FROM repo_files_fts
1754 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 ?`,
1755 )
1756 .bind(q, ...spaces.map((s) => s.row.id), limit)
1757 .all<{ space_id: string; path: string; title: string; snippet: string }>()
1758 ).results;
1759 const byId = new Map(spaces.map((s) => [s.row.id, s]));
1760 return rows.map((r) => {
1761 const s = byId.get(r.space_id)!;
1762 const repo = `${s.repo.namespace}/${s.repo.name}`;
1763 return {
1764 id: `repo:${r.space_id}:${r.path}`,
1765 space_id: r.space_id,
1766 space_slug: "repo",
1767 title: r.title,
1768 icon: null,
1769 slug: r.path,
1770 path: `/${workspace.slug}/-/docs/repo/${repo}/${r.path.split("/").map(encodeURIComponent).join("/")}`,
1771 space_name: repo,
1772 snippet: r.snippet,
1773 updated_at: s.row.indexed_at ?? s.row.added_at,
1774 projects: [repo.toLowerCase()],
1775 repo_file: { repo, path: r.path },
1776 };
1777 });
1778 }
1779
1780 // ── History ─────────────────────────────────────────────────────────────
1781
1782 private async toVersions(workspace: Workspace, rows: Omit<VersionRow, "markdown" | "state">[]): Promise<DocVersion[]> {
1783 const authors = rows.map((r) => {
1784 try {
1785 return JSON.parse(r.authors) as string[];
1786 } catch {
1787 return [];
1788 }
1789 });
1790 const people = await this.profiles(workspace, authors.flat());
1791 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) }));
1792 }
1793
1794 async versions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocVersion[]>> {
1795 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1796 if (!found.ok) return found;
1797 // Whatever is unsaved goes in first, so the newest version is now.
1798 await this.room(a.page_id)
1799 .flush()
1800 .catch(() => undefined);
1801 const rows = (
1802 await this.db
1803 .prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE page_id = ? ORDER BY created_at DESC LIMIT 200")
1804 .bind(a.page_id)
1805 .all<Omit<VersionRow, "markdown" | "state">>()
1806 ).results;
1807 return ok(await this.toVersions(found.value.workspace, rows));
1808 }
1809
1810 async version(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersionDetail>> {
1811 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1812 if (!found.ok) return found;
1813 const row = await this.db
1814 .prepare("SELECT id, page_id, created_at, kind, authors, note, markdown FROM page_versions WHERE id = ? AND page_id = ?")
1815 .bind(a.version_id, a.page_id)
1816 .first<Omit<VersionRow, "state">>();
1817 if (!row) return fail("not_found", "No such version.");
1818 const before = await this.db
1819 .prepare("SELECT markdown FROM page_versions WHERE page_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1")
1820 .bind(a.page_id, row.created_at)
1821 .first<{ markdown: string }>();
1822 const [version] = await this.toVersions(found.value.workspace, [row]);
1823 return ok({ ...version!, markdown: row.markdown, diff: diffLines(before?.markdown ?? "", row.markdown) });
1824 }
1825
1826 async restoreVersion(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersion>> {
1827 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1828 if (!found.ok) return found;
1829 const { workspace, page } = found.value;
1830 const row = await this.db.prepare("SELECT * FROM page_versions WHERE id = ? AND page_id = ?").bind(a.version_id, page.id).first<VersionRow>();
1831 if (!row) return fail("not_found", "No such version.");
1832 const room = this.room(page.id);
1833 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1834 const when = new Date(row.created_at).toISOString().slice(0, 16).replace("T", " ");
1835 const origin: Origin = { key: this.userKey(a.viewer!), kind: "restore", note: `Restored the version of ${when} UTC` };
1836 const versionId = await room.restore({ state: row.state ? new Uint8Array(row.state) : null, markdown: row.markdown }, origin);
1837 const created = versionId
1838 ? 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">>()
1839 : null;
1840 if (!created) return fail("conflict", "The page could not be restored. Try again.");
1841 const [version] = await this.toVersions(workspace, [created]);
1842 this.tell(page.id, { type: "version.created", version: version! });
1843 return ok(version!);
1844 }
1845
1846 // ── Templates and export ────────────────────────────────────────────────
1847
1848 async templates(a: { workspace: string; viewer: Viewer }): Promise<Result<DocTemplate[]>> {
1849 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1850 if (!found.ok) return found;
1851 const rows = (
1852 await this.db
1853 .prepare("SELECT * FROM templates WHERE workspace_id = ? ORDER BY name COLLATE NOCASE")
1854 .bind(found.value.id)
1855 .all<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>()
1856 ).results;
1857 return ok([
1858 ...BUILTIN_TEMPLATES,
1859 ...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) })),
1860 ]);
1861 }
1862
1863 async saveTemplate(a: { workspace: string; viewer: Viewer; input: { page_id: string; name: string; description?: string | null } }): Promise<Result<DocTemplate>> {
1864 const found = await this.pageFor(a.workspace, a.input?.page_id, a.viewer, "view");
1865 if (!found.ok) return found;
1866 const { workspace, page } = found.value;
1867 const name = cleanTitle(a.input.name || page.title).slice(0, 80);
1868 if (!name) return fail("invalid", "Name the template.");
1869 const room = this.room(page.id);
1870 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1871 const { markdown } = await room.read();
1872 const id = newId("tpl");
1873 const description = String(a.input.description ?? "").trim().slice(0, 200);
1874 await this.db
1875 .prepare("INSERT INTO templates (id, workspace_id, name, description, icon, markdown, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1876 .bind(id, workspace.id, name, description, page.icon ?? "📄", markdown, this.userKey(a.viewer!), now())
1877 .run();
1878 return ok({ id, name, description, icon: page.icon ?? "📄", markdown, builtin: false, created_by: { kind: "user", id: a.viewer!.id } });
1879 }
1880
1881 async deleteTemplate(a: { workspace: string; template_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1882 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1883 if (!found.ok) return found;
1884 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 }>();
1885 if (!row) return fail("not_found", "No such template.");
1886 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.");
1887 await this.db.prepare("DELETE FROM templates WHERE id = ?").bind(a.template_id).run();
1888 return ok(true);
1889 }
1890
1891 async exportPage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<{ filename: string; markdown: string }>> {
1892 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1893 if (!found.ok) return found;
1894 const { workspace, page } = found.value;
1895 const room = this.room(page.id);
1896 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1897 const { markdown } = await room.read();
1898 const title = page.title || "Untitled";
1899 return ok({ filename: `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "page"}.md`, markdown: `# ${title}\n\n${markdown}` });
1900 }
1901
1902 async exportSpace(a: { workspace: string; space_id: string; viewer: Viewer }): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>> {
1903 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1904 if (!found.ok) return found;
1905 const spaces = await this.spacesFor(found.value, a.viewer!);
1906 const space = spaces.find((s) => s.row.id === a.space_id);
1907 if (!space?.role) return fail("not_found", "No such space.");
1908 const rows = (
1909 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>()
1910 ).results;
1911 const paths = exportPaths(rows.map((r) => ({ ...r, title: r.title || "Untitled" })));
1912 return ok({
1913 name: space.row.slug,
1914 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)),
1915 });
1916 }
1917
1918 // ── Suggestions ─────────────────────────────────────────────────────────
1919
1920 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<DocSuggestion[]> {
1921 const people = await this.profiles(
1922 workspace,
1923 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1924 );
1925 return rows.map((r, i) => ({
1926 id: r.id,
1927 page_id: r.page_id,
1928 author: people.get(r.author)!,
1929 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1930 target: JSON.parse(r.target) as DocEditTarget,
1931 before_markdown: r.before_markdown,
1932 after_markdown: r.after_markdown,
1933 note: r.note,
1934 status: r.status,
1935 created_at: r.created_at,
1936 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1937 decided_at: r.decided_at,
1938 block_ids: blocks[i] ?? [],
1939 }));
1940 }
1941
1942 /** A page's open suggestions, with the blocks each covers now; ones whose target is gone become stale. */
1943 private async openSuggestions(workspace: Workspace, pageId: string): Promise<DocSuggestion[]> {
1944 const rows = (await this.db.prepare("SELECT * FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(pageId).all<SuggestionRow>()).results;
1945 if (!rows.length) return [];
1946 let blocks: (string[] | null)[] = rows.map(() => []);
1947 try {
1948 blocks = await this.room(pageId).targets(rows.map((r) => JSON.parse(r.target) as DocEditTarget));
1949 } catch (error) {
1950 console.error("docs could not place suggestions", error);
1951 }
1952 const gone = rows.filter((_, i) => blocks[i] === null);
1953 if (gone.length) {
1954 await this.db.batch(gone.map((r) => this.db.prepare("UPDATE suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1955 }
1956 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1957 return this.toSuggestions(
1958 workspace,
1959 live.map((x) => x.r),
1960 live.map((x) => x.b!),
1961 );
1962 }
1963
1964 async suggestions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1965 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1966 if (!found.ok) return found;
1967 return ok(await this.openSuggestions(found.value.workspace, a.page_id));
1968 }
1969
1970 async decideSuggestion(a: { workspace: string; suggestion_id: string; viewer: Viewer; decision: "accept" | "reject" }): Promise<Result<DocSuggestion>> {
1971 const row = await this.db.prepare("SELECT * FROM suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1972 if (!row) return fail("not_found", "No such suggestion.");
1973 const found = await this.pageFor(a.workspace, row.page_id, a.viewer, "edit");
1974 if (!found.ok) return found.error.code === "forbidden" ? fail("forbidden", "Only people who can edit the page can accept or reject a suggestion.") : found;
1975 const { workspace, page } = found.value;
1976 if (row.status !== "open") return fail("conflict", "That suggestion was already decided.");
1977 const me = this.userKey(a.viewer!);
1978 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1979 if (a.decision === "accept") {
1980 const people = await this.profiles(workspace, [row.author, me]);
1981 const room = this.room(page.id);
1982 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1983 const result = await room.edit(JSON.parse(row.target) as DocEditTarget, row.after_markdown, {
1984 key: me,
1985 kind: "suggestion",
1986 note: `Suggested by @${people.get(row.author)!.name}, accepted by @${people.get(me)!.name}`,
1987 authors: [row.author, me],
1988 });
1989 if (!result.applied) status = "stale";
1990 else if (row.marks_current) await this.clearStale(page.id, row.author);
1991 }
1992 await this.db.prepare("UPDATE suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, me, now(), row.id).run();
1993 const [after] = await this.toSuggestions(workspace, [{ ...row, status, decided_by: me, decided_at: now() }]);
1994 this.tell(page.id, { type: "suggestion.updated", suggestion: after! });
1995 if (status === "stale") return fail("conflict", "The part of the page this suggestion changes is gone, so it can't be applied.");
1996 return ok(after!);
1997 }
1998
1999 async acceptAll(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
2000 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
2001 if (!found.ok) return found;
2002 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;
2003 const out: DocSuggestion[] = [];
2004 for (const r of rows) {
2005 const decided = await this.decideSuggestion({ workspace: a.workspace, suggestion_id: r.id, viewer: a.viewer, decision: "accept" });
2006 if (decided.ok) out.push(decided.value);
2007 }
2008 return ok(out);
2009 }
2010
2011 // ── Comments ────────────────────────────────────────────────────────────
2012
2013 async thread(a: { workspace: string; page_id: string; viewer: Viewer; action: DocThreadAction }): Promise<Result<unknown>> {
2014 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "comment");
2015 if (!found.ok) return found;
2016 const { workspace, page, space } = found.value;
2017 const me = this.userKey(a.viewer!);
2018 const room = this.room(page.id);
2019 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2020 const result = (await room.thread(me, space.role!, a.action)) as ThreadResult;
2021 if (!result.ok) return fail(result.code, result.message);
2022 if (result.mentions?.length) this.defer(this.notifyMentioned(workspace, page, space.row, me, result.mentions, result.text ?? "", result.thread_id ?? null));
2023 return ok(result.value);
2024 }
2025
2026 /** People mentioned in a comment hear of it, if they can read the page. */
2027 private async notifyMentioned(workspace: Workspace, page: PageRow, space: SpaceRow, author: string, mentions: string[], text: string, threadId: string | null): Promise<void> {
2028 if (!this.env.NOTIFY) return;
2029 // Comment mentions name people by username (`user:<username>`).
2030 const me = author.startsWith("user:") ? (this.usernames.get(author.slice(5)) ?? "").toLowerCase() : "";
2031 const names = [...new Set(mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()))].filter((n) => n && n !== me);
2032 if (!names.length) return;
2033 const [spaces, people] = await Promise.all([this.allSpaces(workspace), this.profiles(workspace, [author])]);
2034 const s = spaces.find((x) => x.row.id === space.id);
2035 if (!s) return;
2036 await this.nameUsers(s.members.filter((m) => m.principal.startsWith("user:")).map((m) => m.principal.slice(5)));
2037 const teams = this.needsTeams([s]) ? await this.teamsOf(workspace) : new Map<string, Set<string>>();
2038 const members = await this.members(workspace);
2039 const who = people.get(author)!;
2040 const href = `${this.ref(workspace.slug, space, page).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
2041 const notify = notifyClient(this.env.NOTIFY);
2042 await Promise.all(
2043 names.map(async (username) => {
2044 const member = members.get(username);
2045 if (!member) return;
2046 // Their id is not needed: access by username's teams and role is enough to decide.
2047 const person: Person = { user_id: `name:${username}`, owner: member.role === "owner", teams: teams.get(username) ?? new Set() };
2048 const listed = s.members.some((m) => m.principal.startsWith("user:") && this.usernames.get(m.principal.slice(5))?.toLowerCase() === username);
2049 if (!listed && !atLeast(roleOf(rulesOf({ ...s, role: null }), person), "view")) return;
2050 await notify
2051 .notify(
2052 { username },
2053 {
2054 id: `doc-comment:${page.id}:${threadId ?? ""}:${username}:${Date.now()}`,
2055 kind: "mention",
2056 workspace: workspace.slug,
2057 title: `${who.display_name} mentioned you on ${page.title || "Untitled"}`,
2058 body: text.slice(0, 140),
2059 href,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.2060 actor: { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null, look: who.look ?? null },
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.2061 created_at: now(),
2062 },
2063 )
2064 .catch(() => undefined);
2065 }),
2066 );
2067 }
2068
2069 private async resolveThreads(workspace: Workspace, threads: Awaited<ReturnType<PageRoom["threads"]>>): Promise<DocThread[]> {
2070 const people = await this.profiles(
2071 workspace,
2072 threads.flatMap((t) => t.comments.map((c) => c.author)),
2073 );
2074 return threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }));
2075 }
2076
2077 async threads(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocThread[]>> {
2078 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
2079 if (!found.ok) return found;
2080 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
2081 }
2082
2083 // ── Agents ──────────────────────────────────────────────────────────────
2084
2085 /**
2086 * What an agent may read and do for `viewer`, space by space: the
2087 * viewer's own access, narrowed to what every person in the audience can
2088 * read. Never wider than the viewer.
2089 */
2090 private async agentSpaces(slug: string, agentId: string, viewer: Viewer, audience: DocAudience | null): Promise<Result<{ workspace: Workspace; agent: WorkspaceAgent; spaces: (Space & { can: DocAgentAbilities })[] }>> {
2091 const found = await this.viewerWorkspace(slug, viewer);
2092 if (!found.ok) return found;
2093 const workspace = found.value;
2094 const agent = (await this.agentsById([String(agentId ?? "")])).get(String(agentId ?? ""));
2095 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
2096 const spaces = await this.spacesFor(workspace, viewer!);
2097 let readable = (s: Space) => !!s.role;
2098 if (audience?.kind === "workspace") {
2099 readable = (s) => !!s.role && readableByWorkspace(rulesOf(s));
2100 } else if (audience?.kind === "people" && Array.isArray(audience.user_ids) && audience.user_ids.length) {
2101 const ids = [...new Set(audience.user_ids.map(String))].slice(0, 200);
2102 await this.nameUsers(ids);
2103 const [members, teams] = await Promise.all([this.members(workspace), this.needsTeams(spaces) ? this.teamsOf(workspace) : Promise.resolve(new Map<string, Set<string>>())]);
2104 const people: Person[] = ids.map((id) => {
2105 const username = this.usernames.get(id)?.toLowerCase() ?? "";
2106 const member = members.get(username);
2107 // Someone who is not a member reads nothing: a person with no teams who owns nothing.
2108 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: teams.get(username) ?? new Set() };
2109 });
2110 readable = (s) => !!s.role && readableByAll(rulesOf(s), people);
2111 }
2112 return ok({
2113 workspace,
2114 agent,
2115 spaces: spaces.filter(readable).map((s) => ({ ...s, can: agentAbilities(s.role, s.row.agent_mode) })),
2116 });
2117 }
2118
2119 async spacesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; audience: DocAudience | null }) {
2120 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2121 if (!found.ok) return found;
2122 return ok(
2123 found.value.spaces.map((s) => ({
2124 id: s.row.id,
2125 slug: s.row.slug,
2126 name: s.row.name,
2127 description: s.row.description,
2128 kind: s.row.kind,
2129 agent_mode: s.row.agent_mode,
2130 projects: s.projects,
2131 can: s.can,
2132 })),
2133 );
2134 }
2135
2136 /** A page an agent may read for the viewer and audience, or not found. */
2137 private async agentPage(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience?: DocAudience | null }) {
2138 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2139 if (!found.ok) return found;
2140 const page = await this.db
2141 .prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ? AND archived_at IS NULL")
2142 .bind(String(a.page_id ?? ""), found.value.workspace.id)
2143 .first<PageRow>();
2144 const space = page ? found.value.spaces.find((s) => s.row.id === page.space_id) : null;
2145 if (!page || !space) return fail("not_found", "No such page.");
2146 return ok({ ...found.value, page, space });
2147 }
2148
2149 async pageMarkdown(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocAgentPage>> {
2150 const found = await this.agentPage(a);
2151 if (!found.ok) return found;
2152 const { workspace, page, space } = found.value;
2153 const room = this.room(page.id);
2154 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2155 const read = await room.read();
2156 return ok({
2157 page: { ...this.ref(workspace.slug, space.row, page), updated_at: page.updated_at },
2158 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
2159 markdown: read.markdown,
2160 blocks: read.blocks,
2161 can: space.can,
2162 });
2163 }
2164
2165 async searchForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; query: DocSearchQuery; audience: DocAudience | null }): Promise<Result<DocSearchHit[]>> {
2166 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2167 if (!found.ok) return found;
2168 return ok(await this.searchIn(found.value.workspace, found.value.spaces, { ...(a.query ?? { query: "" }), limit: Math.min(Number(a.query?.limit) || 10, 20) }));
2169 }
2170
2171 // ── Recall: the semantic index ──────────────────────────────────────────
2172
2173 /** Pages indexed again one after another (moved, restored). */
2174 private async reindexPages(ids: string[]): Promise<void> {
2175 for (const id of ids.slice(0, 500)) await indexPage(this.env, id);
2176 }
2177
2178 /** A query's embedding, kept a minute; null without an embedder or when it fails (then words only). */
2179 private async queryVector(query: string): Promise<number[] | null> {
2180 const { embedder } = adapters(this.env);
2181 const key = queryKey(query);
2182 if (!embedder || !key) return null;
2183 const cached = queryVectors.get(key);
2184 if (cached) return cached;
2185 try {
2186 const [vector] = await embedder.embed([key]);
2187 if (vector) queryVectors.set(key, vector);
2188 return vector ?? null;
2189 } catch (error) {
2190 console.error("docs could not embed a query; matching words instead", String(error));
2191 return null;
2192 }
2193 }
2194
2195 /** The passages nearest a vector, only from `allowed` spaces (by the index's filter, or after). */
2196 private async meaningMatches(workspaceId: string, allowed: string[], vector: number[]): Promise<{ id: string; score: number }[]> {
2197 const { store } = adapters(this.env);
2198 const plan = vectorQueryPlan(workspaceId, allowed);
2199 if (!store || !plan) return [];
2200 try {
2201 return await store.query(vector, { topK: plan.topK, filter: plan.filter });
2202 } catch (error) {
2203 console.error("docs semantic query failed; matching words instead", String(error));
2204 return [];
2205 }
2206 }
2207
2208 /** Passages by their words (any of them), best first, from `allowed` spaces. */
2209 private async wordMatches(workspaceId: string, allowed: string[], fts: string, limit: number): Promise<string[]> {
2210 if (!allowed.length) return [];
2211 const named = allowed.length <= 80;
2212 const rows = await this.db
2213 .prepare(
2214 `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id
2215 WHERE doc_chunks_fts MATCH ? AND c.workspace_id = ? ${named ? `AND doc_chunks_fts.space_id IN (${allowed.map(() => "?").join(",")})` : ""}
2216 ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT ?`,
2217 )
2218 .bind(fts, workspaceId, ...(named ? allowed : []), limit)
2219 .all<{ id: string }>()
2220 .catch((error: unknown) => {
2221 console.error("docs word recall failed", String(error));
2222 return { results: [] as { id: string }[] };
2223 });
2224 return rows.results.map((r) => r.id);
2225 }
2226
2227 /**
2228 * Passages by id as they read now, with their page or file: only those
2229 * whose page is still out of the trash and whose file is still there.
2230 * A page's passages count as in the page's space now, whatever the index says.
2231 */
2232 private async passages(workspaceId: string, ids: string[]): Promise<Map<string, PassageRow>> {
2233 const out = new Map<string, PassageRow>();
2234 const unique = [...new Set(ids)];
2235 for (let i = 0; i < unique.length; i += 90) {
2236 const part = unique.slice(i, i + 90);
2237 const rows = await this.db
2238 .prepare(
2239 `SELECT c.id, c.page_id, c.repo_file_id, c.path, c.heading, c.text, c.updated_at,
2240 CASE WHEN c.page_id IS NOT NULL THEN p.space_id ELSE c.space_id END AS space_id,
2241 COALESCE(p.title, f.title) AS title, p.icon AS icon, p.updated_at AS page_updated_at
2242 FROM doc_chunks c
2243 LEFT JOIN pages p ON p.id = c.page_id
2244 LEFT JOIN repo_files f ON f.space_id = c.space_id AND f.path = c.path
2245 WHERE c.workspace_id = ? AND c.id IN (${part.map(() => "?").join(",")})
2246 AND ((c.page_id IS NOT NULL AND p.id IS NOT NULL AND p.archived_at IS NULL) OR (c.repo_file_id IS NOT NULL AND f.path IS NOT NULL))`,
2247 )
2248 .bind(workspaceId, ...part)
2249 .all<PassageRow>();
2250 for (const r of rows.results) out.set(r.id, r);
2251 }
2252 return out;
2253 }
2254
2255 /**
2256 * Projects' docs an agent may recall from: those the viewer can read
2257 * and, with an audience, everyone in it. A workspace-wide audience, or
2258 * one too large to ask about person by person, gets public repositories
2259 * only. Never wider than the viewer.
2260 */
2261 private async repoSpacesForAudience(workspace: Workspace, viewer: User, audience: DocAudience | null): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2262 const mine = await this.readableRepoSpaces(workspace, viewer);
2263 if (!mine.length || !audience) return mine;
2264 const publicOnly = () => mine.filter((s) => !s.repo.isPrivate);
2265 if (audience.kind === "workspace") return publicOnly();
2266 if (audience.kind !== "people" || !Array.isArray(audience.user_ids)) return mine;
2267 const others = [...new Set(audience.user_ids.map(String))].filter((id) => id !== viewer.id);
2268 if (!others.length) return mine;
2269 if (others.length > 20 || !this.env.REPOS) return publicOnly();
2270 await this.nameUsers(others);
2271 const members = await this.members(workspace);
2272 let keep = new Set(mine.map((s) => s.row.repo_id));
2273 for (const id of others) {
2274 const username = this.usernames.get(id)?.toLowerCase();
2275 if (!username) {
2276 const open = new Set(publicOnly().map((s) => s.row.repo_id));
2277 keep = new Set([...keep].filter((r) => open.has(r)));
2278 continue;
2279 }
2280 const member = members.get(username);
2281 // As repos sees them: their membership here and no direct grants, so never wider than they are.
2282 const person: User = { id, username, verified: true, workspaces: member ? [{ slug: workspace.slug, role: member.role }] : [] };
2283 const readable = await reposClient(this.env.REPOS)
2284 .readable([...keep], person)
2285 .catch(() => [] as Repo[]);
2286 keep = new Set(readable.map((r) => r.id));
2287 if (!keep.size) break;
2288 }
2289 return mine.filter((s) => keep.has(s.row.repo_id));
2290 }
2291
2292 /**
2293 * What the workspace's Docs say about a query, for an agent about to
2294 * answer (DocsApi.recallForAgent): the closest passages by meaning above
2295 * MEANING_FLOOR, required spaces first, at most two per page, filled with
2296 * passages matching its words when meaning finds too few. Only from what
2297 * the viewer and audience can all read, by the same rules as every other
2298 * agent read (`agentSpaces`).
2299 */
2300 async recallForAgent(a: {
2301 workspace: string;
2302 agent_id: string;
2303 viewer: Viewer;
2304 query: string;
2305 limit?: number | null;
2306 spaces?: string[] | null;
2307 audience: DocAudience | null;
2308 }): Promise<Result<DocPassage[]>> {
2309 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2310 if (!found.ok) return found;
2311 const { workspace, spaces } = found.value;
2312 this.defer(ensureIndexed(this.env, workspace.id).catch((error: unknown) => console.error("docs could not start indexing", workspace.id, String(error))));
2313 const query = String(a.query ?? "").trim().slice(0, 2000);
2314 if (!query) return ok([]);
2315 const limit = recallLimit(a.limit);
2316 const repoSpaces = await this.repoSpacesForAudience(workspace, a.viewer!, a.audience ?? null).catch((error: unknown) => {
2317 console.error("docs could not check projects' docs for recall", String(error));
2318 return [] as { row: RepoSpaceRow; repo: Repo }[];
2319 });
2320 const allowed = [...spaces.map((s) => s.row.id), ...repoSpaces.map((r) => r.row.id)];
2321 if (!allowed.length) return ok([]);
2322 const required = requiredSpaces(allowed, a.spaces);
2323 const fts = ftsAnyQuery(query);
2324 const vector = await this.queryVector(query);
2325 const [meaning, requiredMeaning, words] = await Promise.all([
2326 vector ? this.meaningMatches(workspace.id, allowed, vector) : Promise.resolve([]),
2327 // Required reading asked on its own too, so the rest of the workspace can't crowd it out.
2328 vector && required.length && required.length < allowed.length ? this.meaningMatches(workspace.id, required, vector) : Promise.resolve([]),
2329 fts ? this.wordMatches(workspace.id, allowed, fts, 30) : Promise.resolve([] as string[]),
2330 ]);
2331 const scores = new Map<string, number>();
2332 for (const m of [...meaning, ...requiredMeaning]) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score));
2333 const rows = await this.passages(workspace.id, [...scores.keys(), ...words]);
2334 const candidates: (Candidate & { row: PassageRow })[] = [];
2335 for (const [id, score] of scores) {
2336 const row = rows.get(id);
2337 if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score, by: "meaning", row });
2338 }
2339 for (const id of words) {
2340 const row = rows.get(id);
2341 if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score: WORDS_SCORE, by: "words", row });
2342 }
2343 const picked = pickPassages(candidates, { allowed: new Set(allowed), required, limit });
2344 const stale = await this.staleIds([...new Set(picked.map((c) => c.row.page_id).filter((id): id is string => !!id))]);
2345 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
2346 const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
2347 const out: DocPassage[] = [];
2348 for (const c of picked) {
2349 const row = c.row;
2350 const score = Math.round(c.score * 1000) / 1000;
2351 if (row.page_id) {
2352 const space = bySpace.get(row.space_id);
2353 if (!space) continue;
2354 out.push({
2355 page: this.ref(workspace.slug, space, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
2356 repo_file: null,
2357 space_name: space.name,
2358 heading: row.heading,
2359 text: row.text,
2360 score,
2361 updated_at: row.page_updated_at ?? row.updated_at,
2362 stale: stale.has(row.page_id),
2363 });
2364 } else {
2365 const repo = byRepo.get(row.space_id);
2366 if (!repo || !row.path) continue;
2367 const name = `${repo.repo.namespace}/${repo.repo.name}`;
2368 out.push({
2369 page: null,
2370 repo_file: { repo: name, path: row.path, href: `/${workspace.slug}/-/docs/repo/${name}/${row.path.split("/").map(encodeURIComponent).join("/")}` },
2371 space_name: name,
2372 heading: row.heading,
2373 text: row.text,
2374 score,
2375 updated_at: repo.row.indexed_at ?? row.updated_at,
2376 stale: false,
2377 });
2378 }
2379 }
2380 return ok(out);
2381 }
2382
2383 /** Indexes the workspace's pages and projects' docs again, on the queue. Owners only. */
2384 async reindexDocs(a: { workspace: string; viewer: Viewer }): Promise<Result<boolean>> {
2385 const found = await this.viewerWorkspace(a.workspace, a.viewer);
2386 if (!found.ok) return found;
2387 if (!this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only an owner can index the workspace's docs again.");
2388 return ok(await startBackfill(this.env, found.value.id, { force: true }));
2389 }
2390
2391 private async fileSuggestion(
2392 workspace: Workspace,
2393 page: PageRow,
2394 space: SpaceRow,
2395 agent: WorkspaceAgent,
2396 viewer: User,
2397 edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
2398 ): Promise<Result<DocSuggestion>> {
2399 const room = this.room(page.id);
2400 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2401 const current = await room.target(edit.target);
2402 if (!current) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
2403 const id = newId("sug");
2404 const row: SuggestionRow = {
2405 id,
2406 page_id: page.id,
2407 author: principalKey({ kind: "agent", id: agent.id }),
2408 asked_by: this.userKey(viewer),
2409 target: JSON.stringify(edit.target),
2410 before_markdown: current.markdown,
2411 after_markdown: edit.markdown,
2412 note: edit.note,
2413 status: "open",
2414 created_at: now(),
2415 decided_by: null,
2416 decided_at: null,
2417 marks_current: edit.marks_current ? 1 : 0,
2418 };
2419 await this.db
2420 .prepare("INSERT INTO suggestions (id, page_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
2421 .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)
2422 .run();
2423 const [suggestion] = await this.toSuggestions(workspace, [row], [current.block_ids]);
2424 this.tell(page.id, { type: "suggestion.created", suggestion: suggestion! });
2425 this.defer(room.announce(row.author, agent.display_name).catch(() => undefined));
2426 this.defer(this.notifyOwners(workspace, page, space, suggestion!));
2427 return ok(suggestion!);
2428 }
2429
2430 /** A page's owners (people) hear of a suggestion waiting for them. */
2431 private async notifyOwners(workspace: Workspace, page: PageRow, space: SpaceRow, suggestion: DocSuggestion): Promise<void> {
2432 if (!this.env.NOTIFY) return;
2433 const owners = (await this.db.prepare("SELECT principal FROM page_owners WHERE page_id = ?").bind(page.id).all<{ principal: string }>()).results
2434 .map((o) => o.principal)
2435 .filter((k) => k.startsWith("user:"))
2436 .map((k) => k.slice(5));
2437 if (!owners.length) return;
2438 const notify = notifyClient(this.env.NOTIFY);
2439 const href = this.ref(workspace.slug, space, page).path;
2440 await Promise.all(
2441 owners.map((id) =>
2442 notify
2443 .notify(
2444 { user_id: id },
2445 {
2446 id: `doc-suggestion:${suggestion.id}:${id}`,
2447 kind: "inbox",
2448 workspace: workspace.slug,
2449 title: `${suggestion.author.display_name} suggested a change to ${page.title || "Untitled"}`,
2450 body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
2451 href,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.2452 actor: { kind: "agent", id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null, look: suggestion.author.look ?? null },
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.2453 created_at: suggestion.created_at,
2454 },
2455 )
2456 .catch(() => undefined),
2457 ),
2458 );
2459 }
2460
2461 private cleanEdit(edit: unknown): Result<{ target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }> {
2462 const e = (edit ?? {}) as { target?: unknown; markdown?: unknown; note?: unknown; marks_current?: unknown };
2463 const target = cleanTarget(e.target);
2464 if (!target) return fail("invalid", "Say what to change: append, document, a section by its heading, or blocks by id.");
2465 const markdown = String(e.markdown ?? "");
2466 if (markdown.length > MAX_MARKDOWN) return fail("invalid", "That edit is too long.");
2467 if (target.kind === "append" && !markdown.trim()) return fail("invalid", "Nothing to add.");
2468 return ok({ target, markdown, note: e.note ? String(e.note).trim().slice(0, MAX_NOTE) || null : null, marks_current: e.marks_current === true });
2469 }
2470
2471 async suggestEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocSuggestion>> {
2472 const edit = this.cleanEdit(a.edit);
2473 if (!edit.ok) return edit;
2474 const found = await this.agentPage(a);
2475 if (!found.ok) return found;
2476 const { workspace, agent, page, space } = found.value;
2477 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so no change can be suggested for them.`);
2478 return this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2479 }
2480
2481 async applyEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocAgentEditResult>> {
2482 const edit = this.cleanEdit(a.edit);
2483 if (!edit.ok) return edit;
2484 const found = await this.agentPage(a);
2485 if (!found.ok) return found;
2486 const { workspace, agent, page, space } = found.value;
2487 const ref = this.ref(workspace.slug, space.row, page);
2488 if (!space.can.edit) {
2489 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so it can't be changed for them.`);
2490 const suggestion = await this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2491 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, page: ref }) : suggestion;
2492 }
2493 const room = this.room(page.id);
2494 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2495 const result = await room.edit(edit.value.target, edit.value.markdown, {
2496 key: principalKey({ kind: "agent", id: agent.id }),
2497 kind: "agent",
2498 note: edit.value.note ? `@${agent.handle} for @${a.viewer!.username}: ${edit.value.note}` : `@${agent.handle} for @${a.viewer!.username}`,
2499 authors: [principalKey({ kind: "agent", id: agent.id })],
2500 });
2501 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.");
2502 if (edit.value.marks_current) await this.clearStale(page.id, principalKey({ kind: "agent", id: agent.id }));
2503 this.defer(room.announce(principalKey({ kind: "agent", id: agent.id }), agent.display_name).catch(() => undefined));
2504 return ok({ mode: "applied", version_id: result.version_id, page: ref });
2505 }
2506
2507 async createPageAsAgent(a: {
2508 workspace: string;
2509 agent_id: string;
2510 viewer: Viewer;
2511 input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null };
2512 }): Promise<Result<DocPageRef>> {
2513 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, null);
2514 if (!found.ok) return found;
2515 const { workspace, agent, spaces } = found.value;
2516 const input = a.input ?? ({} as typeof a.input);
2517 const space = input.space_id ? spaces.find((s) => s.row.id === input.space_id) : spaces.find((s) => s.row.is_default);
2518 if (!space) return fail("not_found", "No such space.");
2519 if (!atLeast(space.role, "edit")) return fail("forbidden", `${a.viewer!.username} can't add pages to ${space.row.name}.`);
2520 const title = cleanTitle(input.title);
2521 if (!title) return fail("invalid", "Give the page a title.");
2522 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
2523 const source = input.source && typeof input.source.href === "string" && input.source.href.startsWith("/") ? input.source : null;
2524 if (source) markdown = `> [!NOTE]\n> Written up from [${String(source.title || "a conversation").replace(/[[\]]/g, "")}](${source.href}).\n\n${markdown}`;
2525 const parent = input.parent_id
2526 ? 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 }>()
2527 : null;
2528 const agentKey = principalKey({ kind: "agent", id: agent.id });
2529 const row = await this.insertPage(workspace, space.row, agentKey, {
2530 parent_id: parent?.id ?? null,
2531 title,
2532 icon: cleanIcon(input.icon),
2533 markdown,
2534 owners: [this.userKey(a.viewer!)],
2535 });
2536 return ok(this.ref(workspace.slug, space.row, row));
2537 }
2538
2539 async threadsForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocThread[]>> {
2540 const found = await this.agentPage(a);
2541 if (!found.ok) return found;
2542 return ok(await this.resolveThreads(found.value.workspace, await this.room(found.value.page.id).threads()));
2543 }
2544
2545 // ── Sockets and files ───────────────────────────────────────────────────
2546
2547 private viewerFrom(request: Request): Viewer {
2548 try {
2549 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2550 } catch {
2551 return null;
2552 }
2553 }
2554
2555 /**
2556 * `GET /live?workspace=<slug>&page=<id>`, upgraded to a WebSocket. The
2557 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking the
2558 * session; trusted only because this Worker is reachable through service
2559 * bindings alone. Checked like any read, then handed to the page's room
2560 * with the viewer's role, which the room enforces.
2561 */
2562 async live(request: Request): Promise<Response> {
2563 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2564 const viewer = this.viewerFrom(request);
2565 if (!viewer?.id) return new Response("Sign in to use Docs\n", { status: 401 });
2566 const url = new URL(request.url);
2567 const slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
2568 const found = await this.pageFor(slug, url.searchParams.get("page") ?? "", viewer, "view");
2569 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2570 const { workspace, page, space } = found.value;
2571 if (page.archived_at) return new Response("That page is in the trash\n", { status: 410 });
2572 const room = this.room(page.id);
2573 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2574 const key = this.userKey(viewer);
2575 const who: RoomMember = { page_id: page.id, workspace_slug: workspace.slug, key, member: (await this.profiles(workspace, [key])).get(key)!, role: space.role! };
2576 const headers = new Headers(request.headers);
2577 headers.delete(DOCS_VIEWER_HEADER);
2578 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
2579 return room.fetch(new Request(request.url, { method: "GET", headers }));
2580 }
2581
2582 /** `PUT /files?workspace=&page=&name=`: a file for a page, from someone who can edit it. */
2583 async upload(request: Request): Promise<Response> {
2584 const viewer = this.viewerFrom(request);
2585 const url = new URL(request.url);
2586 const found = await this.pageFor((url.searchParams.get("workspace") ?? "").toLowerCase(), url.searchParams.get("page") ?? "", viewer, "edit");
2587 if (!found.ok) return Response.json(found);
2588 const bytes = Number(request.headers.get("content-length") ?? "0");
2589 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2590 const name = safeName(url.searchParams.get("name") ?? "file");
2591 const contentType = servedType(request.headers.get("content-type") ?? "");
2592 const random = crypto.getRandomValues(new Uint8Array(32));
2593 const key = [...random].map((b) => b.toString(16).padStart(2, "0")).join("");
2594 const id = newId("fil");
2595 await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
2596 await this.db
2597 .prepare("INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2598 .bind(id, found.value.workspace.id, found.value.page.id, key, name, contentType, bytes, this.userKey(viewer!), now())
2599 .run();
2600 const file: DocFile = { id, url: `/docs-files/${key}`, name, content_type: contentType, bytes };
2601 return Response.json(ok(file));
2602 }
2603
2604 /**
2605 * `GET /files/<key>`: a page's file, for the usercontent origin. The key
2606 * is 256 random bits, so knowing it is the permission, as with any
2607 * shared link; it is never on the site's own origin.
2608 */
2609 async file(key: string): Promise<Response> {
2610 const row = await this.db.prepare("SELECT name, content_type FROM files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2611 if (!row) return new Response("Not found\n", { status: 404 });
2612 const stored = await fileStore(this.env).get(`docs/${key}`);
2613 if (!stored) return new Response("Not found\n", { status: 404 });
2614 const inline = row.content_type !== "application/octet-stream";
2615 return new Response(stored.body, {
2616 headers: {
2617 "content-type": row.content_type,
2618 "content-length": String(stored.bytes),
2619 etag: stored.etag,
2620 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2621 "cache-control": "private, max-age=31536000, immutable",
2622 },
2623 });
2624 }
2625}
2626
2627/** One RPC method's answer. */
2628async function answer(service: Docs, method: string, args: any): Promise<Response> {
2629 switch (method) {
2630 case "sidebar":
2631 return Response.json(await service.sidebar(args));
2632 case "home":
2633 return Response.json(await service.home(args));
2634 case "space":
2635 return Response.json(await service.space(args));
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.2636 case "spaces":
2637 return Response.json(await service.spaces(args));
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.2638 case "create_space":
2639 return Response.json(await service.createSpace(args));
2640 case "update_space":
2641 return Response.json(await service.updateSpace(args));
2642 case "set_space_member":
2643 return Response.json(await service.setSpaceMember(args));
2644 case "page":
2645 return Response.json(await service.page(args));
2646 case "create_page":
2647 return Response.json(await service.createPage(args));
2648 case "update_page":
2649 return Response.json(await service.updatePage(args));
2650 case "move_page":
2651 return Response.json(await service.movePage(args));
2652 case "duplicate_page":
2653 return Response.json(await service.duplicatePage(args));
2654 case "archive_page":
2655 return Response.json(await service.archivePage(args));
2656 case "restore_page":
2657 return Response.json(await service.restorePage(args));
2658 case "delete_page":
2659 return Response.json(await service.deletePage(args));
2660 case "trash":
2661 return Response.json(await service.trash(args));
2662 case "favorite":
2663 return Response.json(await service.favorite(args));
2664 case "search":
2665 return Response.json(await service.search(args));
2666 case "versions":
2667 return Response.json(await service.versions(args));
2668 case "version":
2669 return Response.json(await service.version(args));
2670 case "restore_version":
2671 return Response.json(await service.restoreVersion(args));
2672 case "templates":
2673 return Response.json(await service.templates(args));
2674 case "save_template":
2675 return Response.json(await service.saveTemplate(args));
2676 case "delete_template":
2677 return Response.json(await service.deleteTemplate(args));
2678 case "export_page":
2679 return Response.json(await service.exportPage(args));
2680 case "export_space":
2681 return Response.json(await service.exportSpace(args));
2682 case "suggestions":
2683 return Response.json(await service.suggestions(args));
2684 case "decide_suggestion":
2685 return Response.json(await service.decideSuggestion(args));
2686 case "accept_all":
2687 return Response.json(await service.acceptAll(args));
2688 case "thread":
2689 return Response.json(await service.thread(args));
2690 case "threads":
2691 return Response.json(await service.threads(args));
2692 case "spaces_for_agent":
2693 return Response.json(await service.spacesForAgent(args));
2694 case "page_markdown":
2695 return Response.json(await service.pageMarkdown(args));
2696 case "search_for_agent":
2697 return Response.json(await service.searchForAgent(args));
2698 case "suggest_edit":
2699 return Response.json(await service.suggestEdit(args));
2700 case "apply_edit":
2701 return Response.json(await service.applyEdit(args));
2702 case "create_page_as_agent":
2703 return Response.json(await service.createPageAsAgent(args));
2704 case "threads_for_agent":
2705 return Response.json(await service.threadsForAgent(args));
2706 case "stale_pages_for_agent":
2707 return Response.json(await service.stalePagesForAgent(args));
2708 case "mark_current":
2709 return Response.json(await service.markCurrent(args));
2710 case "stale_pages":
2711 return Response.json(await service.stalePages(args));
2712 case "add_repo_space":
2713 return Response.json(await service.addRepoSpace(args));
2714 case "remove_repo_space":
2715 return Response.json(await service.removeRepoSpace(args));
2716 case "repo_page":
2717 return Response.json(await service.repoPage(args));
2718 case "recall_for_agent":
2719 return Response.json(await service.recallForAgent(args));
2720 case "reindex_docs":
2721 return Response.json(await service.reindexDocs(args));
2722 default:
2723 return new Response("Unknown method\n", { status: 404 });
2724 }
2725}
2726
2727export default {
2728 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2729 const url = new URL(request.url);
2730 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
2731 if (request.method === "GET" && url.pathname === "/live") return url.searchParams.has("folio") ? new Folios(env, defer).live(request) : new Docs(env, defer).live(request);
2732 if (request.method === "PUT" && url.pathname === "/files") return url.searchParams.has("folio") ? new Folios(env, defer).upload(request) : new Docs(env, defer).upload(request);
2733 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
2734 if ((request.method === "GET" || request.method === "HEAD") && file) return (await new Folios(env, defer).file(file[1]!)) ?? new Docs(env, defer).file(file[1]!);
2735 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2736 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2737 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2738 const opened = openD1(env.DB, request);
2739 const scoped = Object.create(env, { DB: { value: opened.db } }) as Env;
2740 const args = (await request.json().catch(() => ({}))) as any;
2741 try {
2742 // Folios first (src/folios/rpc.ts), then Docs' pages.
2743 const folio = folioHandler(match[1]!);
2744 if (folio) return opened.finish(Response.json(await folio(new Folios(scoped, defer), args)));
2745 const service = new Docs(scoped, defer);
2746 return opened.finish(await answer(service, match[1]!, args));
2747 } catch (error) {
2748 console.error("docs:", match[1], error);
2749 return opened.finish(Response.json(fail("conflict", "Docs couldn't do that just now. Try again.")));
2750 }
2751 },
2752
2753 /**
2754 * Events from the events service (SUBSCRIBER_DOCS): pages whose cited
2755 * code changed become possibly out of date, and projects' docs are read
2756 * again after a push (src/staleness.ts) and their passages indexed
2757 * (src/indexer.ts). The same queue carries this service's own backfill
2758 * jobs (`docs.index`). One failing message is retried on its own.
2759 */
2760 async queue(batch: MessageBatch<G1tEvent | DocsJob>, env: Env): Promise<void> {
2761 const reindex = async (repoId: string) => {
2762 if (env.REPOS) await reindexRepo({ DB: env.DB, REPOS: env.REPOS }, repoId, (spaceId, changed, gone) => indexRepoFiles(env, spaceId, changed, gone));
2763 };
2764 for (const message of batch.messages) {
2765 try {
2766 const body = message.body;
2767 if (body.type === "docs.index") {
2768 await runBackfill(env, (body as Extract<DocsJob, { type: "docs.index" }>).workspace_id);
2769 message.ack();
2770 continue;
2771 }
2772 if (body.type === "folios.reacl") {
2773 await runReacl(env, (body as Extract<DocsJob, { type: "folios.reacl" }>).folio_id);
2774 message.ack();
2775 continue;
2776 }
2777 if (body.type === "repo.purged") {
2778 // Its docs leave Docs (src/staleness.ts); their passages leave the index first.
2779 const gone = await env.DB.prepare("SELECT id FROM repo_spaces WHERE repo_id = ?").bind((body as G1tEvent<"repo.purged">).data.repoId).all<{ id: string }>();
2780 for (const space of gone.results) await forgetDocs(env, { space_id: space.id });
2781 }
2782 await onEvent(env, body as G1tEvent, reindex);
2783 message.ack();
2784 } catch (error) {
2785 console.error("docs could not handle", message.body?.type, String(error));
2786 message.retry();
2787 }
2788 }
2789 },
2790
2791 /** Daily (wrangler.jsonc `triggers`): folios in the trash for over 30 days are deleted for good. */
2792 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
2793 ctx.waitUntil(
2794 purgeTrash(env)
2795 .then((n) => {
2796 if (n) console.log("folios purged from the trash", n);
2797 })
2798 .catch((error: unknown) => console.error("folios could not purge the trash", String(error))),
2799 );
2800 },
2801} satisfies ExportedHandler<Env, G1tEvent | DocsJob>;

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