Skip to content
140 linesCodeBlameRaw

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

The 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 */
9import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts";
10
11import { touchedPaths } from "../citations.ts";
12import type { Change, StaleEnv } from "../staleness.ts";
13import { slugOf } from "./list.ts";
14import { workspaceReadable } from "./access-store.ts";
15import { publishFolioEvent } from "./events.ts";
16import type { FolioRoom } from "./room.ts";
17
18export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> };
19
20/** Whether any live folio cites the repository. */
21export 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. */
27export 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
59type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string };
60
61async 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}