Skip to content
820 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.

Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1/**
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
The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day.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
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet284export 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",
The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day.617 "folio_page",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet618 // 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>>;
The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day.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>>;
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet680 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 }),
The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day.771 page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }),
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet772 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}