Skip to content
820 linesCodeBlameRaw
1/**
2 * Folios: what people call artifacts. Artifacts mode is one mode for docs,
3 * slides, designs and dashboards, each private, shared with people and
4 * agents, in a space or open to the workspace, and edited live together.
5 * Kept by the docs service (`services/docs`). Plan and decisions:
6 * docs/ARTIFACTS_MODE.md.
7 *
8 * Naming: code says "folio", people see "artifact" (UI text, URLs
9 * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the
10 * `artifacts:*` scopes). The Cloudflare Artifacts git store and workflow
11 * run artifacts are something else and keep their names.
12 *
13 * Wire shapes are snake_case end to end. Each kind's own model is in its
14 * own file: folios-slides.ts, folios-design.ts, folios-dashboard.ts.
15 * `crates/contracts/src/folios.rs` mirrors what the Rust API needs, and a
16 * Rust test runs both validators over `folios.fixtures.json`.
17 *
18 * Access, in one place (section 2.1 of the plan):
19 *
20 * - Every folio has one owner, always a person, who has `manage`.
21 * - It sits in a space, or in its owner's Private section (`space` null).
22 * - A person's role is the highest of: owner; explicit grants on it or an
23 * ancestor it inherits from; their space role when it inherits up to a
24 * folio in a space; and the general access of its access root
25 * (`workspace`: every member; `link`: members who opened the link).
26 * - `inherit: false` makes a folio its own access root ("Only people
27 * invited"). "Private" (the lock) is computed: only the owner can read it.
28 * - An agent's role is never higher than its asker's, narrowed to what
29 * every person in the audience can read.
30 */
31import type { MemberProfile } from "./chat";
32import type { ServiceBinding } from "./clients";
33import type { DatasetQuery, DatasetResult } from "./datasets";
34import type {
35 DocAgentAbilities,
36 DocAgentMode,
37 DocAudience,
38 DocBlockOutline,
39 DocDiffLine,
40 DocEditTarget,
41 DocRepoSpace,
42 DocRole,
43 DocSpace,
44 DocSpaceKind,
45 DocSuggestion,
46 DocThread,
47 DocThreadAction,
48} from "./docs";
49import type { DashboardOp, DashboardPreview } from "./folios-dashboard";
50import type { DesignOp, DesignPreview } from "./folios-design";
51import type { SlidesOp, SlidesPreview } from "./folios-slides";
52import type { User } from "./identity";
53import type { Result } from "./result";
54
55// ── Kinds ─────────────────────────────────────────────────────────────
56
57export type FolioKind = "doc" | "slides" | "design" | "dashboard";
58export const FOLIO_KINDS: readonly FolioKind[] = ["doc", "slides", "design", "dashboard"];
59
60/** The kinds as the Artifacts home's tiles name them. */
61export const FOLIO_KIND_LABELS: Record<FolioKind, string> = {
62 doc: "Docs",
63 slides: "Slides",
64 design: "Design",
65 dashboard: "Dashboard",
66};
67
68/** One of a kind, in a sentence: "a doc", "a deck"... */
69export const FOLIO_KIND_NOUNS: Record<FolioKind, string> = {
70 doc: "doc",
71 slides: "deck",
72 design: "design",
73 dashboard: "dashboard",
74};
75
76export function isFolioKind(value: unknown): value is FolioKind {
77 return typeof value === "string" && (FOLIO_KINDS as readonly string[]).includes(value);
78}
79
80/** Only a doc holds other folios: a doc with children is the folder. */
81export function folioCanHaveChildren(kind: FolioKind): boolean {
82 return kind === "doc";
83}
84
85// ── Roles and access ──────────────────────────────────────────────────
86
87/** What someone may do with a folio, weakest first. The same roles as a space's. */
88export type FolioRole = DocRole;
89export const FOLIO_ROLES: readonly FolioRole[] = ["view", "comment", "edit", "manage"];
90
91/** Who may open a folio besides its owner, grants and space. */
92export type FolioGeneralAccess = "none" | "workspace" | "link";
93export const FOLIO_GENERAL_ACCESS: readonly FolioGeneralAccess[] = ["none", "workspace", "link"];
94
95export const FOLIO_GENERAL_ACCESS_LABELS: Record<FolioGeneralAccess, string> = {
96 none: "Restricted",
97 workspace: "Everyone in the workspace",
98 link: "Anyone in the workspace with the link",
99};
100
101/** General access never gives `manage`. */
102export type FolioGeneralRole = Exclude<FolioRole, "manage">;
103export const FOLIO_GENERAL_ROLES: readonly FolioGeneralRole[] = ["view", "comment", "edit"];
104
105/** Space kinds as Artifacts names them (the database keeps workspace / team / private). */
106export const FOLIO_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = {
107 workspace: "Open",
108 team: "Team",
109 private: "Members only",
110};
111
112/** Who a grant names: `user:<id>`, `agent:<id>` or `team:<slug>`. */
113export type FolioPrincipal = string;
114
115const PRINCIPAL = /^(user|agent|team):[A-Za-z0-9_.-]{1,64}$/;
116
117export function isFolioPrincipal(value: unknown): value is FolioPrincipal {
118 return typeof value === "string" && PRINCIPAL.test(value);
119}
120
121/** The deepest a folio tree goes. */
122export const FOLIO_MAX_DEPTH = 10;
123/** The longest title, in characters. */
124export const FOLIO_MAX_TITLE = 200;
125/** The most people a folio is shared with in one change. */
126export const FOLIO_MAX_SHARE = 50;
127/** A subtree larger than this has its access rebuilt by a queue job, not inline. */
128export const FOLIO_INLINE_REACL = 2_000;
129
130// ── Addresses ─────────────────────────────────────────────────────────
131
132const FOLIO_ID = /(fol_[0-9a-hjkmnp-tv-z]{26})$/;
133
134/** The words of a title as an address: `q4-roadmap`. At most 50 characters. */
135export function folioTitleSlug(title: string): string {
136 return title
137 .normalize("NFKD")
138 .replace(/[̀-ͯ]/g, "")
139 .toLowerCase()
140 .replace(/[^a-z0-9]+/g, "-")
141 .replace(/^-+|-+$/g, "")
142 .slice(0, 50)
143 .replace(/-+$/g, "");
144}
145
146/** A folio's last address segment, `<title-slug>-<id>`: only the id is read, so renames keep links working. */
147export function folioSlug(title: string, id: string): string {
148 const words = folioTitleSlug(title);
149 return words ? `${words}-${id}` : id;
150}
151
152/** A folio's address. Flat: moving it between spaces and Private never breaks a link. */
153export function folioPath(workspace: string, title: string, id: string): string {
154 return `/${workspace}/-/artifacts/${folioSlug(title, id)}`;
155}
156
157/** The folio id at the end of an address segment, or null. */
158export function folioIdFrom(segment: string | null | undefined): string | null {
159 return FOLIO_ID.exec(String(segment ?? ""))?.[1] ?? null;
160}
161
162/** Every folio id a text links to, for backlinks. */
163export function linkedFolioIds(text: string): string[] {
164 return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])];
165}
166
167/** Segments the site's routes use under `-/artifacts/`: never a folio. */
168export const FOLIO_ROUTE_SEGMENTS: readonly string[] = ["live", "api", "query", "threads", "upload", "export", "new", "templates", "trash", "stale", "spaces", "repo"];
169
170// ── Folios ────────────────────────────────────────────────────────────
171
172/** Enough to link to a folio. */
173export type FolioRef = {
174 id: string;
175 kind: FolioKind;
176 title: string;
177 /** An emoji, or null for the kind's icon. */
178 icon: string | null;
179 /** `<title-slug>-<id>`. */
180 slug: string;
181 /** `/<ws>/-/artifacts/<slug>`. */
182 path: string;
183};
184
185/** The space a folio sits in. */
186export type FolioSpaceRef = { id: string; slug: string; name: string; kind: DocSpaceKind };
187
188/** Where a folio's access comes from when it inherits. */
189export type FolioInheritedFrom = { kind: "space" | "folio"; id: string; name: string };
190
191/** What a card draws: written when the folio is saved, never data values. */
192export type FolioPreview = { kind: "doc"; lines: string[] } | SlidesPreview | DesignPreview | DashboardPreview;
193
194/** A folio as lists and its page show it, for one viewer. */
195export type Folio = FolioRef & {
196 workspace_id: string;
197 /** Null: its owner's Private section. */
198 space: FolioSpaceRef | null;
199 parent_id: string | null;
200 position: number;
201 owner: MemberProfile;
202 created_by: MemberProfile;
203 created_at: string;
204 /** Any change: rename, move, share. */
205 updated_at: string;
206 /** Content changes: "Edited 45m ago". */
207 edited_by: MemberProfile | null;
208 edited_at: string;
209 trashed_at: string | null;
210 viewer_role: FolioRole;
211 favorite: boolean;
212 /** Only its owner can read it (the lock). */
213 private: boolean;
214 /** How many people, agents and teams it is shared with directly. */
215 shared_count: number;
216 /** The access root's general access. */
217 general_access: FolioGeneralAccess;
218 general_role: FolioGeneralRole | null;
219 /** False: "Only people invited", its own access root. */
220 inherit: boolean;
221 inherited_from: FolioInheritedFrom | null;
222 /** How agents change it: its own, else its space's, else `suggest`. */
223 agent_mode: DocAgentMode;
224 excerpt: string;
225 preview: FolioPreview | null;
226 /** Where it was written up from, such as a chat thread, when it was. */
227 source: { title: string; href: string } | null;
228 /** Possibly out of date: code it cites changed. */
229 stale: boolean;
230 has_children: boolean;
231};
232
233/** A row of the sidebar's trees. */
234export type FolioTreeNode = {
235 id: string;
236 kind: FolioKind;
237 parent_id: string | null;
238 position: number;
239 title: string;
240 icon: string | null;
241 slug: string;
242 /** It restricts access below where it sits (a lock on the row). */
243 restricted: boolean;
244 stale?: boolean;
245};
246
247export type FolioListTab = "all" | "yours" | "shared";
248export const FOLIO_LIST_TABS: readonly FolioListTab[] = ["all", "yours", "shared"];
249
250export type FolioListQuery = {
251 tab: FolioListTab;
252 kinds?: FolioKind[] | null;
253 space_id?: string | null;
254 /** A member key, `user:<id>`. */
255 owner?: string | null;
256 /** `owner/name`. */
257 project?: string | null;
258 /** Words or meaning; hybrid search when given. */
259 q?: string | null;
260 cursor?: string | null;
261 limit?: number | null;
262};
263
264/** The most folios a page of a list holds. */
265export const FOLIO_LIST_MAX = 100;
266
267export type FolioList = { items: Folio[]; next_cursor: string | null };
268
269/** A folio as its own page opens it: the folio, its saved text, where it sits, what is in it and what links to it. */
270export type FolioPage = {
271 folio: Folio;
272 /** Its text rendition when last saved (a doc's Markdown): what shows until the live editor loads. */
273 text: string;
274 /** The docs it sits under, from the top, that the viewer can read. */
275 breadcrumbs: FolioRef[];
276 /** What sits under it (a doc's sub-pages) that the viewer can read. */
277 children: FolioRef[];
278 /** Folios the viewer can read that link to it. */
279 backlinks: FolioRef[];
280 /** A doc's open suggestions. */
281 suggestions: FolioSuggestion[];
282};
283
284export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] };
285
286export type FoliosSidebar = {
287 favorites: FolioRef[];
288 /** Joined open spaces, the viewer's team spaces and Members-only spaces. */
289 spaces: FoliosSidebarSpace[];
290 /** The viewer's own folios in no space. */
291 private_tree: FolioTreeNode[];
292 /** The tops of what is shared with the viewer: the highest ancestor they can read. */
293 shared: FolioRef[];
294 /** Projects' docs: repositories' `docs/` folders, read-only. */
295 repos: DocRepoSpace[];
296 can_create_space: boolean;
297 trash_count: number;
298 stale_count: number;
299};
300
301/** Content to start a folio with: Markdown for docs and slides, a spec for designs and dashboards. */
302export type FolioContentInput = { markdown: string } | { spec: unknown };
303
304export type NewFolio = {
305 kind: FolioKind;
306 title?: string | null;
307 icon?: string | null;
308 /** Null or absent: the creator's Private section. */
309 space_id?: string | null;
310 /** A doc to sit under. */
311 parent_id?: string | null;
312 template_id?: string | null;
313 content?: FolioContentInput | null;
314 source?: { title: string; href: string } | null;
315 share_with?: { principal: FolioPrincipal; role: FolioRole }[] | null;
316};
317
318export type FolioChange = {
319 title?: string;
320 icon?: string | null;
321 cover?: string | null;
322 projects?: string[];
323};
324
325/** Where to put a folio: a space (null: Private) and a parent doc, before a sibling or last. */
326export type FolioMove = { space_id: string | null; parent_id: string | null; before_id?: string | null };
327
328// ── Sharing ───────────────────────────────────────────────────────────
329
330/** Where a row of "Who has access" comes from. */
331export type FolioAccessSource =
332 | { kind: "owner" }
333 /** A grant on this folio: editable here. */
334 | { kind: "grant" }
335 /** A grant on a parent it inherits from: change it there. */
336 | { kind: "folio"; id: string; title: string; path: string }
337 /** The space it inherits from. */
338 | { kind: "space"; id: string; name: string };
339
340export type FolioAccessRow = {
341 principal: FolioPrincipal;
342 /** A person or agent; a team is shown by name. */
343 profile: MemberProfile | { kind: "team"; id: string; name: string; display_name: string };
344 role: FolioRole;
345 source: FolioAccessSource;
346};
347
348/** The share dialog. */
349export type FolioAccessList = {
350 folio_id: string;
351 owner: MemberProfile;
352 rows: FolioAccessRow[];
353 general_access: FolioGeneralAccess;
354 general_role: FolioGeneralRole | null;
355 inherit: boolean;
356 inherited_from: FolioInheritedFrom | null;
357 /** The folio's own setting; null follows its space's. */
358 agent_mode: DocAgentMode | null;
359 /** The viewer may change any of it. */
360 can_share: boolean;
361 /** Public links are off until a workspace turns them on (later). */
362 public_link: "off";
363};
364
365/** One change from the share dialog. */
366export type FolioAccessChange =
367 /** Share with someone, or change their role; `notify` is an optional message. */
368 | { op: "grant"; principal: FolioPrincipal; role: FolioRole; notify?: string | null }
369 | { op: "revoke"; principal: FolioPrincipal }
370 /** `none` takes no role; `workspace` and `link` take one, never `manage`. */
371 | { op: "general"; access: FolioGeneralAccess; role: FolioGeneralRole | null }
372 /** Follow the space or parent (true), or "Only people invited" (false). */
373 | { op: "inherit"; inherit: boolean }
374 | { op: "agent_mode"; agent_mode: DocAgentMode | null };
375
376// ── History, proposals, templates ─────────────────────────────────────
377
378export type FolioVersionKind = "created" | "edit" | "agent" | "suggestion" | "proposal" | "restore";
379
380export type FolioVersion = {
381 id: string;
382 folio_id: string;
383 created_at: string;
384 authors: MemberProfile[];
385 kind: FolioVersionKind;
386 note: string | null;
387};
388
389export type FolioVersionDetail = FolioVersion & {
390 /** The folio's text rendition then. */
391 text: string;
392 /** Against the version before it. */
393 diff: DocDiffLine[];
394};
395
396export type FolioProposalStatus = "open" | "accepted" | "rejected" | "stale";
397
398/** An agent's whole change to a slides deck, design or dashboard, which a person previews and applies or rejects. */
399export type FolioProposal = {
400 id: string;
401 folio_id: string;
402 author: MemberProfile;
403 asked_by: MemberProfile | null;
404 note: string | null;
405 /** "Adds slides 4–6; rewrites the title slide". */
406 summary: string;
407 status: FolioProposalStatus;
408 created_at: string;
409 decided_by: MemberProfile | null;
410 decided_at: string | null;
411};
412
413/** A doc's tracked change, as Docs has them, on a folio. */
414export type FolioSuggestion = Omit<DocSuggestion, "page_id"> & { folio_id: string };
415
416export type FolioTemplate = {
417 id: string;
418 kind: FolioKind;
419 name: string;
420 description: string;
421 icon: string | null;
422 builtin: boolean;
423 /** Markdown (doc, slides) or a JSON spec (design, dashboard). */
424 body: string;
425 created_by: MemberProfile | null;
426};
427
428// ── Live ──────────────────────────────────────────────────────────────
429
430/** JSON text frames on a folio's socket, besides the Yjs protocol's binary ones. */
431export type FoliosLiveEvent =
432 | { type: "folio.updated"; folio: Folio }
433 | { type: "folio.trashed"; folio_id: string }
434 | { type: "suggestion.created" | "suggestion.updated"; suggestion: FolioSuggestion }
435 | { type: "proposal.created" | "proposal.updated"; proposal: FolioProposal }
436 | { type: "version.created"; version: FolioVersion }
437 /** Whether it is possibly out of date changed: ask again. */
438 | { type: "folio.staleness" }
439 /** Its sharing changed: ask for the access list again to refresh badges. */
440 | { type: "folio.access" }
441 /** The viewer's role changed, or their access ended (`role` null; the socket then closes with 4403). */
442 | { type: "access"; role: FolioRole | null };
443
444// ── Agents ────────────────────────────────────────────────────────────
445
446/** Who will see what an agent says. More than 20 people reads as the workspace. */
447export type FolioAudience = DocAudience;
448
449export type FolioAgentAbilities = DocAgentAbilities;
450
451/** Options on any agent edit. */
452export type FolioEditOptions = {
453 note?: string | null;
454 /** Never edit directly: file a suggestion (doc) or a proposal (others). */
455 suggest_only?: boolean | null;
456 /** The edit brings it up to date with the code it cites. */
457 marks_current?: boolean | null;
458};
459
460/** An agent's (or a token's) change to a folio, in the kind's own terms. */
461export type FolioAgentEdit = (
462 | { kind: "doc"; target: DocEditTarget; markdown: string }
463 | { kind: "slides"; ops: SlidesOp[] }
464 | { kind: "design"; ops: DesignOp[] }
465 | { kind: "dashboard"; ops: DashboardOp[] }
466) &
467 FolioEditOptions;
468
469/** The most ops one edit carries. */
470export const FOLIO_MAX_OPS = 200;
471
472export type FolioAgentEditResult =
473 | { mode: "applied"; version_id: string | null; folio: FolioRef; summary: string }
474 | { mode: "suggested"; suggestion: FolioSuggestion; folio: FolioRef }
475 | { mode: "proposed"; proposal: FolioProposal; folio: FolioRef };
476
477/**
478 * A folio in the form an agent reads and writes: a doc's Markdown with its
479 * block ids; a deck's Markdown with slide ids; a design's node spec; a
480 * dashboard's spec (tiles and queries, never values).
481 */
482export type FolioAgentRead = {
483 folio: FolioRef & { edited_at: string };
484 space: { id: string; slug: string; name: string; agent_mode: DocAgentMode } | null;
485 /** Markdown for doc and slides; JSON text for design and dashboard. */
486 content: string;
487 /** doc: top-level blocks, for `blocks` targets. */
488 blocks?: DocBlockOutline[];
489 can: FolioAgentAbilities;
490 /** False: someone the agent is talking to can't read it, so don't quote it there. */
491 audience_can_read: boolean;
492};
493
494/** One passage recalled for an agent: part of a folio, or of a repository's docs. */
495export type FolioPassage = {
496 folio: FolioRef | null;
497 repo_file: { repo: string; path: string; href: string } | null;
498 space_name: string;
499 heading: string | null;
500 text: string;
501 score: number;
502 updated_at: string;
503 stale: boolean;
504};
505
506export type FolioSearchHit = FolioRef & {
507 space_name: string | null;
508 snippet: string;
509 edited_at: string;
510 heading?: string | null;
511 matched?: "words" | "meaning" | "both" | null;
512};
513
514// ── Validators (pure; mirrored in Rust) ───────────────────────────────
515
516function isObject(value: unknown): value is Record<string, unknown> {
517 return typeof value === "object" && value !== null && !Array.isArray(value);
518}
519
520const isRole = (value: unknown): value is FolioRole => typeof value === "string" && (FOLIO_ROLES as readonly string[]).includes(value);
521
522/** What is wrong with a new folio, or null. Whether its space and parent exist and allow it is the service's to say. */
523export function newFolioError(input: NewFolio): string | null {
524 if (!isFolioKind(input.kind)) return `There is no kind of artifact called ${String(input.kind)}.`;
525 const title = input.title ?? "";
526 if ([...title].length > FOLIO_MAX_TITLE) return `A title is at most ${FOLIO_MAX_TITLE} characters.`;
527 const content = input.content ?? null;
528 if (content !== null) {
529 const wantsMarkdown = input.kind === "doc" || input.kind === "slides";
530 if (wantsMarkdown && typeof (content as { markdown?: unknown }).markdown !== "string") return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from markdown.`;
531 if (!wantsMarkdown && !isObject((content as { spec?: unknown }).spec)) return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from a spec.`;
532 if (input.template_id != null) return "Start from a template or from content, not both.";
533 }
534 const share = input.share_with ?? [];
535 if (share.length > FOLIO_MAX_SHARE) return `Share with at most ${FOLIO_MAX_SHARE} at once.`;
536 for (const row of share) {
537 if (!isFolioPrincipal(row.principal)) return `${String(row.principal)} is not user:, agent: or team: and an id.`;
538 if (!isRole(row.role)) return `There is no role called ${String(row.role)}.`;
539 }
540 return null;
541}
542
543/** What is wrong with a change from the share dialog, or null. */
544export function folioAccessChangeError(change: FolioAccessChange): string | null {
545 switch (change.op) {
546 case "grant":
547 if (!isFolioPrincipal(change.principal)) return `${String(change.principal)} is not user:, agent: or team: and an id.`;
548 if (!isRole(change.role)) return `There is no role called ${String(change.role)}.`;
549 if (change.notify != null && [...change.notify].length > 2_000) return "A message is at most 2000 characters.";
550 return null;
551 case "revoke":
552 return isFolioPrincipal(change.principal) ? null : `${String(change.principal)} is not user:, agent: or team: and an id.`;
553 case "general":
554 if (!(FOLIO_GENERAL_ACCESS as readonly string[]).includes(change.access)) return `There is no general access called ${String(change.access)}.`;
555 if (change.access === "none") return change.role === null ? null : "Restricted takes no role.";
556 if (change.role === null) return `${FOLIO_GENERAL_ACCESS_LABELS[change.access]} needs a role.`;
557 return (FOLIO_GENERAL_ROLES as readonly string[]).includes(change.role) ? null : "General access gives view, comment or edit, never full access.";
558 case "inherit":
559 return typeof change.inherit === "boolean" ? null : "inherit is true or false.";
560 case "agent_mode":
561 return change.agent_mode === null || change.agent_mode === "suggest" || change.agent_mode === "edit" ? null : "agent_mode is suggest, edit or null.";
562 default:
563 return `There is no access change called ${String((change as { op?: unknown }).op)}.`;
564 }
565}
566
567/**
568 * What is wrong with an agent edit's envelope, or null: its kind, and a
569 * doc's target and Markdown or the other kinds' list of ops. Each op is
570 * checked by its kind's validator (`slidesOpError`, `designOpError`,
571 * `dashboardOpError`).
572 */
573export function folioAgentEditError(edit: unknown): string | null {
574 if (!isObject(edit)) return "An edit is an object.";
575 if (!isFolioKind(edit.kind)) return `There is no kind of artifact called ${String(edit.kind)}.`;
576 if (edit.note != null && typeof edit.note !== "string") return "note is text.";
577 if (edit.kind === "doc") {
578 if (typeof edit.markdown !== "string") return "A doc edit has markdown.";
579 if (edit.ops !== undefined) return "A doc edit has a target and markdown, not ops.";
580 const target = edit.target;
581 if (!isObject(target)) return "A doc edit has a target.";
582 switch (target.kind) {
583 case "append":
584 case "document":
585 return null;
586 case "section":
587 return typeof target.heading === "string" && target.heading.length > 0 ? null : "A section target names its heading.";
588 case "blocks":
589 return typeof target.from_block === "string" && typeof target.to_block === "string" ? null : "A blocks target names from_block and to_block.";
590 default:
591 return "A doc edit's target is append, document, section or blocks.";
592 }
593 }
594 if (edit.markdown !== undefined || edit.target !== undefined) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has ops, not a target and markdown.`;
595 if (!Array.isArray(edit.ops) || edit.ops.length === 0) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has a list of ops.`;
596 if (edit.ops.length > FOLIO_MAX_OPS) return `An edit has at most ${FOLIO_MAX_OPS} ops.`;
597 return edit.ops.every((op) => isObject(op) && typeof op.op === "string") ? null : "Each op is an object with an op.";
598}
599
600/** What is wrong with a list query, or null. */
601export function folioListQueryError(query: FolioListQuery): string | null {
602 if (!(FOLIO_LIST_TABS as readonly string[]).includes(query.tab)) return "tab is all, yours or shared.";
603 for (const kind of query.kinds ?? []) if (!isFolioKind(kind)) return `There is no kind of artifact called ${String(kind)}.`;
604 const limit = query.limit ?? null;
605 if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > FOLIO_LIST_MAX)) return `limit is between 1 and ${FOLIO_LIST_MAX}.`;
606 return null;
607}
608
609// ── The docs service's folio RPC ──────────────────────────────────────
610
611/** Every method the docs service answers for folios at `/rpc/<method>` (plan section 7). */
612export const FOLIO_RPC_METHODS = [
613 // Lists and navigation.
614 "folio_list",
615 "folio_sidebar",
616 "folio",
617 "folio_page",
618 // Changing folios.
619 "create_folio",
620 "update_folio",
621 "move_folio",
622 "duplicate_folio",
623 "trash_folio",
624 "restore_folio",
625 "delete_folio",
626 "folio_trash",
627 "favorite_folio",
628 // A person's (or a token's) own read and edit in the agent form.
629 "folio_content",
630 "edit_folio",
631 // Sharing and spaces.
632 "folio_access",
633 "set_folio_grant",
634 "set_folio_general_access",
635 "request_folio_access",
636 "join_space",
637 "leave_space",
638 // Search, history, templates and export.
639 "search_folios",
640 "folio_versions",
641 "folio_version",
642 "restore_folio_version",
643 "folio_templates",
644 "save_folio_template",
645 "delete_folio_template",
646 "export_folio",
647 // Suggestions, proposals and comments.
648 "folio_suggestions",
649 "decide_folio_suggestion",
650 "folio_proposals",
651 "decide_folio_proposal",
652 "folio_thread",
653 "folio_threads",
654 // Dashboards.
655 "query_tile",
656 "query_dataset",
657 "query_dataset_for_agent",
658 // Agents.
659 "folios_for_agent",
660 "read_folio_for_agent",
661 "create_folio_as_agent",
662 "edit_folio_as_agent",
663 "share_folio_as_agent",
664 "recall_folios_for_agent",
665 "stale_folios_for_agent",
666 "mark_folio_current",
667 "reindex_folios",
668] as const;
669
670export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number];
671
672export type FoliosApi = {
673 // ── The site, and the API acting for a person ───────────────────────
674 list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
675 sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
676 /** A folio and the viewer's role in it; records the visit (which is what makes a link folio readable). Not found when they can't read it. */
677 folio(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
678 /** As `folio`, with what its page shows around it. Records the visit too. */
679 page(workspace: string, viewer: User, folioId: string): Promise<Result<FolioPage>>;
680 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
681 update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
682 /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
683 move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>;
684 duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
685 /** To the trash, with everything under it. */
686 trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
687 restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
688 /** For good: only from the trash, manage role. */
689 delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
690 trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
691 favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
692 /** The folio in its agent form, for a person or their token. */
693 content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
694 /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */
695 edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
696
697 access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>;
698 /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */
699 changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>;
700 /** Asks the owner and managers for access; they get an inbox item. */
701 requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
702 joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
703 leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
704
705 search(workspace: string, viewer: User, query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }): Promise<Result<FolioSearchHit[]>>;
706 versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
707 version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>;
708 restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>;
709 templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>;
710 saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>;
711 deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>;
712 export(workspace: string, viewer: User, folioId: string, format?: "markdown" | "json" | null): Promise<Result<{ filename: string; content_type: string; body: string }>>;
713
714 suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>;
715 decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>;
716 proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>;
717 decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>;
718 thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>;
719 threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>;
720
721 /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */
722 queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>;
723 /** A query as the viewer, for the API. */
724 queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>;
725
726 // ── Agents (services/agents) ────────────────────────────────────────
727 //
728 // Each takes the agent and the person it acts for (`viewer`). The agent
729 // never reads or changes more than that person can, narrowed to what
730 // everyone in `audience` can read. A folio it may not read is not found.
731
732 foliosForAgent(workspace: string, agentId: string, viewer: User, query: FolioListQuery, audience?: FolioAudience | null): Promise<Result<FolioList>>;
733 readForAgent(workspace: string, agentId: string, viewer: User, folioId: string, audience?: FolioAudience | null): Promise<Result<FolioAgentRead>>;
734 /**
735 * A new folio: the viewer owns it, the agent made it and gets `edit` on
736 * it. `where` is a space the viewer can edit, `private`, or
737 * `conversation` (Private plus `view` for these people).
738 */
739 createAsAgent(
740 workspace: string,
741 agentId: string,
742 viewer: User,
743 input: { kind: FolioKind; title: string; content?: FolioContentInput | null; template_id?: string | null; where: { space_id: string } | "private" | { conversation: string[] }; parent_id?: string | null; source?: { title: string; href: string } | null },
744 ): Promise<Result<FolioRef>>;
745 editAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
746 /** `view` or `comment` for people already in the conversation, when the viewer has `manage`. Never general access, `edit` or `manage`. */
747 shareAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, input: { user_ids: string[]; role: "view" | "comment" }, audience: FolioAudience): Promise<Result<FolioAccessList>>;
748 recallForAgent(workspace: string, agentId: string, viewer: User, input: { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }, audience?: FolioAudience | null): Promise<Result<FolioPassage[]>>;
749 staleForAgent(workspace: string, agentId: string, viewer: User, options?: { repo?: string | null; since?: string | null }, audience?: FolioAudience | null): Promise<Result<Folio[]>>;
750 /** As the asker narrowed to repositories every audience member can read; spend only when the audience is the asker alone. */
751 queryDatasetForAgent(workspace: string, agentId: string, viewer: User, input: { query: DatasetQuery } | { folio_id: string; tile_id: string }, audience?: FolioAudience | null): Promise<Result<DatasetResult>>;
752 markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
753 /** Indexes the workspace's folios again in the background. Workspace owners. */
754 reindex(workspace: string, viewer: User): Promise<Result<boolean>>;
755};
756
757export function foliosClient(service: ServiceBinding): FoliosApi {
758 const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => {
759 const response = await service.fetch(`https://service/rpc/${method}`, {
760 method: "POST",
761 headers: { "content-type": "application/json" },
762 body: JSON.stringify(args),
763 });
764 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
765 return (await response.json()) as T;
766 };
767 return {
768 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
769 sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }),
770 folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }),
771 page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }),
772 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
773 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
774 move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
775 duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }),
776 trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }),
777 restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
778 delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
779 trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
780 favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
781 content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
782 edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
783 access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }),
784 changeAccess: (workspace, viewer, folioId, change) =>
785 call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }),
786 requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
787 joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
788 leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
789 search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
790 versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
791 version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
792 restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
793 templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }),
794 saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }),
795 deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }),
796 export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }),
797 suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }),
798 decideSuggestion: (workspace, viewer, suggestionId, decision) => call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }),
799 proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }),
800 decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }),
801 thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }),
802 threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }),
803 queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }),
804 queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }),
805 foliosForAgent: (workspace, agentId, viewer, query, audience) => call("folios_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }),
806 readForAgent: (workspace, agentId, viewer, folioId, audience) => call("read_folio_for_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, audience: audience ?? null }),
807 createAsAgent: (workspace, agentId, viewer, input) => call("create_folio_as_agent", { workspace, agent_id: agentId, viewer, input }),
808 editAsAgent: (workspace, agentId, viewer, folioId, edit) => call("edit_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, edit }),
809 shareAsAgent: (workspace, agentId, viewer, folioId, input, audience) =>
810 call("share_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, ...input, audience }),
811 recallForAgent: (workspace, agentId, viewer, input, audience) =>
812 call("recall_folios_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
813 staleForAgent: (workspace, agentId, viewer, options, audience) =>
814 call("stale_folios_for_agent", { workspace, agent_id: agentId, viewer, repo: options?.repo ?? null, since: options?.since ?? null, audience: audience ?? null }),
815 queryDatasetForAgent: (workspace, agentId, viewer, input, audience) =>
816 call("query_dataset_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
817 markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }),
818 reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }),
819 };
820}