Skip to content
933 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.

The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.1//! Folios: what people call artifacts (Artifacts mode).
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 yet2//! One mode for docs, slides, designs and dashboards, kept by the docs
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.3//! service (`services/artifacts`, TypeScript). Code says "folio"; people see
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 yet4//! "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
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.405 /// The artifacts service method it goes to.
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 yet406 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
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.454/// kind when it is well formed. Each op is the artifacts service's to check.
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 yet455pub 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
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.498// --- 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 yet499
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.500/// Every method the artifacts service answers for folios, as `FOLIO_RPC_METHODS`
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 yet501/// in folios.ts.
The Rust list of artifact methods names attach_file_as_agent, as the TypeScript one does.502pub const FOLIO_RPC_METHODS: [&str; 48] = [
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",
The Rust list of artifact methods names attach_file_as_agent, as the TypeScript one does.546 "attach_file_as_agent",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet547 "recall_folios_for_agent",
548 "stale_folios_for_agent",
549 "mark_folio_current",
550 "reindex_folios",
551];
552
553/// `folio_list`.
554#[derive(Clone, Debug, Serialize, Deserialize)]
555pub struct FolioListArgs {
556 pub workspace: String,
557 pub viewer: User,
558 pub query: FolioListQuery,
559}
560
561/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
562/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
563#[derive(Clone, Debug, Serialize, Deserialize)]
564pub struct FolioArgs {
565 pub workspace: String,
566 pub viewer: User,
567 pub folio_id: String,
568}
569
570/// `create_folio`.
571#[derive(Clone, Debug, Serialize, Deserialize)]
572pub struct CreateFolioArgs {
573 pub workspace: String,
574 pub viewer: User,
575 pub input: NewFolio,
576}
577
578/// `update_folio`.
579#[derive(Clone, Debug, Serialize, Deserialize)]
580pub struct UpdateFolioArgs {
581 pub workspace: String,
582 pub viewer: User,
583 pub folio_id: String,
584 pub change: FolioChange,
585}
586
587/// `move_folio`.
588#[derive(Clone, Debug, Serialize, Deserialize)]
589pub struct MoveFolioArgs {
590 pub workspace: String,
591 pub viewer: User,
592 pub folio_id: String,
593 #[serde(rename = "move")]
594 pub to: FolioMove,
595}
596
597/// `edit_folio`: a `FolioAgentEdit`, as JSON.
598#[derive(Clone, Debug, Serialize, Deserialize)]
599pub struct EditFolioArgs {
600 pub workspace: String,
601 pub viewer: User,
602 pub folio_id: String,
603 pub edit: Value,
604}
605
606/// `set_folio_grant` and `set_folio_general_access` (see
607/// [`FolioAccessChange::method`]).
608#[derive(Clone, Debug, Serialize, Deserialize)]
609pub struct FolioAccessArgs {
610 pub workspace: String,
611 pub viewer: User,
612 pub folio_id: String,
613 pub change: FolioAccessChange,
614}
615
616/// `folio_version` and `restore_folio_version`.
617#[derive(Clone, Debug, Serialize, Deserialize)]
618pub struct FolioVersionArgs {
619 pub workspace: String,
620 pub viewer: User,
621 pub folio_id: String,
622 pub version_id: String,
623}
624
625/// `folio_templates`.
626#[derive(Clone, Debug, Serialize, Deserialize)]
627pub struct FolioTemplatesArgs {
628 pub workspace: String,
629 pub viewer: User,
630 pub kind: Option<FolioKind>,
631}
632
633/// `search_folios`.
634#[derive(Clone, Debug, Serialize, Deserialize)]
635pub struct SearchFoliosArgs {
636 pub workspace: String,
637 pub viewer: User,
638 pub query: FolioSearchQuery,
639}
640
641#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
642pub struct FolioSearchQuery {
643 pub q: String,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub kinds: Option<Vec<FolioKind>>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub space_id: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub project: Option<String>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub owner: Option<String>,
652 /// `words` or `hybrid`.
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub mode: Option<String>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub limit: Option<u32>,
657}
658
659/// `query_dataset`.
660#[derive(Clone, Debug, Serialize, Deserialize)]
661pub struct QueryDatasetArgs {
662 pub workspace: String,
663 pub viewer: User,
664 pub query: DatasetQuery,
665}
666
667// --- Events -------------------------------------------------------------------
668
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.669/// The `folio.*` types the artifacts service publishes, with no `repoId` on
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.670/// the event: a folio may be private, so it never reaches a repository's
671/// 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 yet672pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
673
674/// What every `folio.*` event carries (`FolioEventData` in events.ts).
675/// Event data is camelCase, as every event on the bus.
676#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
677#[serde(rename_all = "camelCase")]
678pub struct FolioEvent {
679 pub workspace: String,
680 pub workspace_id: String,
681 pub folio_id: String,
682 pub kind: FolioKind,
683 pub space_id: Option<String>,
684 /// None unless every member of the workspace can read it.
685 pub title: Option<String>,
686}
687
688/// `folio.updated`: a version.
689#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
690#[serde(rename_all = "camelCase")]
691pub struct FolioUpdated {
692 #[serde(flatten)]
693 pub folio: FolioEvent,
694 pub version_id: String,
695 /// edit, agent, suggestion, proposal or restore.
696 pub version_kind: String,
697 /// Member keys.
698 pub authors: Vec<String>,
699}
700
701/// `folio.shared`: who was given access, never content.
702#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
703#[serde(rename_all = "camelCase")]
704pub struct FolioShared {
705 #[serde(flatten)]
706 pub folio: FolioEvent,
707 pub principals: Vec<String>,
708 pub role: FolioRole,
709}
710
711/// `folio.stale`: code it cites changed.
712#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
713#[serde(rename_all = "camelCase")]
714pub struct FolioStale {
715 #[serde(flatten)]
716 pub folio: FolioEvent,
717 /// `owner/name`.
718 pub repo: String,
719 pub commit: String,
720 pub pull: Option<u32>,
721 pub paths: Vec<String>,
722 pub owners: Vec<String>,
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use serde_json::json;
729
730 const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
731 const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
732
733 fn fixtures() -> Value {
734 serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
735 }
736
737 /// The body of `export type <name> = ...` up to its closing `};`.
738 fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
739 let start = format!("export type {name} = ");
740 source
741 .split_once(&start)
742 .and_then(|(_, rest)| rest.split_once("\n};"))
743 .map(|(body, _)| body)
744 .unwrap_or_else(|| panic!("{name} in the TypeScript"))
745 }
746
747 /// Every key `value` serializes to is a field of one of the TypeScript
748 /// types named.
749 fn fields_match(value: &Value, source: &str, types: &[&str]) {
750 let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
751 for key in value.as_object().unwrap().keys() {
752 assert!(
753 bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
754 "{key} is not a field of {types:?}"
755 );
756 }
757 }
758
759 fn folio() -> Folio {
760 serde_json::from_value(json!({
761 "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
762 "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
763 "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
764 "parent_id": null, "position": 1.5,
765 "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
766 "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
767 "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
768 "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
769 "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
770 "general_access": "none", "general_role": null, "inherit": true,
771 "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
772 "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
773 "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
774 }))
775 .unwrap()
776 }
777
778 #[test]
779 fn shapes_have_the_typescript_field_names() {
780 fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
781 let new = NewFolio {
782 kind: FolioKind::Doc,
783 title: Some("Plan".to_owned()),
784 icon: Some("🗺️".to_owned()),
785 space_id: Some("spc_1".to_owned()),
786 parent_id: Some("fol_1".to_owned()),
787 template_id: Some("tpl_1".to_owned()),
788 content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
789 source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
790 share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
791 };
792 fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
793 let query = FolioListQuery {
794 tab: FolioListTab::Shared,
795 kinds: Some(vec![FolioKind::Design]),
796 space_id: Some("s".to_owned()),
797 owner: Some("user:u".to_owned()),
798 project: Some("acme/web".to_owned()),
799 q: Some("roadmap".to_owned()),
800 cursor: Some("c".to_owned()),
801 limit: Some(20),
802 };
803 fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
804 let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
805 fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
806 let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
807 assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
808 let list = FolioList { items: vec![], next_cursor: None };
809 assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
810 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 };
811 fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
812 let template = FolioTemplate {
813 id: "tpl_1".to_owned(),
814 kind: FolioKind::Dashboard,
815 name: "Engineering health".to_owned(),
816 description: String::new(),
817 icon: None,
818 builtin: true,
819 body: "{}".to_owned(),
820 created_by: None,
821 };
822 fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
823 // Every field of the TypeScript `Folio` comes back from Rust too.
824 let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
825 for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
826 for line in body.lines() {
827 let line = line.trim_start();
828 if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
829 assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
830 }
831 }
832 }
833 }
834
835 #[test]
836 fn access_changes_travel_tagged_by_op() {
837 let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
838 assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
839 assert_eq!(change.method(), "set_folio_general_access");
840 let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
841 assert_eq!(grant.method(), "set_folio_grant");
842 let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
843 assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
844 }
845
846 /// The validators agree with the TypeScript's on the shared fixtures.
847 #[test]
848 fn agrees_with_the_typescript_validators_on_the_fixtures() {
849 let fixtures = fixtures();
850 let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
851 None => assert_eq!(outcome, Ok(()), "{case}"),
852 Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
853 Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
854 };
855 for case in fixtures["new_folio"].as_array().unwrap() {
856 let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
857 check(case, outcome);
858 }
859 for case in fixtures["access_change"].as_array().unwrap() {
860 let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
861 check(case, outcome);
862 }
863 for case in fixtures["agent_edit"].as_array().unwrap() {
864 check(case, agent_edit_error(&case["edit"]).map(|_| ()));
865 }
866 for case in fixtures["folio_ids"].as_array().unwrap() {
867 assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
868 }
869 }
870
871 #[test]
872 fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
873 for kind in FolioKind::ALL {
874 assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
875 assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
876 }
877 let methods: Vec<&str> = TS
878 .split_once("export const FOLIO_RPC_METHODS = [")
879 .and_then(|(_, rest)| rest.split_once("] as const"))
880 .map(|(list, _)| list)
881 .expect("FOLIO_RPC_METHODS in folios.ts")
882 .lines()
883 .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
884 .collect();
885 assert_eq!(methods, FOLIO_RPC_METHODS);
886 for event in FOLIO_EVENTS {
887 assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
888 }
889 let event = FolioEvent {
890 workspace: "acme".to_owned(),
891 workspace_id: "wsp_1".to_owned(),
892 folio_id: "fol_1".to_owned(),
893 kind: FolioKind::Doc,
894 space_id: None,
895 title: None,
896 };
897 fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
898 }
899
900 #[test]
901 fn folio_events_read_as_published_and_name_no_repository_id() {
902 let data = json!({
903 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
904 "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
905 });
906 let stale: FolioStale = serde_json::from_value(data).unwrap();
907 assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
908 let json = serde_json::to_value(&stale).unwrap();
909 assert!(json.get("repoId").is_none());
910 let shared: FolioShared = serde_json::from_value(json!({
911 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
912 "principals": ["user:usr_2"], "role": "comment"
913 }))
914 .unwrap();
915 assert_eq!(shared.role, FolioRole::Comment);
916 // Not offered to webhooks.
917 for event in FOLIO_EVENTS {
918 assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
919 }
920 }
921
922 #[test]
923 fn principals_and_kinds() {
924 assert!(is_principal("user:usr_01jb"));
925 assert!(is_principal("team:platform-web"));
926 assert!(!is_principal("user:"));
927 assert!(!is_principal("group:x"));
928 assert!(!is_principal("user:a b"));
929 assert!(FolioKind::Doc.can_have_children());
930 assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
931 assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
932 }
933}

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