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

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