Skip to content
839 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;
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
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.608// ── 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 yet609
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.610/** 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 yet611export 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",
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.663 "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 yet664 "recall_folios_for_agent",
665 "stale_folios_for_agent",
666 "mark_folio_current",
667 "reindex_folios",
668] as const;
669
670export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number];
671
672export type FoliosApi = {
673 // ── The site, and the API acting for a person ───────────────────────
674 list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
675 sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
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.676 /**
677 * A folio and the viewer's role in it; records the visit (which is what
678 * makes a link folio readable). Not found when they can't read it. A
679 * `peek` (chat's card for a link) records nothing, so it finds a link
680 * folio only once they have opened it, and never one in the trash.
681 */
682 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.683 /** As `folio`, with what its page shows around it. Records the visit too. */
684 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 yet685 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
686 update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
687 /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
688 move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>;
689 duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
690 /** To the trash, with everything under it. */
691 trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
692 restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
693 /** For good: only from the trash, manage role. */
694 delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
695 trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
696 favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
697 /** The folio in its agent form, for a person or their token. */
698 content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
699 /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */
700 edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
701
702 access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>;
703 /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */
704 changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>;
705 /** Asks the owner and managers for access; they get an inbox item. */
706 requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
707 joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
708 leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
709
710 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[]>>;
711 versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
712 version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>;
713 restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>;
714 templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>;
715 saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>;
716 deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>;
717 export(workspace: string, viewer: User, folioId: string, format?: "markdown" | "json" | null): Promise<Result<{ filename: string; content_type: string; body: string }>>;
718
719 suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>;
720 decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>;
721 proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>;
722 decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>;
723 thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>;
724 threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>;
725
726 /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */
727 queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>;
728 /** A query as the viewer, for the API. */
729 queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>;
730
731 // ── Agents (services/agents) ────────────────────────────────────────
732 //
733 // Each takes the agent and the person it acts for (`viewer`). The agent
734 // never reads or changes more than that person can, narrowed to what
735 // everyone in `audience` can read. A folio it may not read is not found.
736
737 foliosForAgent(workspace: string, agentId: string, viewer: User, query: FolioListQuery, audience?: FolioAudience | null): Promise<Result<FolioList>>;
738 readForAgent(workspace: string, agentId: string, viewer: User, folioId: string, audience?: FolioAudience | null): Promise<Result<FolioAgentRead>>;
739 /**
740 * A new folio: the viewer owns it, the agent made it and gets `edit` on
741 * it. `where` is a space the viewer can edit, `private`, or
742 * `conversation` (Private plus `view` for these people).
743 */
744 createAsAgent(
745 workspace: string,
746 agentId: string,
747 viewer: User,
748 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 },
749 ): Promise<Result<FolioRef>>;
750 editAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
751 /** `view` or `comment` for people already in the conversation, when the viewer has `manage`. Never general access, `edit` or `manage`. */
752 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.753 /**
754 * A file an agent made (`make_file`: a PDF, a Word document, a
755 * spreadsheet) kept with a folio the viewer can edit, as people's
756 * uploads are: served from the usercontent origin at `url`
757 * (`/docs-files/<key>`, under that origin). `data` is the file in base64.
758 */
759 attachAsAgent(
760 workspace: string,
761 agentId: string,
762 viewer: User,
763 folioId: string,
764 file: { name: string; content_type: string; data: string },
765 ): 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 yet766 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[]>>;
767 staleForAgent(workspace: string, agentId: string, viewer: User, options?: { repo?: string | null; since?: string | null }, audience?: FolioAudience | null): Promise<Result<Folio[]>>;
768 /** As the asker narrowed to repositories every audience member can read; spend only when the audience is the asker alone. */
769 queryDatasetForAgent(workspace: string, agentId: string, viewer: User, input: { query: DatasetQuery } | { folio_id: string; tile_id: string }, audience?: FolioAudience | null): Promise<Result<DatasetResult>>;
770 markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
771 /** Indexes the workspace's folios again in the background. Workspace owners. */
772 reindex(workspace: string, viewer: User): Promise<Result<boolean>>;
773};
774
775export function foliosClient(service: ServiceBinding): FoliosApi {
776 const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => {
777 const response = await service.fetch(`https://service/rpc/${method}`, {
778 method: "POST",
779 headers: { "content-type": "application/json" },
780 body: JSON.stringify(args),
781 });
782 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
783 return (await response.json()) as T;
784 };
785 return {
786 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
787 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.788 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.789 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 yet790 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
791 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
792 move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
793 duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }),
794 trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }),
795 restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
796 delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
797 trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
798 favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
799 content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
800 edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
801 access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }),
802 changeAccess: (workspace, viewer, folioId, change) =>
803 call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }),
804 requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
805 joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
806 leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
807 search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
808 versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
809 version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
810 restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
811 templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }),
812 saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }),
813 deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }),
814 export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }),
815 suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }),
816 decideSuggestion: (workspace, viewer, suggestionId, decision) => call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }),
817 proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }),
818 decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }),
819 thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }),
820 threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }),
821 queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }),
822 queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }),
823 foliosForAgent: (workspace, agentId, viewer, query, audience) => call("folios_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }),
824 readForAgent: (workspace, agentId, viewer, folioId, audience) => call("read_folio_for_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, audience: audience ?? null }),
825 createAsAgent: (workspace, agentId, viewer, input) => call("create_folio_as_agent", { workspace, agent_id: agentId, viewer, input }),
826 editAsAgent: (workspace, agentId, viewer, folioId, edit) => call("edit_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, edit }),
827 shareAsAgent: (workspace, agentId, viewer, folioId, input, audience) =>
828 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.829 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 yet830 recallForAgent: (workspace, agentId, viewer, input, audience) =>
831 call("recall_folios_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
832 staleForAgent: (workspace, agentId, viewer, options, audience) =>
833 call("stale_folios_for_agent", { workspace, agent_id: agentId, viewer, repo: options?.repo ?? null, since: options?.since ?? null, audience: audience ?? null }),
834 queryDatasetForAgent: (workspace, agentId, viewer, input, audience) =>
835 call("query_dataset_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }),
836 markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }),
837 reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }),
838 };
839}