Skip to content
931 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//! Folios: what people call artifacts (Artifacts mode, docs/ARTIFACTS_MODE.md).
2//! One mode for docs, slides, designs and dashboards, kept by the docs
3//! service (`services/docs`, TypeScript). Code says "folio"; people see
4//! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the
5//! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run
6//! artifacts ([`crate::actions`]) are something else.
7//!
8//! This is the subset of `packages/contracts/src/folios.ts` the Rust API
9//! needs: the shapes it sends and reads back, the arguments of the docs
10//! service's RPC, the validators it runs before calling, and the `folio.*`
11//! event types. Agent edits stay JSON ([`serde_json::Value`]): the docs
12//! service applies them; [`agent_edit_error`] checks their envelope. Wire
13//! shapes are snake_case; the tests keep field names, RPC methods and
14//! validators the same as the TypeScript (`folios.fixtures.json`).
15//!
16//! Nothing uses this yet: Phase 1 publishes the events, Phase 3 the API.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::User;
22use crate::datasets::DatasetQuery;
23
24// --- Kinds and roles ----------------------------------------------------------
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum FolioKind {
29 Doc,
30 Slides,
31 Design,
32 Dashboard,
33}
34
35impl FolioKind {
36 pub const ALL: [FolioKind; 4] = [FolioKind::Doc, FolioKind::Slides, FolioKind::Design, FolioKind::Dashboard];
37
38 pub fn as_str(self) -> &'static str {
39 match self {
40 FolioKind::Doc => "doc",
41 FolioKind::Slides => "slides",
42 FolioKind::Design => "design",
43 FolioKind::Dashboard => "dashboard",
44 }
45 }
46
47 pub fn parse(text: &str) -> Option<FolioKind> {
48 FolioKind::ALL.into_iter().find(|kind| kind.as_str() == text)
49 }
50
51 /// As the Artifacts home's tiles name it.
52 pub fn label(self) -> &'static str {
53 match self {
54 FolioKind::Doc => "Docs",
55 FolioKind::Slides => "Slides",
56 FolioKind::Design => "Design",
57 FolioKind::Dashboard => "Dashboard",
58 }
59 }
60
61 /// One of it, in a sentence: "a doc", "a deck".
62 pub fn noun(self) -> &'static str {
63 match self {
64 FolioKind::Doc => "doc",
65 FolioKind::Slides => "deck",
66 FolioKind::Design => "design",
67 FolioKind::Dashboard => "dashboard",
68 }
69 }
70
71 /// Only a doc holds other folios.
72 pub fn can_have_children(self) -> bool {
73 self == FolioKind::Doc
74 }
75
76 /// Whether it starts from Markdown (doc, slides) or a JSON spec.
77 pub fn takes_markdown(self) -> bool {
78 matches!(self, FolioKind::Doc | FolioKind::Slides)
79 }
80}
81
82/// What someone may do with a folio, weakest first.
83#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum FolioRole {
86 View,
87 Comment,
88 Edit,
89 Manage,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum GeneralAccess {
95 None,
96 Workspace,
97 Link,
98}
99
100impl GeneralAccess {
101 pub fn label(self) -> &'static str {
102 match self {
103 GeneralAccess::None => "Restricted",
104 GeneralAccess::Workspace => "Everyone in the workspace",
105 GeneralAccess::Link => "Anyone in the workspace with the link",
106 }
107 }
108}
109
110/// How agents change a folio: file suggestions (or proposals), or edit.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum AgentMode {
114 Suggest,
115 Edit,
116}
117
118/// The most ops one agent edit carries.
119pub const MAX_OPS: usize = 200;
120/// The longest title, in characters.
121pub const MAX_TITLE: usize = 200;
122/// The most people a folio is shared with in one change.
123pub const MAX_SHARE: usize = 50;
124/// The most folios a page of a list holds.
125pub const LIST_MAX: u32 = 100;
126
127/// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or
128/// `team:<slug>`.
129pub fn is_principal(text: &str) -> bool {
130 let Some((kind, id)) = text.split_once(':') else { return false };
131 matches!(kind, "user" | "agent" | "team")
132 && (1..=64).contains(&id.len())
133 && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
134}
135
136/// The folio id at the end of an address segment (`q4-roadmap-fol_…`).
137pub fn folio_id_from(segment: &str) -> Option<&str> {
138 let start = segment.len().checked_sub(30)?;
139 let id = segment.get(start..)?;
140 let suffix = id.strip_prefix("fol_")?;
141 suffix
142 .bytes()
143 .all(|b| b"0123456789abcdefghjkmnpqrstvwxyz".contains(&b))
144 .then_some(id)
145}
146
147// --- Shapes -------------------------------------------------------------------
148
149/// Enough to link to a folio.
150#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
151pub struct FolioRef {
152 pub id: String,
153 pub kind: FolioKind,
154 pub title: String,
155 pub icon: Option<String>,
156 /// `<title-slug>-<id>`.
157 pub slug: String,
158 /// `/<ws>/-/artifacts/<slug>`.
159 pub path: String,
160}
161
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163pub struct FolioSpaceRef {
164 pub id: String,
165 pub slug: String,
166 pub name: String,
167 /// `workspace` (Open), `team` or `private` (Members only).
168 pub kind: String,
169}
170
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct FolioInheritedFrom {
173 /// `space` or `folio`.
174 pub kind: String,
175 pub id: String,
176 pub name: String,
177}
178
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
180pub struct FolioSource {
181 pub title: String,
182 pub href: String,
183}
184
185/// A folio for one viewer. People are member profiles (`MemberProfile` in
186/// chat.ts), kept as JSON.
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
188pub struct Folio {
189 #[serde(flatten)]
190 pub folio: FolioRef,
191 pub workspace_id: String,
192 /// None: its owner's Private section.
193 pub space: Option<FolioSpaceRef>,
194 pub parent_id: Option<String>,
195 pub position: f64,
196 pub owner: Value,
197 pub created_by: Value,
198 pub created_at: String,
199 pub updated_at: String,
200 pub edited_by: Option<Value>,
201 pub edited_at: String,
202 pub trashed_at: Option<String>,
203 pub viewer_role: FolioRole,
204 pub favorite: bool,
205 pub private: bool,
206 pub shared_count: u32,
207 pub general_access: GeneralAccess,
208 pub general_role: Option<FolioRole>,
209 pub inherit: bool,
210 pub inherited_from: Option<FolioInheritedFrom>,
211 pub agent_mode: AgentMode,
212 pub excerpt: String,
213 /// What a card draws; never data values.
214 pub preview: Option<Value>,
215 pub source: Option<FolioSource>,
216 pub stale: bool,
217 pub has_children: bool,
218}
219
220#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum FolioListTab {
223 All,
224 Yours,
225 Shared,
226}
227
228#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
229pub struct FolioListQuery {
230 pub tab: FolioListTab,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub kinds: Option<Vec<FolioKind>>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub space_id: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub owner: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub project: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub q: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub cursor: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub limit: Option<u32>,
245}
246
247impl FolioListQuery {
248 pub fn validate(&self) -> Result<(), String> {
249 match self.limit {
250 Some(limit) if !(1..=LIST_MAX).contains(&limit) => Err(format!("limit is between 1 and {LIST_MAX}.")),
251 _ => Ok(()),
252 }
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
257pub struct FolioList {
258 pub items: Vec<Folio>,
259 pub next_cursor: Option<String>,
260}
261
262/// Content to start from: `markdown` for docs and slides, `spec` for
263/// designs and dashboards (one of them; a union in TypeScript).
264#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
265pub struct FolioContentInput {
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub markdown: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub spec: Option<Value>,
270}
271
272#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
273pub struct ShareWith {
274 pub principal: String,
275 pub role: FolioRole,
276}
277
278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279pub struct NewFolio {
280 pub kind: FolioKind,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub title: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub icon: Option<String>,
285 /// None: the creator's Private section.
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub space_id: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub parent_id: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub template_id: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub content: Option<FolioContentInput>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub source: Option<FolioSource>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub share_with: Option<Vec<ShareWith>>,
298}
299
300impl NewFolio {
301 /// What is wrong with it, in the same words as `newFolioError`.
302 /// Whether its space and parent exist and allow it is the docs
303 /// service's to say.
304 pub fn validate(&self) -> Result<(), String> {
305 if self.title.as_deref().unwrap_or_default().chars().count() > MAX_TITLE {
306 return Err(format!("A title is at most {MAX_TITLE} characters."));
307 }
308 if let Some(content) = &self.content {
309 if self.kind.takes_markdown() && content.markdown.is_none() {
310 return Err(format!("A {} starts from markdown.", self.kind.noun()));
311 }
312 if !self.kind.takes_markdown() && !content.spec.as_ref().is_some_and(Value::is_object) {
313 return Err(format!("A {} starts from a spec.", self.kind.noun()));
314 }
315 if self.template_id.is_some() {
316 return Err("Start from a template or from content, not both.".to_owned());
317 }
318 }
319 let share = self.share_with.as_deref().unwrap_or_default();
320 if share.len() > MAX_SHARE {
321 return Err(format!("Share with at most {MAX_SHARE} at once."));
322 }
323 if let Some(row) = share.iter().find(|row| !is_principal(&row.principal)) {
324 return Err(format!("{} is not user:, agent: or team: and an id.", row.principal));
325 }
326 Ok(())
327 }
328}
329
330#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
331pub struct FolioChange {
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub title: Option<String>,
334 /// Some(None) clears it.
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub icon: Option<Option<String>>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub cover: Option<Option<String>>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub projects: Option<Vec<String>>,
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
344pub struct FolioMove {
345 /// None: Private.
346 pub space_id: Option<String>,
347 pub parent_id: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub before_id: Option<String>,
350}
351
352/// One change from the share dialog.
353#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
354#[serde(tag = "op", rename_all = "snake_case")]
355pub enum FolioAccessChange {
356 Grant {
357 principal: String,
358 role: FolioRole,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 notify: Option<String>,
361 },
362 Revoke {
363 principal: String,
364 },
365 General {
366 access: GeneralAccess,
367 role: Option<FolioRole>,
368 },
369 Inherit {
370 inherit: bool,
371 },
372 AgentMode {
373 agent_mode: Option<AgentMode>,
374 },
375}
376
377impl FolioAccessChange {
378 /// What is wrong with it, in the same words as `folioAccessChangeError`.
379 pub fn validate(&self) -> Result<(), String> {
380 match self {
381 FolioAccessChange::Grant { principal, notify, .. } => {
382 if !is_principal(principal) {
383 return Err(format!("{principal} is not user:, agent: or team: and an id."));
384 }
385 if notify.as_deref().is_some_and(|text| text.chars().count() > 2_000) {
386 return Err("A message is at most 2000 characters.".to_owned());
387 }
388 Ok(())
389 }
390 FolioAccessChange::Revoke { principal } if !is_principal(principal) => Err(format!("{principal} is not user:, agent: or team: and an id.")),
391 FolioAccessChange::General { access: GeneralAccess::None, role } => match role {
392 None => Ok(()),
393 Some(_) => Err("Restricted takes no role.".to_owned()),
394 },
395 FolioAccessChange::General { access, role } => match role {
396 None => Err(format!("{} needs a role.", access.label())),
397 Some(FolioRole::Manage) => Err("General access gives view, comment or edit, never full access.".to_owned()),
398 Some(_) => Ok(()),
399 },
400 _ => Ok(()),
401 }
402 }
403
404 /// The docs service method it goes to.
405 pub fn method(&self) -> &'static str {
406 match self {
407 FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant",
408 _ => "set_folio_general_access",
409 }
410 }
411}
412
413#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
414pub struct FolioVersion {
415 pub id: String,
416 pub folio_id: String,
417 pub created_at: String,
418 pub authors: Vec<Value>,
419 /// created, edit, agent, suggestion, proposal or restore.
420 pub kind: String,
421 pub note: Option<String>,
422}
423
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425pub struct FolioTemplate {
426 pub id: String,
427 pub kind: FolioKind,
428 pub name: String,
429 pub description: String,
430 pub icon: Option<String>,
431 pub builtin: bool,
432 pub body: String,
433 pub created_by: Option<Value>,
434}
435
436// --- Agent edits --------------------------------------------------------------
437
438/// A JavaScript `String(value)` of a field, for messages that must read the
439/// same as the TypeScript's.
440fn js_string(value: Option<&Value>) -> String {
441 match value {
442 None => "undefined".to_owned(),
443 Some(Value::String(text)) => text.clone(),
444 Some(Value::Object(_)) => "[object Object]".to_owned(),
445 Some(Value::Array(items)) => items.iter().map(|item| js_string(Some(item))).collect::<Vec<_>>().join(","),
446 Some(other) => other.to_string(),
447 }
448}
449
450/// What is wrong with an agent edit's envelope (`FolioAgentEdit` in
451/// folios.ts), in the same words as `folioAgentEditError`: its kind, and a
452/// doc's target and Markdown or the other kinds' list of ops. Returns the
453/// kind when it is well formed. Each op is the docs service's to check.
454pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> {
455 let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) };
456 let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else {
457 return Err(format!("There is no kind of artifact called {}.", js_string(edit.get("kind"))));
458 };
459 if edit.get("note").is_some_and(|note| !note.is_null() && !note.is_string()) {
460 return Err("note is text.".to_owned());
461 }
462 if kind == FolioKind::Doc {
463 if !edit.get("markdown").is_some_and(Value::is_string) {
464 return Err("A doc edit has markdown.".to_owned());
465 }
466 if edit.contains_key("ops") {
467 return Err("A doc edit has a target and markdown, not ops.".to_owned());
468 }
469 let Some(target) = edit.get("target").and_then(Value::as_object) else {
470 return Err("A doc edit has a target.".to_owned());
471 };
472 let text = |key: &str| target.get(key).and_then(Value::as_str);
473 return match text("kind") {
474 Some("append" | "document") => Ok(kind),
475 Some("section") if text("heading").is_some_and(|heading| !heading.is_empty()) => Ok(kind),
476 Some("section") => Err("A section target names its heading.".to_owned()),
477 Some("blocks") if text("from_block").is_some() && text("to_block").is_some() => Ok(kind),
478 Some("blocks") => Err("A blocks target names from_block and to_block.".to_owned()),
479 _ => Err("A doc edit's target is append, document, section or blocks.".to_owned()),
480 };
481 }
482 if edit.contains_key("markdown") || edit.contains_key("target") {
483 return Err(format!("A {} edit has ops, not a target and markdown.", kind.noun()));
484 }
485 let Some(ops) = edit.get("ops").and_then(Value::as_array).filter(|ops| !ops.is_empty()) else {
486 return Err(format!("A {} edit has a list of ops.", kind.noun()));
487 };
488 if ops.len() > MAX_OPS {
489 return Err(format!("An edit has at most {MAX_OPS} ops."));
490 }
491 if !ops.iter().all(|op| op.get("op").is_some_and(Value::is_string)) {
492 return Err("Each op is an object with an op.".to_owned());
493 }
494 Ok(kind)
495}
496
497// --- The docs service's folio RPC ---------------------------------------------
498
499/// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS`
500/// in folios.ts.
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.501pub const FOLIO_RPC_METHODS: [&str; 47] = [
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 yet502 "folio_list",
503 "folio_sidebar",
504 "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.505 "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 yet506 "create_folio",
507 "update_folio",
508 "move_folio",
509 "duplicate_folio",
510 "trash_folio",
511 "restore_folio",
512 "delete_folio",
513 "folio_trash",
514 "favorite_folio",
515 "folio_content",
516 "edit_folio",
517 "folio_access",
518 "set_folio_grant",
519 "set_folio_general_access",
520 "request_folio_access",
521 "join_space",
522 "leave_space",
523 "search_folios",
524 "folio_versions",
525 "folio_version",
526 "restore_folio_version",
527 "folio_templates",
528 "save_folio_template",
529 "delete_folio_template",
530 "export_folio",
531 "folio_suggestions",
532 "decide_folio_suggestion",
533 "folio_proposals",
534 "decide_folio_proposal",
535 "folio_thread",
536 "folio_threads",
537 "query_tile",
538 "query_dataset",
539 "query_dataset_for_agent",
540 "folios_for_agent",
541 "read_folio_for_agent",
542 "create_folio_as_agent",
543 "edit_folio_as_agent",
544 "share_folio_as_agent",
545 "recall_folios_for_agent",
546 "stale_folios_for_agent",
547 "mark_folio_current",
548 "reindex_folios",
549];
550
551/// `folio_list`.
552#[derive(Clone, Debug, Serialize, Deserialize)]
553pub struct FolioListArgs {
554 pub workspace: String,
555 pub viewer: User,
556 pub query: FolioListQuery,
557}
558
559/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
560/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
561#[derive(Clone, Debug, Serialize, Deserialize)]
562pub struct FolioArgs {
563 pub workspace: String,
564 pub viewer: User,
565 pub folio_id: String,
566}
567
568/// `create_folio`.
569#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct CreateFolioArgs {
571 pub workspace: String,
572 pub viewer: User,
573 pub input: NewFolio,
574}
575
576/// `update_folio`.
577#[derive(Clone, Debug, Serialize, Deserialize)]
578pub struct UpdateFolioArgs {
579 pub workspace: String,
580 pub viewer: User,
581 pub folio_id: String,
582 pub change: FolioChange,
583}
584
585/// `move_folio`.
586#[derive(Clone, Debug, Serialize, Deserialize)]
587pub struct MoveFolioArgs {
588 pub workspace: String,
589 pub viewer: User,
590 pub folio_id: String,
591 #[serde(rename = "move")]
592 pub to: FolioMove,
593}
594
595/// `edit_folio`: a `FolioAgentEdit`, as JSON.
596#[derive(Clone, Debug, Serialize, Deserialize)]
597pub struct EditFolioArgs {
598 pub workspace: String,
599 pub viewer: User,
600 pub folio_id: String,
601 pub edit: Value,
602}
603
604/// `set_folio_grant` and `set_folio_general_access` (see
605/// [`FolioAccessChange::method`]).
606#[derive(Clone, Debug, Serialize, Deserialize)]
607pub struct FolioAccessArgs {
608 pub workspace: String,
609 pub viewer: User,
610 pub folio_id: String,
611 pub change: FolioAccessChange,
612}
613
614/// `folio_version` and `restore_folio_version`.
615#[derive(Clone, Debug, Serialize, Deserialize)]
616pub struct FolioVersionArgs {
617 pub workspace: String,
618 pub viewer: User,
619 pub folio_id: String,
620 pub version_id: String,
621}
622
623/// `folio_templates`.
624#[derive(Clone, Debug, Serialize, Deserialize)]
625pub struct FolioTemplatesArgs {
626 pub workspace: String,
627 pub viewer: User,
628 pub kind: Option<FolioKind>,
629}
630
631/// `search_folios`.
632#[derive(Clone, Debug, Serialize, Deserialize)]
633pub struct SearchFoliosArgs {
634 pub workspace: String,
635 pub viewer: User,
636 pub query: FolioSearchQuery,
637}
638
639#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
640pub struct FolioSearchQuery {
641 pub q: String,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub kinds: Option<Vec<FolioKind>>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub space_id: Option<String>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub project: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub owner: Option<String>,
650 /// `words` or `hybrid`.
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub mode: Option<String>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub limit: Option<u32>,
655}
656
657/// `query_dataset`.
658#[derive(Clone, Debug, Serialize, Deserialize)]
659pub struct QueryDatasetArgs {
660 pub workspace: String,
661 pub viewer: User,
662 pub query: DatasetQuery,
663}
664
665// --- Events -------------------------------------------------------------------
666
The artifacts plan records what Phase 1 decided, self-hosting and the deploy list name the g1t-folios index and the trash cron, and folio events are listed as published but never offered to webhooks.667/// The `folio.*` types the docs service publishes, with no `repoId` on
668/// the event: a folio may be private, so it never reaches a repository's
669/// timeline or webhooks. Nothing subscribes to them yet.
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 yet670pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
671
672/// What every `folio.*` event carries (`FolioEventData` in events.ts).
673/// Event data is camelCase, as every event on the bus.
674#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct FolioEvent {
677 pub workspace: String,
678 pub workspace_id: String,
679 pub folio_id: String,
680 pub kind: FolioKind,
681 pub space_id: Option<String>,
682 /// None unless every member of the workspace can read it.
683 pub title: Option<String>,
684}
685
686/// `folio.updated`: a version.
687#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
688#[serde(rename_all = "camelCase")]
689pub struct FolioUpdated {
690 #[serde(flatten)]
691 pub folio: FolioEvent,
692 pub version_id: String,
693 /// edit, agent, suggestion, proposal or restore.
694 pub version_kind: String,
695 /// Member keys.
696 pub authors: Vec<String>,
697}
698
699/// `folio.shared`: who was given access, never content.
700#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
701#[serde(rename_all = "camelCase")]
702pub struct FolioShared {
703 #[serde(flatten)]
704 pub folio: FolioEvent,
705 pub principals: Vec<String>,
706 pub role: FolioRole,
707}
708
709/// `folio.stale`: code it cites changed.
710#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
711#[serde(rename_all = "camelCase")]
712pub struct FolioStale {
713 #[serde(flatten)]
714 pub folio: FolioEvent,
715 /// `owner/name`.
716 pub repo: String,
717 pub commit: String,
718 pub pull: Option<u32>,
719 pub paths: Vec<String>,
720 pub owners: Vec<String>,
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use serde_json::json;
727
728 const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
729 const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
730
731 fn fixtures() -> Value {
732 serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
733 }
734
735 /// The body of `export type <name> = ...` up to its closing `};`.
736 fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
737 let start = format!("export type {name} = ");
738 source
739 .split_once(&start)
740 .and_then(|(_, rest)| rest.split_once("\n};"))
741 .map(|(body, _)| body)
742 .unwrap_or_else(|| panic!("{name} in the TypeScript"))
743 }
744
745 /// Every key `value` serializes to is a field of one of the TypeScript
746 /// types named.
747 fn fields_match(value: &Value, source: &str, types: &[&str]) {
748 let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
749 for key in value.as_object().unwrap().keys() {
750 assert!(
751 bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
752 "{key} is not a field of {types:?}"
753 );
754 }
755 }
756
757 fn folio() -> Folio {
758 serde_json::from_value(json!({
759 "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
760 "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
761 "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
762 "parent_id": null, "position": 1.5,
763 "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
764 "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
765 "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
766 "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
767 "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
768 "general_access": "none", "general_role": null, "inherit": true,
769 "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
770 "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
771 "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
772 }))
773 .unwrap()
774 }
775
776 #[test]
777 fn shapes_have_the_typescript_field_names() {
778 fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
779 let new = NewFolio {
780 kind: FolioKind::Doc,
781 title: Some("Plan".to_owned()),
782 icon: Some("🗺️".to_owned()),
783 space_id: Some("spc_1".to_owned()),
784 parent_id: Some("fol_1".to_owned()),
785 template_id: Some("tpl_1".to_owned()),
786 content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
787 source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
788 share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
789 };
790 fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
791 let query = FolioListQuery {
792 tab: FolioListTab::Shared,
793 kinds: Some(vec![FolioKind::Design]),
794 space_id: Some("s".to_owned()),
795 owner: Some("user:u".to_owned()),
796 project: Some("acme/web".to_owned()),
797 q: Some("roadmap".to_owned()),
798 cursor: Some("c".to_owned()),
799 limit: Some(20),
800 };
801 fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
802 let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
803 fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
804 let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
805 assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
806 let list = FolioList { items: vec![], next_cursor: None };
807 assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
808 let version = FolioVersion { id: "ver_1".to_owned(), folio_id: "fol_1".to_owned(), created_at: "t".to_owned(), authors: vec![], kind: "edit".to_owned(), note: None };
809 fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
810 let template = FolioTemplate {
811 id: "tpl_1".to_owned(),
812 kind: FolioKind::Dashboard,
813 name: "Engineering health".to_owned(),
814 description: String::new(),
815 icon: None,
816 builtin: true,
817 body: "{}".to_owned(),
818 created_by: None,
819 };
820 fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
821 // Every field of the TypeScript `Folio` comes back from Rust too.
822 let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
823 for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
824 for line in body.lines() {
825 let line = line.trim_start();
826 if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
827 assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
828 }
829 }
830 }
831 }
832
833 #[test]
834 fn access_changes_travel_tagged_by_op() {
835 let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
836 assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
837 assert_eq!(change.method(), "set_folio_general_access");
838 let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
839 assert_eq!(grant.method(), "set_folio_grant");
840 let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
841 assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
842 }
843
844 /// The validators agree with the TypeScript's on the shared fixtures.
845 #[test]
846 fn agrees_with_the_typescript_validators_on_the_fixtures() {
847 let fixtures = fixtures();
848 let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
849 None => assert_eq!(outcome, Ok(()), "{case}"),
850 Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
851 Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
852 };
853 for case in fixtures["new_folio"].as_array().unwrap() {
854 let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
855 check(case, outcome);
856 }
857 for case in fixtures["access_change"].as_array().unwrap() {
858 let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
859 check(case, outcome);
860 }
861 for case in fixtures["agent_edit"].as_array().unwrap() {
862 check(case, agent_edit_error(&case["edit"]).map(|_| ()));
863 }
864 for case in fixtures["folio_ids"].as_array().unwrap() {
865 assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
866 }
867 }
868
869 #[test]
870 fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
871 for kind in FolioKind::ALL {
872 assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
873 assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
874 }
875 let methods: Vec<&str> = TS
876 .split_once("export const FOLIO_RPC_METHODS = [")
877 .and_then(|(_, rest)| rest.split_once("] as const"))
878 .map(|(list, _)| list)
879 .expect("FOLIO_RPC_METHODS in folios.ts")
880 .lines()
881 .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
882 .collect();
883 assert_eq!(methods, FOLIO_RPC_METHODS);
884 for event in FOLIO_EVENTS {
885 assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
886 }
887 let event = FolioEvent {
888 workspace: "acme".to_owned(),
889 workspace_id: "wsp_1".to_owned(),
890 folio_id: "fol_1".to_owned(),
891 kind: FolioKind::Doc,
892 space_id: None,
893 title: None,
894 };
895 fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
896 }
897
898 #[test]
899 fn folio_events_read_as_published_and_name_no_repository_id() {
900 let data = json!({
901 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
902 "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
903 });
904 let stale: FolioStale = serde_json::from_value(data).unwrap();
905 assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
906 let json = serde_json::to_value(&stale).unwrap();
907 assert!(json.get("repoId").is_none());
908 let shared: FolioShared = serde_json::from_value(json!({
909 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
910 "principals": ["user:usr_2"], "role": "comment"
911 }))
912 .unwrap();
913 assert_eq!(shared.role, FolioRole::Comment);
914 // Not offered to webhooks.
915 for event in FOLIO_EVENTS {
916 assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
917 }
918 }
919
920 #[test]
921 fn principals_and_kinds() {
922 assert!(is_principal("user:usr_01jb"));
923 assert!(is_principal("team:platform-web"));
924 assert!(!is_principal("user:"));
925 assert!(!is_principal("group:x"));
926 assert!(!is_principal("user:a b"));
927 assert!(FolioKind::Doc.can_have_children());
928 assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
929 assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
930 }
931}