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 docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 1 | /** |
| 2 | * Folios that cite code a change touched become possibly out of date, as | |
| 3 | * Docs' pages do (src/staleness.ts, which calls this with the same change | |
| 4 | * after recording pages). Once per folio and commit; the owner hears of | |
| 5 | * it (which change, only if they can read the repository); open rooms | |
| 6 | * ask their readers to look again; `folio.stale` goes out with a title | |
| 7 | * only when the whole workspace can read the folio. | |
| 8 | */ | |
| 9 | import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts"; | |
| 10 | ||
| 11 | import { touchedPaths } from "../citations.ts"; | |
| 12 | import type { Change, StaleEnv } from "../staleness.ts"; | |
| 13 | import { slugOf } from "./list.ts"; | |
| 14 | import { workspaceReadable } from "./access-store.ts"; | |
| 15 | import { publishFolioEvent } from "./events.ts"; | |
| 16 | import type { FolioRoom } from "./room.ts"; | |
| 17 | ||
| 18 | export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> }; | |
| 19 | ||
| 20 | /** Whether any live folio cites the repository. */ | |
| 21 | export async function foliosCite(db: D1Database, repo: string): Promise<boolean> { | |
| 22 | const row = await db.prepare("SELECT 1 AS yes FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL LIMIT 1").bind(repo).first<{ yes: number }>(); | |
| 23 | return !!row; | |
| 24 | } | |
| 25 | ||
| 26 | /** Records a change against every folio whose citations it touches; returns the folios newly made stale. */ | |
| 27 | export async function recordFolioChanges(env: FolioStaleEnv, change: Change, now = new Date()): Promise<string[]> { | |
| 28 | const db = env.DB; | |
| 29 | const cited = ( | |
| 30 | await db | |
| 31 | .prepare("SELECT c.folio_id, c.path FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL") | |
| 32 | .bind(change.repo) | |
| 33 | .all<{ folio_id: string; path: string }>() | |
| 34 | ).results; | |
| 35 | const byFolio = new Map<string, { path: string }[]>(); | |
| 36 | for (const row of cited) byFolio.set(row.folio_id, [...(byFolio.get(row.folio_id) ?? []), row]); | |
| 37 | const fresh: string[] = []; | |
| 38 | const hits = new Map<string, string[]>(); | |
| 39 | for (const [folioId, rows] of byFolio) { | |
| 40 | const touched = touchedPaths(rows, change.changed); | |
| 41 | if (!touched.length) continue; | |
| 42 | hits.set(folioId, touched); | |
| 43 | const [inserted] = await db.batch<{ folio_id: string }>([ | |
| 44 | db | |
| 45 | .prepare( | |
| 46 | "INSERT INTO folio_changes (folio_id, repo, repo_id, commit_sha, pull_number, pull_title, paths, detected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (folio_id, repo, commit_sha) DO NOTHING RETURNING folio_id", | |
| 47 | ) | |
| 48 | .bind(folioId, change.repo, change.repo_id, change.commit, change.pull?.number ?? null, change.pull?.title ?? null, JSON.stringify(touched), now.toISOString()), | |
| 49 | ...(change.pull | |
| 50 | ? [db.prepare("UPDATE folio_changes SET pull_number = ?, pull_title = ? WHERE folio_id = ? AND repo = ? AND commit_sha = ?").bind(change.pull.number, change.pull.title, folioId, change.repo, change.commit)] | |
| 51 | : []), | |
| 52 | ]); | |
| 53 | if (inserted?.results.length) fresh.push(folioId); | |
| 54 | } | |
| 55 | if (fresh.length) await tellOf(env, change, fresh, hits); | |
| 56 | return fresh; | |
| 57 | } | |
| 58 | ||
| 59 | type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string }; | |
| 60 | ||
| 61 | async function tellOf(env: FolioStaleEnv, change: Change, ids: string[], hits: Map<string, string[]>): Promise<void> { | |
| 62 | const db = env.DB; | |
| 63 | const rows = (await db.prepare("SELECT id, workspace_id, kind, space_id, title, owner FROM folios WHERE id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(ids)).all<Row>()).results; | |
| 64 | const slugs = await identityClient(env.IDENTITY) | |
| 65 | .usernames([...new Set(rows.map((r) => r.workspace_id))]) | |
| 66 | .catch(() => ({}) as Record<string, string>); | |
| 67 | const ownerIds = [...new Set(rows.map((r) => r.owner).filter((k) => k.startsWith("user:")).map((k) => k.slice(5)))]; | |
| 68 | const people = ownerIds.length ? await identityClient(env.IDENTITY).usersForAudience(ownerIds).catch(() => [] as User[]) : []; | |
| 69 | const [namespace, name] = change.repo.split("/") as [string, string]; | |
| 70 | const canRead = new Set<string>(); | |
| 71 | if (env.REPOS) { | |
| 72 | await Promise.all( | |
| 73 | people.map(async (person) => { | |
| 74 | const found = await reposClient(env.REPOS!) | |
| 75 | .get({ namespace, name }, person) | |
| 76 | .catch(() => null); | |
| 77 | if (found?.ok) canRead.add(person.id); | |
| 78 | }), | |
| 79 | ); | |
| 80 | } | |
| 81 | const what = change.pull ? `${change.repo}#${change.pull.number}` : `${change.repo}@${change.commit.slice(0, 7)}`; | |
| 82 | const notify = env.NOTIFY ? notifyClient(env.NOTIFY) : null; | |
| 83 | for (const row of rows) { | |
| 84 | const slug = slugs[row.workspace_id]; | |
| 85 | if (!slug) continue; | |
| 86 | const href = `/${slug}/-/artifacts/${slugOf(row.title, row.id)}`; | |
| 87 | const paths = hits.get(row.id) ?? []; | |
| 88 | const work: Promise<unknown>[] = []; | |
| 89 | if (notify && row.owner.startsWith("user:")) { | |
| 90 | const id = row.owner.slice(5); | |
| 91 | const known = canRead.has(id); | |
| 92 | work.push( | |
| 93 | notify | |
| 94 | .notify( | |
| 95 | { user_id: id }, | |
| 96 | { | |
| 97 | id: `folio-stale:${row.id}:${change.commit}:${id}`, | |
| 98 | kind: "inbox", | |
| 99 | workspace: slug, | |
| 100 | title: `${row.title || "Untitled"} may be out of date`, | |
| 101 | body: known ? `${what} changed ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ` and ${paths.length - 3} more` : ""}` : "A change to code this cites was merged.", | |
| 102 | href, | |
| 103 | actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null }, | |
| 104 | created_at: new Date().toISOString(), | |
| 105 | }, | |
| 106 | ) | |
| 107 | .catch(() => undefined), | |
| 108 | ); | |
| 109 | } | |
| 110 | if (env.FOLIOS) { | |
| 111 | work.push( | |
| 112 | env.FOLIOS.get(env.FOLIOS.idFromName(row.id)) | |
| 113 | .notice({ type: "folio.staleness" }) | |
| 114 | .catch(() => undefined), | |
| 115 | ); | |
| 116 | } | |
| 117 | const open = await workspaceReadable(db, row.id).catch(() => false); | |
| 118 | work.push( | |
| 119 | publishFolioEvent( | |
| 120 | env.EVENTS, | |
| 121 | "folio.stale", | |
| 122 | { | |
| 123 | workspace: slug, | |
| 124 | workspaceId: row.workspace_id, | |
| 125 | folioId: row.id, | |
| 126 | kind: row.kind, | |
| 127 | spaceId: row.space_id, | |
| 128 | title: open ? row.title : null, | |
| 129 | repo: change.repo, | |
| 130 | commit: change.commit, | |
| 131 | pull: change.pull?.number ?? null, | |
| 132 | paths, | |
| 133 | owners: [row.owner], | |
| 134 | }, | |
| 135 | change.actor ? `user:${change.actor}` : null, | |
| 136 | ), | |
| 137 | ); | |
| 138 | await Promise.all(work); | |
| 139 | } | |
| 140 | } |