Skip to content
846 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 artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.5 * Kept by the artifacts service (`services/artifacts`).
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;
A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.273 /**
274 * The document as last saved (a Yjs update, base64), so the editor
275 * opens from it at once and syncs the difference with its room; null
276 * when none is kept yet or it is too large to carry, and the editor
277 * waits for the room.
278 */
279 state: string | null;
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.280 /** The docs it sits under, from the top, that the viewer can read. */
281 breadcrumbs: FolioRef[];
282 /** What sits under it (a doc's sub-pages) that the viewer can read. */
283 children: FolioRef[];
284 /** Folios the viewer can read that link to it. */
285 backlinks: FolioRef[];
286 /** A doc's open suggestions. */
287 suggestions: FolioSuggestion[];
288};
289
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 yet290export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] };
291
292export type FoliosSidebar = {
293 favorites: FolioRef[];
294 /** Joined open spaces, the viewer's team spaces and Members-only spaces. */
295 spaces: FoliosSidebarSpace[];
296 /** The viewer's own folios in no space. */
297 private_tree: FolioTreeNode[];
298 /** The tops of what is shared with the viewer: the highest ancestor they can read. */
299 shared: FolioRef[];
300 /** Projects' docs: repositories' `docs/` folders, read-only. */
301 repos: DocRepoSpace[];
302 can_create_space: boolean;
303 trash_count: number;
304 stale_count: number;
305};
306
307/** Content to start a folio with: Markdown for docs and slides, a spec for designs and dashboards. */
308export type FolioContentInput = { markdown: string } | { spec: unknown };
309
310export type NewFolio = {
311 kind: FolioKind;
312 title?: string | null;
313 icon?: string | null;
314 /** Null or absent: the creator's Private section. */
315 space_id?: string | null;
316 /** A doc to sit under. */
317 parent_id?: string | null;
318 template_id?: string | null;
319 content?: FolioContentInput | null;
320 source?: { title: string; href: string } | null;
321 share_with?: { principal: FolioPrincipal; role: FolioRole }[] | null;
322};
323
324export type FolioChange = {
325 title?: string;
326 icon?: string | null;
327 cover?: string | null;
328 projects?: string[];
329};
330
331/** Where to put a folio: a space (null: Private) and a parent doc, before a sibling or last. */
332export type FolioMove = { space_id: string | null; parent_id: string | null; before_id?: string | null };
333
334// ── Sharing ───────────────────────────────────────────────────────────
335
336/** Where a row of "Who has access" comes from. */
337export type FolioAccessSource =
338 | { kind: "owner" }
339 /** A grant on this folio: editable here. */
340 | { kind: "grant" }
341 /** A grant on a parent it inherits from: change it there. */
342 | { kind: "folio"; id: string; title: string; path: string }
343 /** The space it inherits from. */
344 | { kind: "space"; id: string; name: string };
345
346export type FolioAccessRow = {
347 principal: FolioPrincipal;
348 /** A person or agent; a team is shown by name. */
349 profile: MemberProfile | { kind: "team"; id: string; name: string; display_name: string };
350 role: FolioRole;
351 source: FolioAccessSource;
352};
353
354/** The share dialog. */
355export type FolioAccessList = {
356 folio_id: string;
357 owner: MemberProfile;
358 rows: FolioAccessRow[];
359 general_access: FolioGeneralAccess;
360 general_role: FolioGeneralRole | null;
361 inherit: boolean;
362 inherited_from: FolioInheritedFrom | null;
363 /** The folio's own setting; null follows its space's. */
364 agent_mode: DocAgentMode | null;
365 /** The viewer may change any of it. */
366 can_share: boolean;
367 /** Public links are off until a workspace turns them on (later). */
368 public_link: "off";
369};
370
371/** One change from the share dialog. */
372export type FolioAccessChange =
373 /** Share with someone, or change their role; `notify` is an optional message. */
374 | { op: "grant"; principal: FolioPrincipal; role: FolioRole; notify?: string | null }
375 | { op: "revoke"; principal: FolioPrincipal }
376 /** `none` takes no role; `workspace` and `link` take one, never `manage`. */
377 | { op: "general"; access: FolioGeneralAccess; role: FolioGeneralRole | null }
378 /** Follow the space or parent (true), or "Only people invited" (false). */
379 | { op: "inherit"; inherit: boolean }
380 | { op: "agent_mode"; agent_mode: DocAgentMode | null };
381
382// ── History, proposals, templates ─────────────────────────────────────
383
384export type FolioVersionKind = "created" | "edit" | "agent" | "suggestion" | "proposal" | "restore";
385
386export type FolioVersion = {
387 id: string;
388 folio_id: string;
389 created_at: string;
390 authors: MemberProfile[];
391 kind: FolioVersionKind;
392 note: string | null;
393};
394
395export type FolioVersionDetail = FolioVersion & {
396 /** The folio's text rendition then. */
397 text: string;
398 /** Against the version before it. */
399 diff: DocDiffLine[];
400};
401
402export type FolioProposalStatus = "open" | "accepted" | "rejected" | "stale";
403
404/** An agent's whole change to a slides deck, design or dashboard, which a person previews and applies or rejects. */
405export type FolioProposal = {
406 id: string;
407 folio_id: string;
408 author: MemberProfile;
409 asked_by: MemberProfile | null;
410 note: string | null;
411 /** "Adds slides 4–6; rewrites the title slide". */
412 summary: string;
413 status: FolioProposalStatus;
414 created_at: string;
415 decided_by: MemberProfile | null;
416 decided_at: string | null;
417};
418
419/** A doc's tracked change, as Docs has them, on a folio. */
420export type FolioSuggestion = Omit<DocSuggestion, "page_id"> & { folio_id: string };
421
422export type FolioTemplate = {
423 id: string;
424 kind: FolioKind;
425 name: string;
426 description: string;
427 icon: string | null;
428 builtin: boolean;
429 /** Markdown (doc, slides) or a JSON spec (design, dashboard). */
430 body: string;
431 created_by: MemberProfile | null;
432};
433
434// ── Live ──────────────────────────────────────────────────────────────
435
436/** JSON text frames on a folio's socket, besides the Yjs protocol's binary ones. */
437export type FoliosLiveEvent =
438 | { type: "folio.updated"; folio: Folio }
439 | { type: "folio.trashed"; folio_id: string }
440 | { type: "suggestion.created" | "suggestion.updated"; suggestion: FolioSuggestion }
441 | { type: "proposal.created" | "proposal.updated"; proposal: FolioProposal }
442 | { type: "version.created"; version: FolioVersion }
443 /** Whether it is possibly out of date changed: ask again. */
444 | { type: "folio.staleness" }
445 /** Its sharing changed: ask for the access list again to refresh badges. */
446 | { type: "folio.access" }
447 /** The viewer's role changed, or their access ended (`role` null; the socket then closes with 4403). */
448 | { type: "access"; role: FolioRole | null };
449
450// ── Agents ────────────────────────────────────────────────────────────
451
452/** Who will see what an agent says. More than 20 people reads as the workspace. */
453export type FolioAudience = DocAudience;
454
455export type FolioAgentAbilities = DocAgentAbilities;
456
457/** Options on any agent edit. */
458export type FolioEditOptions = {
459 note?: string | null;
460 /** Never edit directly: file a suggestion (doc) or a proposal (others). */
461 suggest_only?: boolean | null;
462 /** The edit brings it up to date with the code it cites. */
463 marks_current?: boolean | null;
464};
465
466/** An agent's (or a token's) change to a folio, in the kind's own terms. */
467export type FolioAgentEdit = (
468 | { kind: "doc"; target: DocEditTarget; markdown: string }
469 | { kind: "slides"; ops: SlidesOp[] }
470 | { kind: "design"; ops: DesignOp[] }
471 | { kind: "dashboard"; ops: DashboardOp[] }
472) &
473 FolioEditOptions;
474
475/** The most ops one edit carries. */
476export const FOLIO_MAX_OPS = 200;
477
478export type FolioAgentEditResult =
479 | { mode: "applied"; version_id: string | null; folio: FolioRef; summary: string }
480 | { mode: "suggested"; suggestion: FolioSuggestion; folio: FolioRef }
481 | { mode: "proposed"; proposal: FolioProposal; folio: FolioRef };
482
483/**
484 * A folio in the form an agent reads and writes: a doc's Markdown with its
485 * block ids; a deck's Markdown with slide ids; a design's node spec; a
486 * dashboard's spec (tiles and queries, never values).
487 */
488export type FolioAgentRead = {
489 folio: FolioRef & { edited_at: string };
490 space: { id: string; slug: string; name: string; agent_mode: DocAgentMode } | null;
491 /** Markdown for doc and slides; JSON text for design and dashboard. */
492 content: string;
493 /** doc: top-level blocks, for `blocks` targets. */
494 blocks?: DocBlockOutline[];
495 can: FolioAgentAbilities;
496 /** False: someone the agent is talking to can't read it, so don't quote it there. */
497 audience_can_read: boolean;
498};
499
500/** One passage recalled for an agent: part of a folio, or of a repository's docs. */
501export type FolioPassage = {
502 folio: FolioRef | null;
503 repo_file: { repo: string; path: string; href: string } | null;
504 space_name: string;
505 heading: string | null;
506 text: string;
507 score: number;
508 updated_at: string;
509 stale: boolean;
510};
511
512export type FolioSearchHit = FolioRef & {
513 space_name: string | null;
514 snippet: string;
515 edited_at: string;
516 heading?: string | null;
517 matched?: "words" | "meaning" | "both" | null;
518};
519
520// ── Validators (pure; mirrored in Rust) ───────────────────────────────
521
522function isObject(value: unknown): value is Record<string, unknown> {
523 return typeof value === "object" && value !== null && !Array.isArray(value);
524}
525
526const isRole = (value: unknown): value is FolioRole => typeof value === "string" && (FOLIO_ROLES as readonly string[]).includes(value);
527
528/** What is wrong with a new folio, or null. Whether its space and parent exist and allow it is the service's to say. */
529export function newFolioError(input: NewFolio): string | null {
530 if (!isFolioKind(input.kind)) return `There is no kind of artifact called ${String(input.kind)}.`;
531 const title = input.title ?? "";
532 if ([...title].length > FOLIO_MAX_TITLE) return `A title is at most ${FOLIO_MAX_TITLE} characters.`;
533 const content = input.content ?? null;
534 if (content !== null) {
535 const wantsMarkdown = input.kind === "doc" || input.kind === "slides";
536 if (wantsMarkdown && typeof (content as { markdown?: unknown }).markdown !== "string") return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from markdown.`;
537 if (!wantsMarkdown && !isObject((content as { spec?: unknown }).spec)) return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from a spec.`;
538 if (input.template_id != null) return "Start from a template or from content, not both.";
539 }
540 const share = input.share_with ?? [];
541 if (share.length > FOLIO_MAX_SHARE) return `Share with at most ${FOLIO_MAX_SHARE} at once.`;
542 for (const row of share) {
543 if (!isFolioPrincipal(row.principal)) return `${String(row.principal)} is not user:, agent: or team: and an id.`;
544 if (!isRole(row.role)) return `There is no role called ${String(row.role)}.`;
545 }
546 return null;
547}
548
549/** What is wrong with a change from the share dialog, or null. */
550export function folioAccessChangeError(change: FolioAccessChange): string | null {
551 switch (change.op) {
552 case "grant":
553 if (!isFolioPrincipal(change.principal)) return `${String(change.principal)} is not user:, agent: or team: and an id.`;
554 if (!isRole(change.role)) return `There is no role called ${String(change.role)}.`;
555 if (change.notify != null && [...change.notify].length > 2_000) return "A message is at most 2000 characters.";
556 return null;
557 case "revoke":
558 return isFolioPrincipal(change.principal) ? null : `${String(change.principal)} is not user:, agent: or team: and an id.`;
559 case "general":
560 if (!(FOLIO_GENERAL_ACCESS as readonly string[]).includes(change.access)) return `There is no general access called ${String(change.access)}.`;
561 if (change.access === "none") return change.role === null ? null : "Restricted takes no role.";
562 if (change.role === null) return `${FOLIO_GENERAL_ACCESS_LABELS[change.access]} needs a role.`;
563 return (FOLIO_GENERAL_ROLES as readonly string[]).includes(change.role) ? null : "General access gives view, comment or edit, never full access.";
564 case "inherit":
565 return typeof change.inherit === "boolean" ? null : "inherit is true or false.";
566 case "agent_mode":
567 return change.agent_mode === null || change.agent_mode === "suggest" || change.agent_mode === "edit" ? null : "agent_mode is suggest, edit or null.";
568 default:
569 return `There is no access change called ${String((change as { op?: unknown }).op)}.`;
570 }
571}
572
573/**
574 * What is wrong with an agent edit's envelope, or null: its kind, and a
575 * doc's target and Markdown or the other kinds' list of ops. Each op is
576 * checked by its kind's validator (`slidesOpError`, `designOpError`,
577 * `dashboardOpError`).
578 */
579export function folioAgentEditError(edit: unknown): string | null {
580 if (!isObject(edit)) return "An edit is an object.";
581 if (!isFolioKind(edit.kind)) return `There is no kind of artifact called ${String(edit.kind)}.`;
582 if (edit.note != null && typeof edit.note !== "string") return "note is text.";
583 if (edit.kind === "doc") {
584 if (typeof edit.markdown !== "string") return "A doc edit has markdown.";
585 if (edit.ops !== undefined) return "A doc edit has a target and markdown, not ops.";
586 const target = edit.target;
587 if (!isObject(target)) return "A doc edit has a target.";
588 switch (target.kind) {
589 case "append":
590 case "document":
591 return null;
592 case "section":
593 return typeof target.heading === "string" && target.heading.length > 0 ? null : "A section target names its heading.";
594 case "blocks":
595 return typeof target.from_block === "string" && typeof target.to_block === "string" ? null : "A blocks target names from_block and to_block.";
596 default:
597 return "A doc edit's target is append, document, section or blocks.";
598 }
599 }
600 if (edit.markdown !== undefined || edit.target !== undefined) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has ops, not a target and markdown.`;
601 if (!Array.isArray(edit.ops) || edit.ops.length === 0) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has a list of ops.`;
602 if (edit.ops.length > FOLIO_MAX_OPS) return `An edit has at most ${FOLIO_MAX_OPS} ops.`;
603 return edit.ops.every((op) => isObject(op) && typeof op.op === "string") ? null : "Each op is an object with an op.";
604}
605
606/** What is wrong with a list query, or null. */
607export function folioListQueryError(query: FolioListQuery): string | null {
608 if (!(FOLIO_LIST_TABS as readonly string[]).includes(query.tab)) return "tab is all, yours or shared.";
609 for (const kind of query.kinds ?? []) if (!isFolioKind(kind)) return `There is no kind of artifact called ${String(kind)}.`;
610 const limit = query.limit ?? null;
611 if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > FOLIO_LIST_MAX)) return `limit is between 1 and ${FOLIO_LIST_MAX}.`;
612 return null;
613}
614
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.615// ── The artifacts service's folio RPC ──────────────────────────────────────
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 yet616
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.617/** Every method the artifacts service answers for folios at `/rpc/<method>` (plan section 7). */
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 yet618export const FOLIO_RPC_METHODS = [
619 // Lists and navigation.
620 "folio_list",
621 "folio_sidebar",
622 "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.623 "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 yet624 // Changing folios.
625 "create_folio",
626 "update_folio",
627 "move_folio",
628 "duplicate_folio",
629 "trash_folio",
630 "restore_folio",
631 "delete_folio",
632 "folio_trash",
633 "favorite_folio",
634 // A person's (or a token's) own read and edit in the agent form.
635 "folio_content",
636 "edit_folio",
637 // Sharing and spaces.
638 "folio_access",
639 "set_folio_grant",
640 "set_folio_general_access",
641 "request_folio_access",
642 "join_space",
643 "leave_space",
644 // Search, history, templates and export.
645 "search_folios",
646 "folio_versions",
647 "folio_version",
648 "restore_folio_version",
649 "folio_templates",
650 "save_folio_template",
651 "delete_folio_template",
652 "export_folio",
653 // Suggestions, proposals and comments.
654 "folio_suggestions",
655 "decide_folio_suggestion",
656 "folio_proposals",
657 "decide_folio_proposal",
658 "folio_thread",
659 "folio_threads",
660 // Dashboards.
661 "query_tile",
662 "query_dataset",
663 "query_dataset_for_agent",
664 // Agents.
665 "folios_for_agent",
666 "read_folio_for_agent",
667 "create_folio_as_agent",
668 "edit_folio_as_agent",
669 "share_folio_as_agent",
Every agent starts with g1t's foundational skills, playbooks for documents, research, data, code, communication, and files and media, each using only tools the agent already has and saying plainly what isn't possible yet: agents make PDFs, Word documents, spreadsheets, CSV and Markdown files with make_file, kept with a doc in Artifacts and served from g1tusercontent.com; an agent's Skills tab shows what each skill can do and with which tools, and owners turn a skill off per agent; the agent skills guide says how.670 "attach_file_as_agent",
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 yet671 "recall_folios_for_agent",
672 "stale_folios_for_agent",
673 "mark_folio_current",
674 "reindex_folios",
675] as const;
676
677export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number];
678
679export type FoliosApi = {
680 // ── The site, and the API acting for a person ───────────────────────
681 list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
682 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.683 /**
684 * A folio and the viewer's role in it; records the visit (which is what
685 * makes a link folio readable). Not found when they can't read it. A
686 * `peek` (chat's card for a link) records nothing, so it finds a link
687 * folio only once they have opened it, and never one in the trash.
688 */
689 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.690 /** As `folio`, with what its page shows around it. Records the visit too. */
691 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 yet692 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
693 update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
694 /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
695 move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>;
696 duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
697 /** To the trash, with everything under it. */
698 trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
699 restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
700 /** For good: only from the trash, manage role. */
701 delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
702 trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
703 favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
704 /** The folio in its agent form, for a person or their token. */
705 content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
706 /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */
707 edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
708
709 access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>;
710 /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */
711 changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>;
712 /** Asks the owner and managers for access; they get an inbox item. */
713 requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
714 joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
715 leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
716
717 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[]>>;
718 versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
719 version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>;
720 restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>;
721 templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>;
722 saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>;
723 deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>;
724 export(workspace: string, viewer: User, folioId: string, format?: "markdown" | "json" | null): Promise<Result<{ filename: string; content_type: string; body: string }>>;
725
726 suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>;
727 decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>;
728 proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>;
729 decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>;
730 thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>;
731 threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>;
732
733 /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */
734 queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>;
735 /** A query as the viewer, for the API. */
736 queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>;
737
738 // ── Agents (services/agents) ────────────────────────────────────────
739 //
740 // Each takes the agent and the person it acts for (`viewer`). The agent
741 // never reads or changes more than that person can, narrowed to what
742 // everyone in `audience` can read. A folio it may not read is not found.
743
744 foliosForAgent(workspace: string, agentId: string, viewer: User, query: FolioListQuery, audience?: FolioAudience | null): Promise<Result<FolioList>>;
745 readForAgent(workspace: string, agentId: string, viewer: User, folioId: string, audience?: FolioAudience | null): Promise<Result<FolioAgentRead>>;
746 /**
747 * A new folio: the viewer owns it, the agent made it and gets `edit` on
748 * it. `where` is a space the viewer can edit, `private`, or
749 * `conversation` (Private plus `view` for these people).
750 */
751 createAsAgent(
752 workspace: string,
753 agentId: string,
754 viewer: User,
755 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 },
756 ): Promise<Result<FolioRef>>;
757 editAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
758 /** `view` or `comment` for people already in the conversation, when the viewer has `manage`. Never general access, `edit` or `manage`. */
759 shareAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, input: { user_ids: string[]; role: "view" | "comment" }, audience: FolioAudience): Promise<Result<FolioAccessList>>;
Every agent starts with g1t's foundational skills, playbooks for documents, research, data, code, communication, and files and media, each using only tools the agent already has and saying plainly what isn't possible yet: agents make PDFs, Word documents, spreadsheets, CSV and Markdown files with make_file, kept with a doc in Artifacts and served from g1tusercontent.com; an agent's Skills tab shows what each skill can do and with which tools, and owners turn a skill off per agent; the agent skills guide says how.760 /**
761 * A file an agent made (`make_file`: a PDF, a Word document, a
762 * spreadsheet) kept with a folio the viewer can edit, as people's
763 * uploads are: served from the usercontent origin at `url`
764 * (`/docs-files/<key>`, under that origin). `data` is the file in base64.
765 */
766 attachAsAgent(
767 workspace: string,
768 agentId: string,
769 viewer: User,
770 folioId: string,
771 file: { name: string; content_type: string; data: string },
772 ): Promise<Result<{ id: string; url: string; name: string; content_type: string; bytes: number }>>;
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 yet773 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[]>>;
774 staleForAgent(workspace: string, agentId: string, viewer: User, options?: { repo?: string | null; since?: string | null }, audience?: FolioAudience | null): Promise<Result<Folio[]>>;
775 /** As the asker narrowed to repositories every audience member can read; spend only when the audience is the asker alone. */
776 queryDatasetForAgent(workspace: string, agentId: string, viewer: User, input: { query: DatasetQuery } | { folio_id: string; tile_id: string }, audience?: FolioAudience | null): Promise<Result<DatasetResult>>;
777 markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
778 /** Indexes the workspace's folios again in the background. Workspace owners. */
779 reindex(workspace: string, viewer: User): Promise<Result<boolean>>;
780};
781
782export function foliosClient(service: ServiceBinding): FoliosApi {
783 const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => {
784 const response = await service.fetch(`https://service/rpc/${method}`, {
785 method: "POST",
786 headers: { "content-type": "application/json" },
787 body: JSON.stringify(args),
788 });
789 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
790 return (await response.json()) as T;
791 };
792 return {
793 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
794 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.795 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.796 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 yet797 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
798 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
799 move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
800 duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }),
801 trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }),
802 restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
803 delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
804 trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
805 favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
806 content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
807 edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
808 access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }),
809 changeAccess: (workspace, viewer, folioId, change) =>
810 call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }),
811 requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
812 joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
813 leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
814 search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
815 versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
816 version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
817 restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
818 templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }),
819 saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }),
820 deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }),
821 export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }),
822 suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }),
823 decideSuggestion: (workspace, viewer, suggestionId, decision) => call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }),
824 proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }),
825 decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }),
826 thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }),
827 threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }),
828 queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }),
829 queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }),
830 foliosForAgent: (workspace, agentId, viewer, query, audience) => call("folios_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }),
831 readForAgent: (workspace, agentId, viewer, folioId, audience) => call("read_folio_for_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, audience: audience ?? null }),
832 createAsAgent: (workspace, agentId, viewer, input) => call("create_folio_as_agent", { workspace, agent_id: agentId, viewer, input }),
833 editAsAgent: (workspace, agentId, viewer, folioId, edit) => call("edit_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, edit }),
834 shareAsAgent: (workspace, agentId, viewer, folioId, input, audience) =>
835 call("share_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, ...input, audience }),
Every agent starts with g1t's foundational skills, playbooks for documents, research, data, code, communication, and files and media, each using only tools the agent already has and saying plainly what isn't possible yet: agents make PDFs, Word documents, spreadsheets, CSV and Markdown files with make_file, kept with a doc in Artifacts and served from g1tusercontent.com; an agent's Skills tab shows what each skill can do and with which tools, and owners turn a skill off per agent; the agent skills guide says how.836 attachAsAgent: (workspace, agentId, viewer, folioId, file) => call("attach_file_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, file }),
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 yet837 recallForAgent: (workspace, agentId, viewer, input, audience) =>
838 call("recall_folios_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
839 staleForAgent: (workspace, agentId, viewer, options, audience) =>
840 call("stale_folios_for_agent", { workspace, agent_id: agentId, viewer, repo: options?.repo ?? null, since: options?.since ?? null, audience: audience ?? null }),
841 queryDatasetForAgent: (workspace, agentId, viewer, input, audience) =>
842 call("query_dataset_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
843 markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }),
844 reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }),
845 };
846}