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