Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

This file's history is long; its oldest lines are credited to the oldest commit read.