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