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