| 1 | /** |
| 2 | * Docs: the workspace's written knowledge, kept by the artifacts service |
| 3 | * (`services/artifacts`). Spaces hold trees of pages; each page is a CRDT |
| 4 | * document (Yjs) edited live over a socket, saved with a Markdown |
| 5 | * rendition that search, agents, export and the read view use. |
| 6 | * |
| 7 | * Wire shapes are snake_case end to end: the site, agents and the live |
| 8 | * socket all carry the same objects. |
| 9 | * |
| 10 | * Access, in one place: |
| 11 | * |
| 12 | * - A space has a kind: `workspace` (every member gets `default_role`), |
| 13 | * `team` (the team's members get `default_role`) or `private` (only the |
| 14 | * people, agents and teams listed as its members). |
| 15 | * - Members are listed with a role: view < comment < edit < manage. A |
| 16 | * person's role is the highest of the space's base role (when it applies |
| 17 | * to them) and every listing that names them or one of their teams. |
| 18 | * Workspace owners manage every workspace and team space; a private space |
| 19 | * is its members' alone. |
| 20 | * - An agent never sees or changes more than the person it acts for (the |
| 21 | * `viewer` on every agent call). It reads what that person can read, |
| 22 | * narrowed further to what every person in the `audience` can read; it |
| 23 | * suggests where that person can comment; and it edits directly only |
| 24 | * where that person can edit AND the space lets agents edit |
| 25 | * (`agent_mode: "edit"`). Otherwise its edit becomes a suggestion. |
| 26 | */ |
| 27 | import type { MemberProfile, Principal } from "./chat"; |
| 28 | import type { ServiceBinding } from "./clients"; |
| 29 | import type { User } from "./identity"; |
| 30 | import type { Result } from "./result"; |
| 31 | |
| 32 | /** What someone may do in a space, weakest first. */ |
| 33 | export type DocRole = "view" | "comment" | "edit" | "manage"; |
| 34 | export const DOC_ROLES: readonly DocRole[] = ["view", "comment", "edit", "manage"]; |
| 35 | |
| 36 | export const DOC_ROLE_LABELS: Record<DocRole, string> = { |
| 37 | view: "Can view", |
| 38 | comment: "Can comment", |
| 39 | edit: "Can edit", |
| 40 | manage: "Full access", |
| 41 | }; |
| 42 | |
| 43 | export const DOC_ROLE_SUMMARIES: Record<DocRole, string> = { |
| 44 | view: "Read pages and their history.", |
| 45 | comment: "Read pages and comment on them.", |
| 46 | edit: "Write and organize pages, and accept or reject suggestions.", |
| 47 | manage: "Edit, and change who has access and how agents work here.", |
| 48 | }; |
| 49 | |
| 50 | /** Who a space is for. */ |
| 51 | export type DocSpaceKind = "workspace" | "team" | "private"; |
| 52 | |
| 53 | export const DOC_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = { |
| 54 | workspace: "Everyone in the workspace", |
| 55 | team: "A team", |
| 56 | private: "Only its members", |
| 57 | }; |
| 58 | |
| 59 | /** How agents change pages in a space: as tracked suggestions (the default), or directly. */ |
| 60 | export type DocAgentMode = "suggest" | "edit"; |
| 61 | |
| 62 | export const DOC_AGENT_MODE_LABELS: Record<DocAgentMode, string> = { |
| 63 | suggest: "Suggest changes", |
| 64 | edit: "Edit directly", |
| 65 | }; |
| 66 | |
| 67 | /** A key for a space member: `user:<id>`, `agent:<id>` or `team:<slug>`. */ |
| 68 | export type DocMemberKey = string; |
| 69 | |
| 70 | export type DocSpace = { |
| 71 | id: string; |
| 72 | workspace_id: string; |
| 73 | /** Unique in the workspace; in the URL: `/<workspace>/-/docs/<slug>`. */ |
| 74 | slug: string; |
| 75 | name: string; |
| 76 | description: string | null; |
| 77 | /** An emoji, or null for the default book. */ |
| 78 | icon: string | null; |
| 79 | kind: DocSpaceKind; |
| 80 | /** The team's slug, for a team space. */ |
| 81 | team: string | null; |
| 82 | /** What every workspace member (workspace) or team member (team) gets; null for a private space. */ |
| 83 | default_role: DocRole | null; |
| 84 | agent_mode: DocAgentMode; |
| 85 | /** People with edit access may share what is in it (Artifacts), as people with full access can. Off unless turned on. */ |
| 86 | editors_can_share: boolean; |
| 87 | /** |
| 88 | * The workspace's General space, made once when Artifacts is first |
| 89 | * opened and shown to every member without joining. It can be archived |
| 90 | * and deleted like any other; once deleted it is not made again, and no |
| 91 | * other space takes its place. |
| 92 | */ |
| 93 | is_default: boolean; |
| 94 | /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */ |
| 95 | projects: string[]; |
| 96 | created_by: Principal; |
| 97 | created_at: string; |
| 98 | archived_at: string | null; |
| 99 | /** The viewer's role in it. */ |
| 100 | viewer_role: DocRole; |
| 101 | /** Pages in it that are not in the trash. */ |
| 102 | page_count: number; |
| 103 | }; |
| 104 | |
| 105 | /** A space member, as the space's settings show it. */ |
| 106 | export type DocSpaceMember = { |
| 107 | key: DocMemberKey; |
| 108 | kind: "user" | "agent" | "team"; |
| 109 | /** Username, agent handle or team slug. */ |
| 110 | name: string; |
| 111 | display_name: string; |
| 112 | avatar: string | null; |
| 113 | avatar_seed?: string | null; |
| 114 | role: DocRole; |
| 115 | }; |
| 116 | |
| 117 | /** Enough to link to a page. */ |
| 118 | export type DocPageRef = { |
| 119 | id: string; |
| 120 | space_id: string; |
| 121 | space_slug: string; |
| 122 | title: string; |
| 123 | /** An emoji, or null for the default page icon. */ |
| 124 | icon: string | null; |
| 125 | /** `<title-slug>-<id>`: the last part of the page's URL. */ |
| 126 | slug: string; |
| 127 | /** `/<workspace>/-/docs/<space>/<slug>`. */ |
| 128 | path: string; |
| 129 | }; |
| 130 | |
| 131 | export type DocPage = DocPageRef & { |
| 132 | parent_id: string | null; |
| 133 | position: number; |
| 134 | /** A cover image's URL, or a CSS gradient name (`gradient:<n>`); null for none. */ |
| 135 | cover: string | null; |
| 136 | created_by: MemberProfile; |
| 137 | created_at: string; |
| 138 | updated_by: MemberProfile | null; |
| 139 | updated_at: string; |
| 140 | /** When it went to the trash; null when it is not there. */ |
| 141 | archived_at: string | null; |
| 142 | has_children: boolean; |
| 143 | /** Projects (`owner/name`) the page is about, besides its space's. */ |
| 144 | projects: string[]; |
| 145 | owners: MemberProfile[]; |
| 146 | /** The first lines of its text, for cards. */ |
| 147 | excerpt: string; |
| 148 | /** Whether code it cites changed since someone last marked it current: possibly out of date. */ |
| 149 | stale: boolean; |
| 150 | }; |
| 151 | |
| 152 | // ── Citations and staleness ─────────────────────────────────────────────── |
| 153 | // |
| 154 | // A page can cite code: a path (a file, a folder, or a glob like |
| 155 | // `src/export/**`) in a repository, optionally naming what at that path it |
| 156 | // describes (a symbol, an endpoint, an environment variable). Citations |
| 157 | // come from the page's text (the editor's citation chips, and links to |
| 158 | // files in a repository: `/<owner>/<repo>/blob/<ref>/<path>`) and from |
| 159 | // the page's header ("Describes"). When a merged pull request or a push |
| 160 | // to a repository's default branch changes a cited path, the page is |
| 161 | // marked possibly out of date with that change, until someone with edit |
| 162 | // access marks it current again (or an agent updates it). |
| 163 | |
| 164 | /** What a citation names at its path. */ |
| 165 | export type DocCitationKind = "path" | "symbol" | "endpoint" | "env"; |
| 166 | export const DOC_CITATION_KINDS: readonly DocCitationKind[] = ["path", "symbol", "endpoint", "env"]; |
| 167 | |
| 168 | export const DOC_CITATION_KIND_LABELS: Record<DocCitationKind, string> = { |
| 169 | path: "A file or folder", |
| 170 | symbol: "A symbol", |
| 171 | endpoint: "An endpoint", |
| 172 | env: "An environment variable", |
| 173 | }; |
| 174 | |
| 175 | export type DocCitation = { |
| 176 | /** `owner/name`, lowercased. */ |
| 177 | repo: string; |
| 178 | /** A file, a folder (everything under it), or a glob (`*`, `**`, `?`). */ |
| 179 | path: string; |
| 180 | kind: DocCitationKind; |
| 181 | /** The symbol, endpoint (`POST /v1/export`) or variable (`EXPORT_BUCKET`), for those kinds. */ |
| 182 | label: string | null; |
| 183 | /** The commit it was cited at, when known. */ |
| 184 | ref: string | null; |
| 185 | /** From the page's text, or from its header's "Describes". */ |
| 186 | source: "body" | "header"; |
| 187 | }; |
| 188 | |
| 189 | /** One entry of a page's "Describes": a repository and a path in it. */ |
| 190 | export type DocDescribes = { repo: string; path: string }; |
| 191 | |
| 192 | /** A change that made a page possibly out of date. */ |
| 193 | export type DocStaleChange = { |
| 194 | /** |
| 195 | * False when the viewer can't read the repository: then `repo`, `pull`, |
| 196 | * `commit` and `paths` are blank, and the page only says that a change |
| 197 | * they can't see touched code it cites. |
| 198 | */ |
| 199 | visible: boolean; |
| 200 | repo: string | null; |
| 201 | commit: string | null; |
| 202 | /** The merged pull request, when the change came from one. */ |
| 203 | pull: { number: number; title: string | null } | null; |
| 204 | /** The cited paths it changed (the changed files, at most 20). */ |
| 205 | paths: string[]; |
| 206 | /** When it was noticed. RFC 3339. */ |
| 207 | at: string; |
| 208 | }; |
| 209 | |
| 210 | /** Why a page is possibly out of date: every change since it was last marked current, newest first. */ |
| 211 | export type DocStaleness = { since: string; changes: DocStaleChange[] }; |
| 212 | |
| 213 | // ── A project's docs ────────────────────────────────────────────────────── |
| 214 | // |
| 215 | // A repository's `docs/` folder (and README.md), shown read-only in Docs |
| 216 | // next to the workspace's spaces and found by the same search. It is read |
| 217 | // from the default branch and kept up to date on every push to it. Each |
| 218 | // reader sees only the repositories they can read. Changes go through the |
| 219 | // repository: "Edit in Code" opens the file. |
| 220 | |
| 221 | export type DocRepoFile = { |
| 222 | /** From the repository's root: `docs/guide/setup.md`, `README.md`. */ |
| 223 | path: string; |
| 224 | /** Its first heading, or its file name. */ |
| 225 | title: string; |
| 226 | }; |
| 227 | |
| 228 | export type DocRepoSpace = { |
| 229 | id: string; |
| 230 | /** `owner/name`, as the repository is named now. */ |
| 231 | repo: string; |
| 232 | default_branch: string; |
| 233 | /** The commit it was read at; null until it has been. */ |
| 234 | commit: string | null; |
| 235 | indexed_at: string | null; |
| 236 | added_by: MemberProfile; |
| 237 | files: DocRepoFile[]; |
| 238 | /** Whether the viewer may stop showing it (whoever added it, or an owner). */ |
| 239 | can_remove: boolean; |
| 240 | }; |
| 241 | |
| 242 | export type DocRepoPage = { |
| 243 | space: DocRepoSpace; |
| 244 | file: DocRepoFile & { |
| 245 | markdown: string; |
| 246 | /** `/<workspace>/-/docs/repo/<owner>/<name>/<path>`. */ |
| 247 | href: string; |
| 248 | /** The file in Code, on the default branch. */ |
| 249 | code_href: string; |
| 250 | }; |
| 251 | }; |
| 252 | |
| 253 | /** A page as the sidebar's tree lists it: flat, ordered by `position` within each parent. */ |
| 254 | export type DocTreeNode = { |
| 255 | id: string; |
| 256 | parent_id: string | null; |
| 257 | position: number; |
| 258 | title: string; |
| 259 | icon: string | null; |
| 260 | slug: string; |
| 261 | /** Possibly out of date (see `DocPage.stale`). */ |
| 262 | stale?: boolean; |
| 263 | }; |
| 264 | |
| 265 | export type DocsSidebarSpace = DocSpace & { pages: DocTreeNode[] }; |
| 266 | |
| 267 | export type DocsSidebar = { |
| 268 | spaces: DocsSidebarSpace[]; |
| 269 | favorites: DocPageRef[]; |
| 270 | /** The pages the viewer opened last, newest first. */ |
| 271 | recent: DocPageRef[]; |
| 272 | /** Whether the viewer may make spaces (members of the workspace may). */ |
| 273 | can_create_space: boolean; |
| 274 | trash_count: number; |
| 275 | /** Pages the viewer can read that are possibly out of date. */ |
| 276 | stale_count: number; |
| 277 | /** Projects' docs folders shown in Docs, those whose repository the viewer can read. */ |
| 278 | repos: DocRepoSpace[]; |
| 279 | }; |
| 280 | |
| 281 | export type DocsHome = { |
| 282 | /** Recently edited pages the viewer can read, newest first. */ |
| 283 | recent: DocPage[]; |
| 284 | /** Pages the viewer made or owns. */ |
| 285 | mine: DocPage[]; |
| 286 | /** Pages possibly out of date, most recently flagged first. */ |
| 287 | stale: DocPage[]; |
| 288 | spaces: DocSpace[]; |
| 289 | /** Every project some space or page is linked to, for the filter. */ |
| 290 | projects: string[]; |
| 291 | /** The project the lists are filtered to, or null. */ |
| 292 | project: string | null; |
| 293 | }; |
| 294 | |
| 295 | /** A tracked change an agent proposed. `blocks` targets name top-level block ids from `page_markdown`. */ |
| 296 | export type DocEditTarget = |
| 297 | /** Add to the end of the page. */ |
| 298 | | { kind: "append" } |
| 299 | /** Replace the whole page. */ |
| 300 | | { kind: "document" } |
| 301 | /** Replace a section: the heading whose text matches (case-insensitive) and everything under it, up to the next heading of the same or a higher level. The new Markdown should include the heading if it is to stay. */ |
| 302 | | { kind: "section"; heading: string } |
| 303 | /** Replace top-level blocks `from_block` through `to_block`, inclusive (with any blocks nested under them). */ |
| 304 | | { kind: "blocks"; from_block: string; to_block: string }; |
| 305 | |
| 306 | export type DocSuggestionStatus = "open" | "accepted" | "rejected" | "stale"; |
| 307 | |
| 308 | export type DocSuggestion = { |
| 309 | id: string; |
| 310 | page_id: string; |
| 311 | /** The agent that suggested it. */ |
| 312 | author: MemberProfile; |
| 313 | /** The person it acted for. */ |
| 314 | asked_by: MemberProfile | null; |
| 315 | target: DocEditTarget; |
| 316 | /** The target's Markdown when it was suggested. */ |
| 317 | before_markdown: string; |
| 318 | /** What it proposes instead. */ |
| 319 | after_markdown: string; |
| 320 | /** Why, in a line. */ |
| 321 | note: string | null; |
| 322 | status: DocSuggestionStatus; |
| 323 | created_at: string; |
| 324 | decided_by: MemberProfile | null; |
| 325 | decided_at: string | null; |
| 326 | /** The top-level blocks it covers now, so the editor can mark them; empty for an append or when the target is gone. */ |
| 327 | block_ids: string[]; |
| 328 | }; |
| 329 | |
| 330 | export type DocVersionKind = "created" | "edit" | "agent" | "suggestion" | "restore"; |
| 331 | |
| 332 | export type DocVersion = { |
| 333 | id: string; |
| 334 | page_id: string; |
| 335 | created_at: string; |
| 336 | /** Everyone whose changes are in it, people and agents. */ |
| 337 | authors: MemberProfile[]; |
| 338 | kind: DocVersionKind; |
| 339 | /** "Suggested by @inky, accepted by @ana"; "Restored from Oct 3, 14:02". */ |
| 340 | note: string | null; |
| 341 | }; |
| 342 | |
| 343 | export type DocDiffLine = { op: "same" | "add" | "del"; text: string }; |
| 344 | |
| 345 | export type DocVersionDetail = DocVersion & { |
| 346 | markdown: string; |
| 347 | /** Against the version before it (or nothing, for the first). */ |
| 348 | diff: DocDiffLine[]; |
| 349 | }; |
| 350 | |
| 351 | export type DocPageDetail = { |
| 352 | page: DocPage; |
| 353 | space: DocSpace; |
| 354 | /** The page's ancestors, root first. */ |
| 355 | breadcrumbs: DocPageRef[]; |
| 356 | /** As last saved: the read view, and what the editor shows before the socket connects. */ |
| 357 | markdown: string; |
| 358 | role: DocRole; |
| 359 | backlinks: DocPageRef[]; |
| 360 | children: DocPageRef[]; |
| 361 | favorite: boolean; |
| 362 | /** When the viewer last opened it, before now. */ |
| 363 | last_viewed_at: string | null; |
| 364 | suggestions: DocSuggestion[]; |
| 365 | /** Code the page cites: from its text and its header. */ |
| 366 | citations: DocCitation[]; |
| 367 | /** The header's "Describes" list. */ |
| 368 | describes: DocDescribes[]; |
| 369 | /** Why it is possibly out of date; null when it isn't. */ |
| 370 | staleness: DocStaleness | null; |
| 371 | }; |
| 372 | |
| 373 | export type DocSearchHit = DocPageRef & { |
| 374 | space_name: string; |
| 375 | /** Text around the match; `[[` and `]]` mark matched words. */ |
| 376 | snippet: string; |
| 377 | updated_at: string; |
| 378 | projects: string[]; |
| 379 | /** Set for a file from a project's docs folder (then `id` is `repo:<space>:<path>` and `path` its address in Docs). */ |
| 380 | repo_file?: { repo: string; path: string } | null; |
| 381 | /** The heading of the passage that matched, when search found one (`mode: "hybrid"`). */ |
| 382 | heading?: string | null; |
| 383 | /** How it was found: by its words, by meaning (the semantic index), or both. */ |
| 384 | matched?: "words" | "meaning" | "both" | null; |
| 385 | }; |
| 386 | |
| 387 | export type DocTemplate = { |
| 388 | id: string; |
| 389 | name: string; |
| 390 | description: string; |
| 391 | icon: string; |
| 392 | /** Built in (meeting notes, RFC, ...) rather than saved by the workspace. */ |
| 393 | builtin: boolean; |
| 394 | markdown: string; |
| 395 | created_by: Principal | null; |
| 396 | }; |
| 397 | |
| 398 | /** One top-level block, as agents see a page. */ |
| 399 | export type DocBlockOutline = { |
| 400 | id: string; |
| 401 | /** BlockNote's type: `heading`, `paragraph`, `bulletListItem`, `codeBlock`, `callout`, ... */ |
| 402 | type: string; |
| 403 | /** For a heading. */ |
| 404 | level: number | null; |
| 405 | /** The block and anything nested under it. */ |
| 406 | markdown: string; |
| 407 | }; |
| 408 | |
| 409 | /** |
| 410 | * What an agent may do on a page, for the person it acts for: read it, |
| 411 | * suggest changes, edit its body (only in a space whose agents edit), and |
| 412 | * attach a file it made (whenever the person could edit: a file beside the |
| 413 | * page changes nothing in it). |
| 414 | */ |
| 415 | export type DocAgentAbilities = { read: boolean; suggest: boolean; edit: boolean; attach: boolean }; |
| 416 | |
| 417 | export type NewDocSpace = { |
| 418 | name: string; |
| 419 | slug?: string | null; |
| 420 | description?: string | null; |
| 421 | icon?: string | null; |
| 422 | kind: DocSpaceKind; |
| 423 | team?: string | null; |
| 424 | default_role?: DocRole | null; |
| 425 | agent_mode?: DocAgentMode | null; |
| 426 | projects?: string[] | null; |
| 427 | editors_can_share?: boolean | null; |
| 428 | }; |
| 429 | |
| 430 | export type DocSpaceChange = Partial<Omit<NewDocSpace, "kind">> & { kind?: DocSpaceKind; archived?: boolean }; |
| 431 | |
| 432 | export type NewDocPage = { |
| 433 | space_id: string; |
| 434 | parent_id?: string | null; |
| 435 | title?: string | null; |
| 436 | icon?: string | null; |
| 437 | /** A built-in template's id (`builtin:<name>`) or a saved one's. */ |
| 438 | template_id?: string | null; |
| 439 | /** Starting Markdown, when there is no template. */ |
| 440 | markdown?: string | null; |
| 441 | projects?: string[] | null; |
| 442 | }; |
| 443 | |
| 444 | export type DocPageChange = { |
| 445 | title?: string; |
| 446 | icon?: string | null; |
| 447 | cover?: string | null; |
| 448 | projects?: string[]; |
| 449 | /** Member keys (`user:<id>`, `agent:<id>`). */ |
| 450 | owners?: string[]; |
| 451 | /** Replaces the header's "Describes" list (at most 20). */ |
| 452 | describes?: DocDescribes[]; |
| 453 | }; |
| 454 | |
| 455 | /** Where a page goes: under `parent_id` (null for the top of the space), before `before_id` (null for the end). */ |
| 456 | export type DocMove = { space_id?: string | null; parent_id: string | null; before_id?: string | null }; |
| 457 | |
| 458 | export type DocSearchQuery = { |
| 459 | query: string; |
| 460 | space_id?: string | null; |
| 461 | /** `owner/name`: pages linked to it, or in a space linked to it. */ |
| 462 | project?: string | null; |
| 463 | limit?: number | null; |
| 464 | /** |
| 465 | * `words` (the default): titles and text by their words, as you type. |
| 466 | * `hybrid`: words and meaning (the semantic index) together, each hit |
| 467 | * with the passage and heading that matched; the Docs search page. |
| 468 | */ |
| 469 | mode?: "words" | "hybrid" | null; |
| 470 | }; |
| 471 | |
| 472 | /** |
| 473 | * The comment operations of the editor's thread store (BlockNote's |
| 474 | * `RESTYjsThreadStore`), applied by the page's room to the `threads` map in |
| 475 | * the page's Yjs document, so every open editor sees them at once. |
| 476 | */ |
| 477 | export type DocThreadAction = |
| 478 | | { op: "create"; body: unknown; metadata?: unknown; page_level?: boolean } |
| 479 | /** Anchors a thread to the text between two Yjs relative positions (JSON). */ |
| 480 | | { op: "anchor"; thread_id: string; anchor: unknown; head: unknown } |
| 481 | | { op: "comment"; thread_id: string; body: unknown; metadata?: unknown } |
| 482 | | { op: "edit_comment"; thread_id: string; comment_id: string; body: unknown; metadata?: unknown } |
| 483 | | { op: "delete_comment"; thread_id: string; comment_id: string; soft?: boolean } |
| 484 | | { op: "delete_thread"; thread_id: string } |
| 485 | | { op: "resolve" | "unresolve"; thread_id: string } |
| 486 | | { op: "react" | "unreact"; thread_id: string; comment_id: string; emoji: string }; |
| 487 | |
| 488 | /** A comment thread, as agents and the inbox see it. */ |
| 489 | export type DocThread = { |
| 490 | id: string; |
| 491 | /** The text it is on; null for a comment on the whole page. */ |
| 492 | quote: string | null; |
| 493 | resolved: boolean; |
| 494 | comments: { id: string; author: MemberProfile; text: string; created_at: string }[]; |
| 495 | }; |
| 496 | |
| 497 | /** A file put in a page: served from the usercontent origin. */ |
| 498 | export type DocFile = { id: string; url: string; name: string; content_type: string; bytes: number }; |
| 499 | |
| 500 | /** |
| 501 | * What a page's live socket carries besides the Yjs protocol (binary |
| 502 | * frames: y-protocols sync and awareness). These are JSON text frames. |
| 503 | */ |
| 504 | export type DocsLiveEvent = |
| 505 | | { type: "page.updated"; page: DocPage } |
| 506 | | { type: "page.archived"; page_id: string } |
| 507 | | { type: "suggestion.created" | "suggestion.updated"; suggestion: DocSuggestion } |
| 508 | | { type: "version.created"; version: DocVersion } |
| 509 | /** Whether it is possibly out of date changed: ask for the page again (what each reader sees of why depends on what they can read). */ |
| 510 | | { type: "page.staleness" } |
| 511 | /** The viewer's role changed, or their access ended (`role` null). */ |
| 512 | | { type: "access"; role: DocRole | null }; |
| 513 | |
| 514 | /** |
| 515 | * Header the site sets on a forwarded live socket and on uploads: the |
| 516 | * viewer, as JSON. Trusted only because the artifacts service is reachable |
| 517 | * through service bindings alone. |
| 518 | */ |
| 519 | export const DOCS_VIEWER_HEADER = "x-g1t-docs-viewer"; |
| 520 | |
| 521 | /** The largest file a page takes, in bytes. */ |
| 522 | export const DOC_MAX_FILE_BYTES = 25 * 1024 * 1024; |
| 523 | |
| 524 | /** The name of the Yjs XML fragment that holds a page's blocks. */ |
| 525 | export const DOC_FRAGMENT = "document-store"; |
| 526 | /** The name of the Yjs map that holds a page's comment threads. */ |
| 527 | export const DOC_THREADS = "threads"; |
| 528 | |
| 529 | export type DocsApi = { |
| 530 | // ── The site ───────────────────────────────────────────────────────── |
| 531 | |
| 532 | /** Spaces with their page trees, favorites, recent pages. Makes the General space the first time. */ |
| 533 | sidebar(workspace: string, viewer: User): Promise<Result<DocsSidebar>>; |
| 534 | home(workspace: string, viewer: User, options?: { project?: string | null }): Promise<Result<DocsHome>>; |
| 535 | space(workspace: string, spaceSlug: string, viewer: User): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>>; |
| 536 | /** Every space the viewer can open, archived ones included (never deleted ones), General first. */ |
| 537 | spaces(workspace: string, viewer: User): Promise<Result<DocSpace[]>>; |
| 538 | createSpace(workspace: string, viewer: User, input: NewDocSpace): Promise<Result<DocSpace>>; |
| 539 | /** Manage role only. `archived` true or false archives a space or brings it back, the General space too. */ |
| 540 | updateSpace(workspace: string, spaceId: string, viewer: User, change: DocSpaceChange): Promise<Result<DocSpace>>; |
| 541 | /** Adds, changes (`role`) or removes (`role` null) a member. Manage role only; the last manager stays. */ |
| 542 | setSpaceMember(workspace: string, spaceId: string, viewer: User, member: DocMemberKey, role: DocRole | null): Promise<Result<DocSpaceMember[]>>; |
| 543 | |
| 544 | /** A page, and the viewer's role in it; records the view. Not found when they can't read it. */ |
| 545 | page(workspace: string, pageId: string, viewer: User): Promise<Result<DocPageDetail>>; |
| 546 | createPage(workspace: string, viewer: User, input: NewDocPage): Promise<Result<DocPage>>; |
| 547 | updatePage(workspace: string, pageId: string, viewer: User, change: DocPageChange): Promise<Result<DocPage>>; |
| 548 | /** Edit role in both spaces. Refuses moving a page under itself. */ |
| 549 | movePage(workspace: string, pageId: string, viewer: User, move: DocMove): Promise<Result<DocPage>>; |
| 550 | duplicatePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; |
| 551 | /** To the trash, with every page under it. */ |
| 552 | archivePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; |
| 553 | restorePage(workspace: string, pageId: string, viewer: User): Promise<Result<DocPage>>; |
| 554 | /** For good: only from the trash, manage role. */ |
| 555 | deletePage(workspace: string, pageId: string, viewer: User): Promise<Result<boolean>>; |
| 556 | trash(workspace: string, viewer: User): Promise<Result<DocPage[]>>; |
| 557 | favorite(workspace: string, pageId: string, viewer: User, on: boolean): Promise<Result<boolean>>; |
| 558 | |
| 559 | search(workspace: string, viewer: User, query: DocSearchQuery): Promise<Result<DocSearchHit[]>>; |
| 560 | |
| 561 | versions(workspace: string, pageId: string, viewer: User): Promise<Result<DocVersion[]>>; |
| 562 | version(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersionDetail>>; |
| 563 | /** Makes the page what it was then, as a new version. Edit role. */ |
| 564 | restoreVersion(workspace: string, pageId: string, versionId: string, viewer: User): Promise<Result<DocVersion>>; |
| 565 | |
| 566 | templates(workspace: string, viewer: User): Promise<Result<DocTemplate[]>>; |
| 567 | /** Saves a page as one of the workspace's templates. */ |
| 568 | saveTemplate(workspace: string, viewer: User, input: { page_id: string; name: string; description?: string | null }): Promise<Result<DocTemplate>>; |
| 569 | deleteTemplate(workspace: string, templateId: string, viewer: User): Promise<Result<boolean>>; |
| 570 | |
| 571 | exportPage(workspace: string, pageId: string, viewer: User): Promise<Result<{ filename: string; markdown: string }>>; |
| 572 | /** Every page in a space as Markdown, at paths that follow the tree. */ |
| 573 | exportSpace(workspace: string, spaceId: string, viewer: User): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>>; |
| 574 | |
| 575 | suggestions(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>; |
| 576 | /** Accept (applies it to the live document, attributed to both) or reject. Edit role. */ |
| 577 | decideSuggestion(workspace: string, suggestionId: string, viewer: User, decision: "accept" | "reject"): Promise<Result<DocSuggestion>>; |
| 578 | acceptAll(workspace: string, pageId: string, viewer: User): Promise<Result<DocSuggestion[]>>; |
| 579 | |
| 580 | /** A comment operation from the editor; comment role (deleting others' comments or threads needs edit). */ |
| 581 | thread(workspace: string, pageId: string, viewer: User, action: DocThreadAction): Promise<Result<unknown>>; |
| 582 | threads(workspace: string, pageId: string, viewer: User): Promise<Result<DocThread[]>>; |
| 583 | |
| 584 | /** Clears "possibly out of date": the page says what the code does now. Edit role. */ |
| 585 | markCurrent(workspace: string, pageId: string, viewer: User): Promise<Result<boolean>>; |
| 586 | /** Pages the viewer can read that are possibly out of date, most recently flagged first; `repo` (`owner/name`) narrows to changes there. */ |
| 587 | stalePages(workspace: string, viewer: User, options?: { repo?: string | null }): Promise<Result<DocPage[]>>; |
| 588 | |
| 589 | /** |
| 590 | * Shows a repository's `docs/` folder and README.md in Docs, read from |
| 591 | * its default branch. Any member who can read the repository may; the |
| 592 | * files are read at once and again on every push to the default branch. |
| 593 | */ |
| 594 | addRepoSpace(workspace: string, viewer: User, repo: string): Promise<Result<DocRepoSpace>>; |
| 595 | /** Stops showing it. Whoever added it, or a workspace owner. */ |
| 596 | removeRepoSpace(workspace: string, viewer: User, id: string): Promise<Result<boolean>>; |
| 597 | /** One file of a project's docs, for a viewer who can read the repository; not found otherwise. */ |
| 598 | repoPage(workspace: string, viewer: User, repo: string, path: string): Promise<Result<DocRepoPage>>; |
| 599 | /** |
| 600 | * Indexes the workspace's pages and projects' docs for agents' recall |
| 601 | * again, in batches in the background. Workspace owners. True when a run |
| 602 | * started (false: one is already going). |
| 603 | */ |
| 604 | reindexDocs(workspace: string, viewer: User): Promise<Result<boolean>>; |
| 605 | |
| 606 | }; |
| 607 | |
| 608 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 609 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 610 | method: "POST", |
| 611 | headers: { "content-type": "application/json" }, |
| 612 | body: JSON.stringify(args), |
| 613 | }); |
| 614 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 615 | return (await response.json()) as T; |
| 616 | } |
| 617 | |
| 618 | export function docsClient(service: ServiceBinding): DocsApi { |
| 619 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); |
| 620 | return { |
| 621 | sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }), |
| 622 | home: (workspace, viewer, options) => call("home", { workspace, viewer, project: options?.project ?? null }), |
| 623 | space: (workspace, spaceSlug, viewer) => call("space", { workspace, space: spaceSlug, viewer }), |
| 624 | spaces: (workspace, viewer) => call("spaces", { workspace, viewer }), |
| 625 | createSpace: (workspace, viewer, input) => call("create_space", { workspace, viewer, input }), |
| 626 | updateSpace: (workspace, spaceId, viewer, change) => call("update_space", { workspace, space_id: spaceId, viewer, change }), |
| 627 | setSpaceMember: (workspace, spaceId, viewer, member, role) => call("set_space_member", { workspace, space_id: spaceId, viewer, member, role }), |
| 628 | page: (workspace, pageId, viewer) => call("page", { workspace, page_id: pageId, viewer }), |
| 629 | createPage: (workspace, viewer, input) => call("create_page", { workspace, viewer, input }), |
| 630 | updatePage: (workspace, pageId, viewer, change) => call("update_page", { workspace, page_id: pageId, viewer, change }), |
| 631 | movePage: (workspace, pageId, viewer, move) => call("move_page", { workspace, page_id: pageId, viewer, move }), |
| 632 | duplicatePage: (workspace, pageId, viewer) => call("duplicate_page", { workspace, page_id: pageId, viewer }), |
| 633 | archivePage: (workspace, pageId, viewer) => call("archive_page", { workspace, page_id: pageId, viewer }), |
| 634 | restorePage: (workspace, pageId, viewer) => call("restore_page", { workspace, page_id: pageId, viewer }), |
| 635 | deletePage: (workspace, pageId, viewer) => call("delete_page", { workspace, page_id: pageId, viewer }), |
| 636 | trash: (workspace, viewer) => call("trash", { workspace, viewer }), |
| 637 | favorite: (workspace, pageId, viewer, on) => call("favorite", { workspace, page_id: pageId, viewer, on }), |
| 638 | search: (workspace, viewer, query) => call("search", { workspace, viewer, query }), |
| 639 | versions: (workspace, pageId, viewer) => call("versions", { workspace, page_id: pageId, viewer }), |
| 640 | version: (workspace, pageId, versionId, viewer) => call("version", { workspace, page_id: pageId, version_id: versionId, viewer }), |
| 641 | restoreVersion: (workspace, pageId, versionId, viewer) => call("restore_version", { workspace, page_id: pageId, version_id: versionId, viewer }), |
| 642 | templates: (workspace, viewer) => call("templates", { workspace, viewer }), |
| 643 | saveTemplate: (workspace, viewer, input) => call("save_template", { workspace, viewer, input }), |
| 644 | deleteTemplate: (workspace, templateId, viewer) => call("delete_template", { workspace, template_id: templateId, viewer }), |
| 645 | exportPage: (workspace, pageId, viewer) => call("export_page", { workspace, page_id: pageId, viewer }), |
| 646 | exportSpace: (workspace, spaceId, viewer) => call("export_space", { workspace, space_id: spaceId, viewer }), |
| 647 | suggestions: (workspace, pageId, viewer) => call("suggestions", { workspace, page_id: pageId, viewer }), |
| 648 | decideSuggestion: (workspace, suggestionId, viewer, decision) => call("decide_suggestion", { workspace, suggestion_id: suggestionId, viewer, decision }), |
| 649 | acceptAll: (workspace, pageId, viewer) => call("accept_all", { workspace, page_id: pageId, viewer }), |
| 650 | thread: (workspace, pageId, viewer, action) => call("thread", { workspace, page_id: pageId, viewer, action }), |
| 651 | threads: (workspace, pageId, viewer) => call("threads", { workspace, page_id: pageId, viewer }), |
| 652 | markCurrent: (workspace, pageId, viewer) => call("mark_current", { workspace, page_id: pageId, viewer }), |
| 653 | stalePages: (workspace, viewer, options) => call("stale_pages", { workspace, viewer, repo: options?.repo ?? null }), |
| 654 | addRepoSpace: (workspace, viewer, repo) => call("add_repo_space", { workspace, viewer, repo }), |
| 655 | removeRepoSpace: (workspace, viewer, id) => call("remove_repo_space", { workspace, viewer, id }), |
| 656 | repoPage: (workspace, viewer, repo, path) => call("repo_page", { workspace, viewer, repo, path }), |
| 657 | reindexDocs: (workspace, viewer) => call("reindex_docs", { workspace, viewer }), |
| 658 | }; |
| 659 | } |