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 yet
20 files+3559−340/20 viewed
| 119 | 119 | "token_endpoint_auth_methods_supported": ["none"], | |
| 120 | 120 | // A client may ask for some of these with `scope`; the person | |
| 121 | 121 | // approving can trim them. Asking for none gives the agent preset. | |
| 122 | − | "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(), | |
| 122 | + | "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(), | |
| 123 | 123 | "service_documentation": "https://docs.g1t.sh/guides/authentication/", | |
| 124 | 124 | }) | |
| 125 | 125 | } | |
| ⋯ | |||
| 245 | 245 | "authorization_servers": [services.addresses.api], | |
| 246 | 246 | "bearer_methods_supported": ["header"], | |
| 247 | 247 | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 248 | − | "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()).to_vec(), | |
| 248 | + | "scopes_supported": g1t_contracts::scopes::offered_scopes().iter().map(|scope| scope.as_str()).collect::<Vec<_>>(), | |
| 249 | 249 | }))? | |
| 250 | 250 | } | |
| 251 | 251 | ("POST", "/oauth/register") => register(request).await?, | |
| 1 | + | //! Datasets: the safe query layer behind dashboards (Artifacts mode, | |
| 2 | + | //! docs/ARTIFACTS_MODE.md section 3.4). Mirrors | |
| 3 | + | //! `packages/contracts/src/datasets.ts`; the tests here keep the catalog the | |
| 4 | + | //! same and run both validators over `datasets.fixtures.json`. | |
| 5 | + | //! | |
| 6 | + | //! Not SQL: a query names a dataset from a declared catalog, one measure, | |
| 7 | + | //! at most one dimension, an interval, filters on declared fields and a | |
| 8 | + | //! range. The service that owns the data ([`DatasetSpec::service`]) answers | |
| 9 | + | //! `query_dataset` for the viewer, over only what the viewer can read, with | |
| 10 | + | //! a fixed query per measure and dimension, and caps the rows. The Rust | |
| 11 | + | //! owners (work, actions, billing) validate with [`DatasetQuery::validate`] | |
| 12 | + | //! before they run anything. | |
| 13 | + | ||
| 14 | + | use serde::{Deserialize, Serialize}; | |
| 15 | + | ||
| 16 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 17 | + | #[serde(rename_all = "snake_case")] | |
| 18 | + | pub enum DatasetId { | |
| 19 | + | Issues, | |
| 20 | + | PullRequests, | |
| 21 | + | WorkflowRuns, | |
| 22 | + | Deployments, | |
| 23 | + | Spend, | |
| 24 | + | AgentSessions, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl DatasetId { | |
| 28 | + | pub const ALL: [DatasetId; 6] = [ | |
| 29 | + | DatasetId::Issues, | |
| 30 | + | DatasetId::PullRequests, | |
| 31 | + | DatasetId::WorkflowRuns, | |
| 32 | + | DatasetId::Deployments, | |
| 33 | + | DatasetId::Spend, | |
| 34 | + | DatasetId::AgentSessions, | |
| 35 | + | ]; | |
| 36 | + | ||
| 37 | + | pub fn as_str(self) -> &'static str { | |
| 38 | + | match self { | |
| 39 | + | DatasetId::Issues => "issues", | |
| 40 | + | DatasetId::PullRequests => "pull_requests", | |
| 41 | + | DatasetId::WorkflowRuns => "workflow_runs", | |
| 42 | + | DatasetId::Deployments => "deployments", | |
| 43 | + | DatasetId::Spend => "spend", | |
| 44 | + | DatasetId::AgentSessions => "agent_sessions", | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Its entry in [`DATASETS`]. | |
| 49 | + | pub fn spec(self) -> &'static DatasetSpec { | |
| 50 | + | DATASETS.iter().find(|spec| spec.id == self).expect("every dataset is in the catalog") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// How rows are summed up. `count` takes no field; `rate` a rate field; the | |
| 55 | + | /// rest a measure field. | |
| 56 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 57 | + | #[serde(rename_all = "snake_case")] | |
| 58 | + | pub enum MeasureOp { | |
| 59 | + | Count, | |
| 60 | + | Sum, | |
| 61 | + | Avg, | |
| 62 | + | P50, | |
| 63 | + | P95, | |
| 64 | + | Rate, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | impl MeasureOp { | |
| 68 | + | pub fn as_str(self) -> &'static str { | |
| 69 | + | match self { | |
| 70 | + | MeasureOp::Count => "count", | |
| 71 | + | MeasureOp::Sum => "sum", | |
| 72 | + | MeasureOp::Avg => "avg", | |
| 73 | + | MeasureOp::P50 => "p50", | |
| 74 | + | MeasureOp::P95 => "p95", | |
| 75 | + | MeasureOp::Rate => "rate", | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 81 | + | pub struct Measure { | |
| 82 | + | pub op: MeasureOp, | |
| 83 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 84 | + | pub field: Option<String>, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 88 | + | #[serde(rename_all = "snake_case")] | |
| 89 | + | pub enum Interval { | |
| 90 | + | Day, | |
| 91 | + | Week, | |
| 92 | + | Month, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 96 | + | #[serde(rename_all = "snake_case")] | |
| 97 | + | pub enum FilterOp { | |
| 98 | + | Eq, | |
| 99 | + | Neq, | |
| 100 | + | In, | |
| 101 | + | Gte, | |
| 102 | + | Lte, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | impl FilterOp { | |
| 106 | + | pub fn as_str(self) -> &'static str { | |
| 107 | + | match self { | |
| 108 | + | FilterOp::Eq => "eq", | |
| 109 | + | FilterOp::Neq => "neq", | |
| 110 | + | FilterOp::In => "in", | |
| 111 | + | FilterOp::Gte => "gte", | |
| 112 | + | FilterOp::Lte => "lte", | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Text for `eq`/`neq` on a dimension, a list for `in`, a number on a | |
| 118 | + | /// measure. | |
| 119 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 120 | + | #[serde(untagged)] | |
| 121 | + | pub enum FilterValue { | |
| 122 | + | Text(String), | |
| 123 | + | Number(f64), | |
| 124 | + | List(Vec<String>), | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 128 | + | pub struct Filter { | |
| 129 | + | pub field: String, | |
| 130 | + | pub op: FilterOp, | |
| 131 | + | pub value: FilterValue, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 135 | + | pub enum RangePreset { | |
| 136 | + | #[serde(rename = "7d")] | |
| 137 | + | Days7, | |
| 138 | + | #[serde(rename = "30d")] | |
| 139 | + | Days30, | |
| 140 | + | #[serde(rename = "90d")] | |
| 141 | + | Days90, | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | impl RangePreset { | |
| 145 | + | pub fn days(self) -> u32 { | |
| 146 | + | match self { | |
| 147 | + | RangePreset::Days7 => 7, | |
| 148 | + | RangePreset::Days30 => 30, | |
| 149 | + | RangePreset::Days90 => 90, | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// A preset counted back from now, or between two times: dates | |
| 155 | + | /// (`2026-10-01`) or RFC 3339 UTC times; `to` is exclusive. | |
| 156 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 157 | + | #[serde(untagged)] | |
| 158 | + | pub enum DatasetRange { | |
| 159 | + | Preset(RangePreset), | |
| 160 | + | Between { from: String, to: String }, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 164 | + | pub struct DatasetQuery { | |
| 165 | + | pub dataset: DatasetId, | |
| 166 | + | pub measure: Measure, | |
| 167 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 168 | + | pub group_by: Option<String>, | |
| 169 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 170 | + | pub interval: Option<Interval>, | |
| 171 | + | /// Which of the dataset's time fields the range and interval use; its | |
| 172 | + | /// first when absent. | |
| 173 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 174 | + | pub time: Option<String>, | |
| 175 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 176 | + | pub filters: Option<Vec<Filter>>, | |
| 177 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 178 | + | pub range: Option<DatasetRange>, | |
| 179 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 180 | + | pub limit: Option<u32>, | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 184 | + | #[serde(rename_all = "snake_case")] | |
| 185 | + | pub enum ColumnType { | |
| 186 | + | String, | |
| 187 | + | Number, | |
| 188 | + | Time, | |
| 189 | + | Money, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 193 | + | pub struct DatasetColumn { | |
| 194 | + | pub name: String, | |
| 195 | + | #[serde(rename = "type")] | |
| 196 | + | pub kind: ColumnType, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// What `query_dataset` answers. | |
| 200 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 201 | + | pub struct DatasetResult { | |
| 202 | + | pub columns: Vec<DatasetColumn>, | |
| 203 | + | /// Each a string, a number or null. | |
| 204 | + | pub rows: Vec<Vec<serde_json::Value>>, | |
| 205 | + | /// More rows matched than were returned. | |
| 206 | + | pub truncated: bool, | |
| 207 | + | /// The viewer cannot read everything the query covers. | |
| 208 | + | pub partial: bool, | |
| 209 | + | /// RFC 3339. | |
| 210 | + | pub as_of: String, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// The service that owns a dataset. | |
| 214 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 215 | + | pub enum DatasetService { | |
| 216 | + | Work, | |
| 217 | + | Actions, | |
| 218 | + | Deployments, | |
| 219 | + | Billing, | |
| 220 | + | Agents, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | impl DatasetService { | |
| 224 | + | pub fn as_str(self) -> &'static str { | |
| 225 | + | match self { | |
| 226 | + | DatasetService::Work => "work", | |
| 227 | + | DatasetService::Actions => "actions", | |
| 228 | + | DatasetService::Deployments => "deployments", | |
| 229 | + | DatasetService::Billing => "billing", | |
| 230 | + | DatasetService::Agents => "agents", | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// What a viewer needs to query a dataset. | |
| 236 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 237 | + | pub enum DatasetNeeds { | |
| 238 | + | Member, | |
| 239 | + | /// The workspace's billing role. | |
| 240 | + | Billing, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | impl DatasetNeeds { | |
| 244 | + | pub fn as_str(self) -> &'static str { | |
| 245 | + | match self { | |
| 246 | + | DatasetNeeds::Member => "member", | |
| 247 | + | DatasetNeeds::Billing => "billing", | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[derive(Debug)] | |
| 253 | + | pub struct DatasetSpec { | |
| 254 | + | pub id: DatasetId, | |
| 255 | + | pub label: &'static str, | |
| 256 | + | pub service: DatasetService, | |
| 257 | + | pub needs: DatasetNeeds, | |
| 258 | + | /// Time fields, the default first. | |
| 259 | + | pub times: &'static [&'static str], | |
| 260 | + | /// Text fields to group and filter by. | |
| 261 | + | pub dimensions: &'static [&'static str], | |
| 262 | + | /// Number fields for sum, avg, p50, p95, and number filters. | |
| 263 | + | pub measures: &'static [&'static str], | |
| 264 | + | /// Yes-or-no fields `rate` gives the share of. | |
| 265 | + | pub rates: &'static [&'static str], | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// The catalog. | |
| 269 | + | pub const DATASETS: [DatasetSpec; 6] = [ | |
| 270 | + | DatasetSpec { | |
| 271 | + | id: DatasetId::Issues, | |
| 272 | + | label: "Issues", | |
| 273 | + | service: DatasetService::Work, | |
| 274 | + | needs: DatasetNeeds::Member, | |
| 275 | + | times: &["created_at", "closed_at"], | |
| 276 | + | dimensions: &["repo", "label", "state", "author_kind", "assignee_kind", "milestone"], | |
| 277 | + | measures: &["time_to_close_hours", "comments"], | |
| 278 | + | rates: &["closed"], | |
| 279 | + | }, | |
| 280 | + | DatasetSpec { | |
| 281 | + | id: DatasetId::PullRequests, | |
| 282 | + | label: "Pull requests", | |
| 283 | + | service: DatasetService::Work, | |
| 284 | + | needs: DatasetNeeds::Member, | |
| 285 | + | times: &["created_at", "merged_at", "closed_at"], | |
| 286 | + | dimensions: &["repo", "label", "state", "author_kind", "base_branch"], | |
| 287 | + | measures: &["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"], | |
| 288 | + | rates: &["merged"], | |
| 289 | + | }, | |
| 290 | + | DatasetSpec { | |
| 291 | + | id: DatasetId::WorkflowRuns, | |
| 292 | + | label: "Workflow runs", | |
| 293 | + | service: DatasetService::Actions, | |
| 294 | + | needs: DatasetNeeds::Member, | |
| 295 | + | times: &["started_at", "completed_at"], | |
| 296 | + | dimensions: &["repo", "workflow", "branch", "event", "conclusion", "runner_kind"], | |
| 297 | + | measures: &["duration_seconds", "queue_seconds"], | |
| 298 | + | rates: &["succeeded"], | |
| 299 | + | }, | |
| 300 | + | DatasetSpec { | |
| 301 | + | id: DatasetId::Deployments, | |
| 302 | + | label: "Deployments", | |
| 303 | + | service: DatasetService::Deployments, | |
| 304 | + | needs: DatasetNeeds::Member, | |
| 305 | + | times: &["created_at"], | |
| 306 | + | dimensions: &["repo", "project", "environment", "state"], | |
| 307 | + | measures: &["duration_seconds", "time_to_restore_hours"], | |
| 308 | + | rates: &["failed"], | |
| 309 | + | }, | |
| 310 | + | DatasetSpec { | |
| 311 | + | id: DatasetId::Spend, | |
| 312 | + | label: "Spend", | |
| 313 | + | service: DatasetService::Billing, | |
| 314 | + | needs: DatasetNeeds::Billing, | |
| 315 | + | times: &["day"], | |
| 316 | + | dimensions: &["product", "project", "person", "model"], | |
| 317 | + | measures: &["amount_micros"], | |
| 318 | + | rates: &[], | |
| 319 | + | }, | |
| 320 | + | DatasetSpec { | |
| 321 | + | id: DatasetId::AgentSessions, | |
| 322 | + | label: "Agent sessions", | |
| 323 | + | service: DatasetService::Agents, | |
| 324 | + | needs: DatasetNeeds::Member, | |
| 325 | + | times: &["started_at"], | |
| 326 | + | dimensions: &["agent", "repo", "outcome", "model", "trigger"], | |
| 327 | + | measures: &["duration_seconds", "cost_micros", "tokens"], | |
| 328 | + | rates: &["succeeded"], | |
| 329 | + | }, | |
| 330 | + | ]; | |
| 331 | + | ||
| 332 | + | /// The most rows a query returns. | |
| 333 | + | pub const MAX_ROWS: u32 = 100; | |
| 334 | + | /// The most filters on one query. | |
| 335 | + | pub const MAX_FILTERS: usize = 10; | |
| 336 | + | /// The most values in an `in` filter. | |
| 337 | + | pub const MAX_IN_VALUES: usize = 50; | |
| 338 | + | /// The longest `{ from, to }` range, in days. | |
| 339 | + | pub const MAX_RANGE_DAYS: u64 = 366; | |
| 340 | + | ||
| 341 | + | const DAY_MS: u64 = 86_400_000; | |
| 342 | + | ||
| 343 | + | /// A range end as milliseconds since the epoch: a real date (`2026-10-01`) | |
| 344 | + | /// or an RFC 3339 UTC time with at most milliseconds. `None` otherwise. | |
| 345 | + | pub fn range_time(text: &str) -> Option<u64> { | |
| 346 | + | let bytes = text.as_bytes(); | |
| 347 | + | let digits = |range: std::ops::Range<usize>| -> Option<u64> { | |
| 348 | + | let part = text.get(range)?; | |
| 349 | + | if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { | |
| 350 | + | return None; | |
| 351 | + | } | |
| 352 | + | part.parse().ok() | |
| 353 | + | }; | |
| 354 | + | if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' { | |
| 355 | + | return None; | |
| 356 | + | } | |
| 357 | + | let (year, month, day) = (digits(0..4)?, digits(5..7)?, digits(8..10)?); | |
| 358 | + | if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) { | |
| 359 | + | return None; | |
| 360 | + | } | |
| 361 | + | let full = if bytes.len() == 10 { | |
| 362 | + | format!("{text}T00:00:00Z") | |
| 363 | + | } else { | |
| 364 | + | // T hh:mm:ss, optional .f{1,3}, Z. | |
| 365 | + | if bytes.len() < 20 || bytes[10] != b'T' || bytes[13] != b':' || bytes[16] != b':' || *bytes.last()? != b'Z' { | |
| 366 | + | return None; | |
| 367 | + | } | |
| 368 | + | let (hour, minute, second) = (digits(11..13)?, digits(14..16)?, digits(17..19)?); | |
| 369 | + | if hour > 23 || minute > 59 || second > 59 { | |
| 370 | + | return None; | |
| 371 | + | } | |
| 372 | + | match bytes.len() { | |
| 373 | + | 20 => {} | |
| 374 | + | 22..=24 if bytes[19] == b'.' => { | |
| 375 | + | digits(20..bytes.len() - 1)?; | |
| 376 | + | } | |
| 377 | + | _ => return None, | |
| 378 | + | } | |
| 379 | + | text.to_owned() | |
| 380 | + | }; | |
| 381 | + | crate::time::parse_rfc3339(&full) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | fn days_in_month(year: u64, month: u64) -> u64 { | |
| 385 | + | match month { | |
| 386 | + | 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29, | |
| 387 | + | 2 => 28, | |
| 388 | + | 4 | 6 | 9 | 11 => 30, | |
| 389 | + | _ => 31, | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | impl DatasetQuery { | |
| 394 | + | /// What is wrong with it, if anything: the same rules, and the same | |
| 395 | + | /// words, as `datasetQueryError` in TypeScript. It checks the query | |
| 396 | + | /// against the catalog only; who may run it is the owning service's to | |
| 397 | + | /// decide. | |
| 398 | + | pub fn validate(&self) -> Result<(), String> { | |
| 399 | + | let spec = self.dataset.spec(); | |
| 400 | + | let name = self.dataset.as_str(); | |
| 401 | + | let field = self.measure.field.as_deref(); | |
| 402 | + | match self.measure.op { | |
| 403 | + | MeasureOp::Count => { | |
| 404 | + | if field.is_some() { | |
| 405 | + | return Err("count takes no field.".to_owned()); | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | MeasureOp::Rate => { | |
| 409 | + | let Some(field) = field else { return Err("rate needs a field.".to_owned()) }; | |
| 410 | + | if !spec.rates.contains(&field) { | |
| 411 | + | return Err(format!("{name} has no yes-or-no field {field} to take the rate of.")); | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | op => { | |
| 415 | + | let Some(field) = field else { return Err(format!("{} needs a field.", op.as_str())) }; | |
| 416 | + | if !spec.measures.contains(&field) { | |
| 417 | + | return Err(format!("{name} has no number field {field}.")); | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | if let Some(group_by) = self.group_by.as_deref() | |
| 422 | + | && !spec.dimensions.contains(&group_by) | |
| 423 | + | { | |
| 424 | + | return Err(format!("{name} can't be grouped by {group_by}.")); | |
| 425 | + | } | |
| 426 | + | if let Some(time) = self.time.as_deref() | |
| 427 | + | && !spec.times.contains(&time) | |
| 428 | + | { | |
| 429 | + | return Err(format!("{name} has no time field {time}.")); | |
| 430 | + | } | |
| 431 | + | let filters = self.filters.as_deref().unwrap_or_default(); | |
| 432 | + | if filters.len() > MAX_FILTERS { | |
| 433 | + | return Err(format!("A query takes at most {MAX_FILTERS} filters.")); | |
| 434 | + | } | |
| 435 | + | for filter in filters { | |
| 436 | + | let (field, op) = (filter.field.as_str(), filter.op.as_str()); | |
| 437 | + | if spec.dimensions.contains(&field) { | |
| 438 | + | match (filter.op, &filter.value) { | |
| 439 | + | (FilterOp::In, FilterValue::List(values)) if !values.is_empty() && values.len() <= MAX_IN_VALUES => {} | |
| 440 | + | (FilterOp::In, _) => return Err(format!("in on {field} takes a list of 1 to {MAX_IN_VALUES} values.")), | |
| 441 | + | (FilterOp::Eq | FilterOp::Neq, FilterValue::Text(_)) => {} | |
| 442 | + | (FilterOp::Eq | FilterOp::Neq, _) => return Err(format!("{op} on {field} takes text.")), | |
| 443 | + | _ => return Err(format!("{field} is text: filter it with eq, neq or in.")), | |
| 444 | + | } | |
| 445 | + | } else if spec.measures.contains(&field) { | |
| 446 | + | match (filter.op, &filter.value) { | |
| 447 | + | (FilterOp::In, _) => return Err(format!("{field} is a number: filter it with eq, neq, gte or lte.")), | |
| 448 | + | (_, FilterValue::Number(n)) if n.is_finite() => {} | |
| 449 | + | _ => return Err(format!("{op} on {field} takes a number.")), | |
| 450 | + | } | |
| 451 | + | } else { | |
| 452 | + | return Err(format!("{name} can't be filtered by {field}.")); | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | if let Some(DatasetRange::Between { from, to }) = &self.range { | |
| 456 | + | let (Some(from), Some(to)) = (range_time(from), range_time(to)) else { | |
| 457 | + | return Err("A range's from and to are dates or RFC 3339 UTC times.".to_owned()); | |
| 458 | + | }; | |
| 459 | + | if from >= to { | |
| 460 | + | return Err("A range's from comes before its to.".to_owned()); | |
| 461 | + | } | |
| 462 | + | if to - from > MAX_RANGE_DAYS * DAY_MS { | |
| 463 | + | return Err(format!("A range is at most {MAX_RANGE_DAYS} days.")); | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | if let Some(limit) = self.limit | |
| 467 | + | && !(1..=MAX_ROWS).contains(&limit) | |
| 468 | + | { | |
| 469 | + | return Err(format!("limit is between 1 and {MAX_ROWS}.")); | |
| 470 | + | } | |
| 471 | + | Ok(()) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | #[cfg(test)] | |
| 476 | + | mod tests { | |
| 477 | + | use super::*; | |
| 478 | + | ||
| 479 | + | /// The two validators agree on every case in the shared fixtures. | |
| 480 | + | #[test] | |
| 481 | + | fn agrees_with_the_typescript_validator_on_the_fixtures() { | |
| 482 | + | let fixtures: serde_json::Value = serde_json::from_str(include_str!("../../../packages/contracts/src/datasets.fixtures.json")).unwrap(); | |
| 483 | + | let cases = fixtures["cases"].as_array().unwrap(); | |
| 484 | + | assert!(cases.len() > 20); | |
| 485 | + | for case in cases { | |
| 486 | + | let outcome = serde_json::from_value::<DatasetQuery>(case["query"].clone()) | |
| 487 | + | .map_err(|_| "*".to_owned()) | |
| 488 | + | .and_then(|query| query.validate()); | |
| 489 | + | match case["error"].as_str() { | |
| 490 | + | None => assert_eq!(outcome, Ok(()), "{}", case["query"]), | |
| 491 | + | Some("*") => assert!(outcome.is_err(), "{} should be refused", case["query"]), | |
| 492 | + | Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{}", case["query"]), | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | /// The site's copy of the catalog names the same datasets, services and | |
| 498 | + | /// fields, in the same order. | |
| 499 | + | #[test] | |
| 500 | + | fn the_typescript_mirror_has_the_same_catalog() { | |
| 501 | + | let ts = include_str!("../../../packages/contracts/src/datasets.ts"); | |
| 502 | + | let catalog = ts | |
| 503 | + | .split_once("export const DATASETS: Record<DatasetId, DatasetSpec> = {") | |
| 504 | + | .and_then(|(_, rest)| rest.split_once("\n};")) | |
| 505 | + | .map(|(table, _)| table) | |
| 506 | + | .expect("DATASETS in datasets.ts"); | |
| 507 | + | let quoted = |line: &str, key: &str| -> Vec<String> { | |
| 508 | + | let start = format!("{key}: ["); | |
| 509 | + | let list = line.split_once(&start).and_then(|(_, rest)| rest.split_once(']')).map(|(list, _)| list).unwrap_or_else(|| panic!("{key} in {line}")); | |
| 510 | + | list.split('"').skip(1).step_by(2).map(str::to_owned).collect() | |
| 511 | + | }; | |
| 512 | + | let lines: Vec<&str> = catalog.lines().filter(|line| line.contains("service:")).collect(); | |
| 513 | + | assert_eq!(lines.len(), DATASETS.len()); | |
| 514 | + | for (line, spec) in lines.iter().zip(DATASETS.iter()) { | |
| 515 | + | assert!(line.trim_start().starts_with(&format!("{}: {{", spec.id.as_str())), "{line}"); | |
| 516 | + | assert!(line.contains(&format!("label: \"{}\"", spec.label)), "{line}"); | |
| 517 | + | assert!(line.contains(&format!("service: \"{}\"", spec.service.as_str())), "{line}"); | |
| 518 | + | assert!(line.contains(&format!("needs: \"{}\"", spec.needs.as_str())), "{line}"); | |
| 519 | + | assert_eq!(quoted(line, "times"), spec.times, "{line}"); | |
| 520 | + | assert_eq!(quoted(line, "dimensions"), spec.dimensions, "{line}"); | |
| 521 | + | assert_eq!(quoted(line, "measures"), spec.measures, "{line}"); | |
| 522 | + | assert_eq!(quoted(line, "rates"), spec.rates, "{line}"); | |
| 523 | + | } | |
| 524 | + | for (name, value) in [("DATASET_MAX_ROWS", MAX_ROWS as usize), ("DATASET_MAX_FILTERS", MAX_FILTERS), ("DATASET_MAX_IN_VALUES", MAX_IN_VALUES), ("DATASET_MAX_RANGE_DAYS", MAX_RANGE_DAYS as usize)] { | |
| 525 | + | assert!(ts.contains(&format!("export const {name} = {value};")), "{name}"); | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | #[test] | |
| 530 | + | fn every_dataset_reads_back_and_has_a_time_field() { | |
| 531 | + | for id in DatasetId::ALL { | |
| 532 | + | assert_eq!(serde_json::to_value(id).unwrap(), id.as_str()); | |
| 533 | + | let spec = id.spec(); | |
| 534 | + | assert!(!spec.times.is_empty(), "{}", id.as_str()); | |
| 535 | + | // A field is one thing: never both a dimension and a number. | |
| 536 | + | for field in spec.dimensions { | |
| 537 | + | assert!(!spec.measures.contains(field) && !spec.rates.contains(field), "{field}"); | |
| 538 | + | } | |
| 539 | + | } | |
| 540 | + | assert_eq!(DatasetId::Spend.spec().needs, DatasetNeeds::Billing); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | #[test] | |
| 544 | + | fn a_query_travels_snake_case() { | |
| 545 | + | let query = DatasetQuery { | |
| 546 | + | dataset: DatasetId::PullRequests, | |
| 547 | + | measure: Measure { op: MeasureOp::P95, field: Some("cycle_time_hours".to_owned()) }, | |
| 548 | + | group_by: Some("repo".to_owned()), | |
| 549 | + | interval: Some(Interval::Week), | |
| 550 | + | time: None, | |
| 551 | + | filters: Some(vec![Filter { field: "state".to_owned(), op: FilterOp::Eq, value: FilterValue::Text("merged".to_owned()) }]), | |
| 552 | + | range: Some(DatasetRange::Preset(RangePreset::Days90)), | |
| 553 | + | limit: None, | |
| 554 | + | }; | |
| 555 | + | let json = serde_json::to_value(&query).unwrap(); | |
| 556 | + | assert_eq!( | |
| 557 | + | json, | |
| 558 | + | serde_json::json!({ | |
| 559 | + | "dataset": "pull_requests", | |
| 560 | + | "measure": { "op": "p95", "field": "cycle_time_hours" }, | |
| 561 | + | "group_by": "repo", | |
| 562 | + | "interval": "week", | |
| 563 | + | "filters": [{ "field": "state", "op": "eq", "value": "merged" }], | |
| 564 | + | "range": "90d" | |
| 565 | + | }) | |
| 566 | + | ); | |
| 567 | + | assert_eq!(serde_json::from_value::<DatasetQuery>(json).unwrap(), query); | |
| 568 | + | let between: DatasetRange = serde_json::from_value(serde_json::json!({ "from": "2026-01-01", "to": "2026-02-01" })).unwrap(); | |
| 569 | + | assert!(matches!(between, DatasetRange::Between { .. })); | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | #[test] | |
| 573 | + | fn range_times_are_real_dates_or_utc_times() { | |
| 574 | + | assert_eq!(range_time("1970-01-02"), Some(DAY_MS)); | |
| 575 | + | assert_eq!(range_time("2026-10-02T05:16:19Z"), Some(1_790_918_179_000)); | |
| 576 | + | assert_eq!(range_time("2026-10-02T05:16:19.5Z"), Some(1_790_918_179_500)); | |
| 577 | + | assert_eq!(range_time("2024-02-29"), crate::time::parse_rfc3339("2024-02-29T00:00:00Z")); | |
| 578 | + | for bad in ["2026-02-29", "2026-04-31", "2026-13-01", "2026-10-02T24:00:00Z", "2026-10-02T05:16:19", "2026-10-02T05:16:19+02:00", "26-10-02", "2026-1-02", "today", ""] { | |
| 579 | + | assert_eq!(range_time(bad), None, "{bad}"); | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | } |
| 976 | 976 | /// space, so it never reaches a repository's timeline or webhooks. | |
| 977 | 977 | pub const DOC_PAGE_EVENTS: [&str; 4] = ["doc.page.created", "doc.page.updated", "doc.page.archived", "doc.page.stale"]; | |
| 978 | 978 | ||
| 979 | + | /// The `folio.*` types (Artifacts mode), with their payloads, live in | |
| 980 | + | /// [`crate::folios`]; nothing publishes or subscribes to them yet. | |
| 981 | + | pub use crate::folios::FOLIO_EVENTS; | |
| 982 | + | ||
| 979 | 983 | /// What every `doc.page.*` event carries (`DocPageEventData` in | |
| 980 | 984 | /// events.ts). `doc.page.updated` adds `versionId`, `kind` and `authors`; | |
| 981 | 985 | /// `doc.page.stale` adds `repoId`, `repo`, `commit`, `pull`, `paths` and |
| 1 | + | //! 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 | + | ||
| 18 | + | use serde::{Deserialize, Serialize}; | |
| 19 | + | use serde_json::Value; | |
| 20 | + | ||
| 21 | + | use crate::User; | |
| 22 | + | use 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")] | |
| 28 | + | pub enum FolioKind { | |
| 29 | + | Doc, | |
| 30 | + | Slides, | |
| 31 | + | Design, | |
| 32 | + | Dashboard, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl 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")] | |
| 85 | + | pub 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")] | |
| 94 | + | pub enum GeneralAccess { | |
| 95 | + | None, | |
| 96 | + | Workspace, | |
| 97 | + | Link, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl 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")] | |
| 113 | + | pub enum AgentMode { | |
| 114 | + | Suggest, | |
| 115 | + | Edit, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The most ops one agent edit carries. | |
| 119 | + | pub const MAX_OPS: usize = 200; | |
| 120 | + | /// The longest title, in characters. | |
| 121 | + | pub const MAX_TITLE: usize = 200; | |
| 122 | + | /// The most people a folio is shared with in one change. | |
| 123 | + | pub const MAX_SHARE: usize = 50; | |
| 124 | + | /// The most folios a page of a list holds. | |
| 125 | + | pub const LIST_MAX: u32 = 100; | |
| 126 | + | ||
| 127 | + | /// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or | |
| 128 | + | /// `team:<slug>`. | |
| 129 | + | pub 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_…`). | |
| 137 | + | pub 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)] | |
| 151 | + | pub 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)] | |
| 163 | + | pub 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)] | |
| 172 | + | pub 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)] | |
| 180 | + | pub 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)] | |
| 188 | + | pub 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")] | |
| 222 | + | pub enum FolioListTab { | |
| 223 | + | All, | |
| 224 | + | Yours, | |
| 225 | + | Shared, | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 229 | + | pub 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 | + | ||
| 247 | + | impl 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)] | |
| 257 | + | pub 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)] | |
| 265 | + | pub 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)] | |
| 273 | + | pub struct ShareWith { | |
| 274 | + | pub principal: String, | |
| 275 | + | pub role: FolioRole, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 279 | + | pub 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 | + | ||
| 300 | + | impl 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)] | |
| 331 | + | pub 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)] | |
| 344 | + | pub 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")] | |
| 355 | + | pub 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 | + | ||
| 377 | + | impl 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)] | |
| 414 | + | pub 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)] | |
| 425 | + | pub 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. | |
| 440 | + | fn 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. | |
| 454 | + | pub 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. | |
| 501 | + | pub const FOLIO_RPC_METHODS: [&str; 46] = [ | |
| 502 | + | "folio_list", | |
| 503 | + | "folio_sidebar", | |
| 504 | + | "folio", | |
| 505 | + | "create_folio", | |
| 506 | + | "update_folio", | |
| 507 | + | "move_folio", | |
| 508 | + | "duplicate_folio", | |
| 509 | + | "trash_folio", | |
| 510 | + | "restore_folio", | |
| 511 | + | "delete_folio", | |
| 512 | + | "folio_trash", | |
| 513 | + | "favorite_folio", | |
| 514 | + | "folio_content", | |
| 515 | + | "edit_folio", | |
| 516 | + | "folio_access", | |
| 517 | + | "set_folio_grant", | |
| 518 | + | "set_folio_general_access", | |
| 519 | + | "request_folio_access", | |
| 520 | + | "join_space", | |
| 521 | + | "leave_space", | |
| 522 | + | "search_folios", | |
| 523 | + | "folio_versions", | |
| 524 | + | "folio_version", | |
| 525 | + | "restore_folio_version", | |
| 526 | + | "folio_templates", | |
| 527 | + | "save_folio_template", | |
| 528 | + | "delete_folio_template", | |
| 529 | + | "export_folio", | |
| 530 | + | "folio_suggestions", | |
| 531 | + | "decide_folio_suggestion", | |
| 532 | + | "folio_proposals", | |
| 533 | + | "decide_folio_proposal", | |
| 534 | + | "folio_thread", | |
| 535 | + | "folio_threads", | |
| 536 | + | "query_tile", | |
| 537 | + | "query_dataset", | |
| 538 | + | "query_dataset_for_agent", | |
| 539 | + | "folios_for_agent", | |
| 540 | + | "read_folio_for_agent", | |
| 541 | + | "create_folio_as_agent", | |
| 542 | + | "edit_folio_as_agent", | |
| 543 | + | "share_folio_as_agent", | |
| 544 | + | "recall_folios_for_agent", | |
| 545 | + | "stale_folios_for_agent", | |
| 546 | + | "mark_folio_current", | |
| 547 | + | "reindex_folios", | |
| 548 | + | ]; | |
| 549 | + | ||
| 550 | + | /// `folio_list`. | |
| 551 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 552 | + | pub struct FolioListArgs { | |
| 553 | + | pub workspace: String, | |
| 554 | + | pub viewer: User, | |
| 555 | + | pub query: FolioListQuery, | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | /// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`, | |
| 559 | + | /// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`. | |
| 560 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 561 | + | pub struct FolioArgs { | |
| 562 | + | pub workspace: String, | |
| 563 | + | pub viewer: User, | |
| 564 | + | pub folio_id: String, | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | /// `create_folio`. | |
| 568 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 569 | + | pub struct CreateFolioArgs { | |
| 570 | + | pub workspace: String, | |
| 571 | + | pub viewer: User, | |
| 572 | + | pub input: NewFolio, | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | /// `update_folio`. | |
| 576 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 577 | + | pub struct UpdateFolioArgs { | |
| 578 | + | pub workspace: String, | |
| 579 | + | pub viewer: User, | |
| 580 | + | pub folio_id: String, | |
| 581 | + | pub change: FolioChange, | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | /// `move_folio`. | |
| 585 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 586 | + | pub struct MoveFolioArgs { | |
| 587 | + | pub workspace: String, | |
| 588 | + | pub viewer: User, | |
| 589 | + | pub folio_id: String, | |
| 590 | + | #[serde(rename = "move")] | |
| 591 | + | pub to: FolioMove, | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | /// `edit_folio`: a `FolioAgentEdit`, as JSON. | |
| 595 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 596 | + | pub struct EditFolioArgs { | |
| 597 | + | pub workspace: String, | |
| 598 | + | pub viewer: User, | |
| 599 | + | pub folio_id: String, | |
| 600 | + | pub edit: Value, | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | /// `set_folio_grant` and `set_folio_general_access` (see | |
| 604 | + | /// [`FolioAccessChange::method`]). | |
| 605 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 606 | + | pub struct FolioAccessArgs { | |
| 607 | + | pub workspace: String, | |
| 608 | + | pub viewer: User, | |
| 609 | + | pub folio_id: String, | |
| 610 | + | pub change: FolioAccessChange, | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | /// `folio_version` and `restore_folio_version`. | |
| 614 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 615 | + | pub struct FolioVersionArgs { | |
| 616 | + | pub workspace: String, | |
| 617 | + | pub viewer: User, | |
| 618 | + | pub folio_id: String, | |
| 619 | + | pub version_id: String, | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | /// `folio_templates`. | |
| 623 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 624 | + | pub struct FolioTemplatesArgs { | |
| 625 | + | pub workspace: String, | |
| 626 | + | pub viewer: User, | |
| 627 | + | pub kind: Option<FolioKind>, | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | /// `search_folios`. | |
| 631 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 632 | + | pub struct SearchFoliosArgs { | |
| 633 | + | pub workspace: String, | |
| 634 | + | pub viewer: User, | |
| 635 | + | pub query: FolioSearchQuery, | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 639 | + | pub struct FolioSearchQuery { | |
| 640 | + | pub q: String, | |
| 641 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 642 | + | pub kinds: Option<Vec<FolioKind>>, | |
| 643 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 644 | + | pub space_id: Option<String>, | |
| 645 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 646 | + | pub project: Option<String>, | |
| 647 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 648 | + | pub owner: Option<String>, | |
| 649 | + | /// `words` or `hybrid`. | |
| 650 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 651 | + | pub mode: Option<String>, | |
| 652 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 653 | + | pub limit: Option<u32>, | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | /// `query_dataset`. | |
| 657 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 658 | + | pub struct QueryDatasetArgs { | |
| 659 | + | pub workspace: String, | |
| 660 | + | pub viewer: User, | |
| 661 | + | pub query: DatasetQuery, | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | // --- Events ------------------------------------------------------------------- | |
| 665 | + | ||
| 666 | + | /// The `folio.*` types the docs service will publish (Phase 1), with no | |
| 667 | + | /// `repoId` on the event: a folio may be private, so it never reaches a | |
| 668 | + | /// repository's timeline or webhooks. Nothing subscribes to them yet. | |
| 669 | + | pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"]; | |
| 670 | + | ||
| 671 | + | /// What every `folio.*` event carries (`FolioEventData` in events.ts). | |
| 672 | + | /// Event data is camelCase, as every event on the bus. | |
| 673 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 674 | + | #[serde(rename_all = "camelCase")] | |
| 675 | + | pub struct FolioEvent { | |
| 676 | + | pub workspace: String, | |
| 677 | + | pub workspace_id: String, | |
| 678 | + | pub folio_id: String, | |
| 679 | + | pub kind: FolioKind, | |
| 680 | + | pub space_id: Option<String>, | |
| 681 | + | /// None unless every member of the workspace can read it. | |
| 682 | + | pub title: Option<String>, | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | /// `folio.updated`: a version. | |
| 686 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 687 | + | #[serde(rename_all = "camelCase")] | |
| 688 | + | pub struct FolioUpdated { | |
| 689 | + | #[serde(flatten)] | |
| 690 | + | pub folio: FolioEvent, | |
| 691 | + | pub version_id: String, | |
| 692 | + | /// edit, agent, suggestion, proposal or restore. | |
| 693 | + | pub version_kind: String, | |
| 694 | + | /// Member keys. | |
| 695 | + | pub authors: Vec<String>, | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | /// `folio.shared`: who was given access, never content. | |
| 699 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 700 | + | #[serde(rename_all = "camelCase")] | |
| 701 | + | pub struct FolioShared { | |
| 702 | + | #[serde(flatten)] | |
| 703 | + | pub folio: FolioEvent, | |
| 704 | + | pub principals: Vec<String>, | |
| 705 | + | pub role: FolioRole, | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | /// `folio.stale`: code it cites changed. | |
| 709 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 710 | + | #[serde(rename_all = "camelCase")] | |
| 711 | + | pub struct FolioStale { | |
| 712 | + | #[serde(flatten)] | |
| 713 | + | pub folio: FolioEvent, | |
| 714 | + | /// `owner/name`. | |
| 715 | + | pub repo: String, | |
| 716 | + | pub commit: String, | |
| 717 | + | pub pull: Option<u32>, | |
| 718 | + | pub paths: Vec<String>, | |
| 719 | + | pub owners: Vec<String>, | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | #[cfg(test)] | |
| 723 | + | mod tests { | |
| 724 | + | use super::*; | |
| 725 | + | use serde_json::json; | |
| 726 | + | ||
| 727 | + | const TS: &str = include_str!("../../../packages/contracts/src/folios.ts"); | |
| 728 | + | const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts"); | |
| 729 | + | ||
| 730 | + | fn fixtures() -> Value { | |
| 731 | + | serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap() | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | /// The body of `export type <name> = ...` up to its closing `};`. | |
| 735 | + | fn ts_type<'a>(source: &'a str, name: &str) -> &'a str { | |
| 736 | + | let start = format!("export type {name} = "); | |
| 737 | + | source | |
| 738 | + | .split_once(&start) | |
| 739 | + | .and_then(|(_, rest)| rest.split_once("\n};")) | |
| 740 | + | .map(|(body, _)| body) | |
| 741 | + | .unwrap_or_else(|| panic!("{name} in the TypeScript")) | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | /// Every key `value` serializes to is a field of one of the TypeScript | |
| 745 | + | /// types named. | |
| 746 | + | fn fields_match(value: &Value, source: &str, types: &[&str]) { | |
| 747 | + | let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect(); | |
| 748 | + | for key in value.as_object().unwrap().keys() { | |
| 749 | + | assert!( | |
| 750 | + | bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))), | |
| 751 | + | "{key} is not a field of {types:?}" | |
| 752 | + | ); | |
| 753 | + | } | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | fn folio() -> Folio { | |
| 757 | + | serde_json::from_value(json!({ | |
| 758 | + | "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null, | |
| 759 | + | "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", | |
| 760 | + | "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" }, | |
| 761 | + | "parent_id": null, "position": 1.5, | |
| 762 | + | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }, | |
| 763 | + | "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" }, | |
| 764 | + | "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z", | |
| 765 | + | "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null, | |
| 766 | + | "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2, | |
| 767 | + | "general_access": "none", "general_role": null, "inherit": true, | |
| 768 | + | "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest", | |
| 769 | + | "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" }, | |
| 770 | + | "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false | |
| 771 | + | })) | |
| 772 | + | .unwrap() | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | #[test] | |
| 776 | + | fn shapes_have_the_typescript_field_names() { | |
| 777 | + | fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]); | |
| 778 | + | let new = NewFolio { | |
| 779 | + | kind: FolioKind::Doc, | |
| 780 | + | title: Some("Plan".to_owned()), | |
| 781 | + | icon: Some("🗺️".to_owned()), | |
| 782 | + | space_id: Some("spc_1".to_owned()), | |
| 783 | + | parent_id: Some("fol_1".to_owned()), | |
| 784 | + | template_id: Some("tpl_1".to_owned()), | |
| 785 | + | content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }), | |
| 786 | + | source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }), | |
| 787 | + | share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]), | |
| 788 | + | }; | |
| 789 | + | fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]); | |
| 790 | + | let query = FolioListQuery { | |
| 791 | + | tab: FolioListTab::Shared, | |
| 792 | + | kinds: Some(vec![FolioKind::Design]), | |
| 793 | + | space_id: Some("s".to_owned()), | |
| 794 | + | owner: Some("user:u".to_owned()), | |
| 795 | + | project: Some("acme/web".to_owned()), | |
| 796 | + | q: Some("roadmap".to_owned()), | |
| 797 | + | cursor: Some("c".to_owned()), | |
| 798 | + | limit: Some(20), | |
| 799 | + | }; | |
| 800 | + | fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]); | |
| 801 | + | let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) }; | |
| 802 | + | fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]); | |
| 803 | + | let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) }; | |
| 804 | + | assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" })); | |
| 805 | + | let list = FolioList { items: vec![], next_cursor: None }; | |
| 806 | + | assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null })); | |
| 807 | + | 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 }; | |
| 808 | + | fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]); | |
| 809 | + | let template = FolioTemplate { | |
| 810 | + | id: "tpl_1".to_owned(), | |
| 811 | + | kind: FolioKind::Dashboard, | |
| 812 | + | name: "Engineering health".to_owned(), | |
| 813 | + | description: String::new(), | |
| 814 | + | icon: None, | |
| 815 | + | builtin: true, | |
| 816 | + | body: "{}".to_owned(), | |
| 817 | + | created_by: None, | |
| 818 | + | }; | |
| 819 | + | fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]); | |
| 820 | + | // Every field of the TypeScript `Folio` comes back from Rust too. | |
| 821 | + | let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect(); | |
| 822 | + | for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] { | |
| 823 | + | for line in body.lines() { | |
| 824 | + | let line = line.trim_start(); | |
| 825 | + | if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) { | |
| 826 | + | assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust"); | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | } | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | #[test] | |
| 833 | + | fn access_changes_travel_tagged_by_op() { | |
| 834 | + | let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) }; | |
| 835 | + | assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" })); | |
| 836 | + | assert_eq!(change.method(), "set_folio_general_access"); | |
| 837 | + | let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap(); | |
| 838 | + | assert_eq!(grant.method(), "set_folio_grant"); | |
| 839 | + | let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap(); | |
| 840 | + | assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) }); | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | /// The validators agree with the TypeScript's on the shared fixtures. | |
| 844 | + | #[test] | |
| 845 | + | fn agrees_with_the_typescript_validators_on_the_fixtures() { | |
| 846 | + | let fixtures = fixtures(); | |
| 847 | + | let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() { | |
| 848 | + | None => assert_eq!(outcome, Ok(()), "{case}"), | |
| 849 | + | Some("*") => assert!(outcome.is_err(), "{case} should be refused"), | |
| 850 | + | Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"), | |
| 851 | + | }; | |
| 852 | + | for case in fixtures["new_folio"].as_array().unwrap() { | |
| 853 | + | let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate()); | |
| 854 | + | check(case, outcome); | |
| 855 | + | } | |
| 856 | + | for case in fixtures["access_change"].as_array().unwrap() { | |
| 857 | + | let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate()); | |
| 858 | + | check(case, outcome); | |
| 859 | + | } | |
| 860 | + | for case in fixtures["agent_edit"].as_array().unwrap() { | |
| 861 | + | check(case, agent_edit_error(&case["edit"]).map(|_| ())); | |
| 862 | + | } | |
| 863 | + | for case in fixtures["folio_ids"].as_array().unwrap() { | |
| 864 | + | assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}"); | |
| 865 | + | } | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | #[test] | |
| 869 | + | fn the_typescript_mirror_has_the_same_kinds_methods_and_events() { | |
| 870 | + | for kind in FolioKind::ALL { | |
| 871 | + | assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str()); | |
| 872 | + | assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str()); | |
| 873 | + | } | |
| 874 | + | let methods: Vec<&str> = TS | |
| 875 | + | .split_once("export const FOLIO_RPC_METHODS = [") | |
| 876 | + | .and_then(|(_, rest)| rest.split_once("] as const")) | |
| 877 | + | .map(|(list, _)| list) | |
| 878 | + | .expect("FOLIO_RPC_METHODS in folios.ts") | |
| 879 | + | .lines() | |
| 880 | + | .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name)) | |
| 881 | + | .collect(); | |
| 882 | + | assert_eq!(methods, FOLIO_RPC_METHODS); | |
| 883 | + | for event in FOLIO_EVENTS { | |
| 884 | + | assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts"); | |
| 885 | + | } | |
| 886 | + | let event = FolioEvent { | |
| 887 | + | workspace: "acme".to_owned(), | |
| 888 | + | workspace_id: "wsp_1".to_owned(), | |
| 889 | + | folio_id: "fol_1".to_owned(), | |
| 890 | + | kind: FolioKind::Doc, | |
| 891 | + | space_id: None, | |
| 892 | + | title: None, | |
| 893 | + | }; | |
| 894 | + | fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]); | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | #[test] | |
| 898 | + | fn folio_events_read_as_published_and_name_no_repository_id() { | |
| 899 | + | let data = json!({ | |
| 900 | + | "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null, | |
| 901 | + | "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"] | |
| 902 | + | }); | |
| 903 | + | let stale: FolioStale = serde_json::from_value(data).unwrap(); | |
| 904 | + | assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431))); | |
| 905 | + | let json = serde_json::to_value(&stale).unwrap(); | |
| 906 | + | assert!(json.get("repoId").is_none()); | |
| 907 | + | let shared: FolioShared = serde_json::from_value(json!({ | |
| 908 | + | "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4", | |
| 909 | + | "principals": ["user:usr_2"], "role": "comment" | |
| 910 | + | })) | |
| 911 | + | .unwrap(); | |
| 912 | + | assert_eq!(shared.role, FolioRole::Comment); | |
| 913 | + | // Not offered to webhooks. | |
| 914 | + | for event in FOLIO_EVENTS { | |
| 915 | + | assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}"); | |
| 916 | + | } | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | #[test] | |
| 920 | + | fn principals_and_kinds() { | |
| 921 | + | assert!(is_principal("user:usr_01jb")); | |
| 922 | + | assert!(is_principal("team:platform-web")); | |
| 923 | + | assert!(!is_principal("user:")); | |
| 924 | + | assert!(!is_principal("group:x")); | |
| 925 | + | assert!(!is_principal("user:a b")); | |
| 926 | + | assert!(FolioKind::Doc.can_have_children()); | |
| 927 | + | assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1); | |
| 928 | + | assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View); | |
| 929 | + | } | |
| 930 | + | } |
| 17 | 17 | pub mod checks; | |
| 18 | 18 | pub mod codeowners; | |
| 19 | 19 | pub mod credentials; | |
| 20 | + | pub mod datasets; | |
| 20 | 21 | pub mod deploy_keys; | |
| 21 | 22 | pub mod events; | |
| 23 | + | pub mod folios; | |
| 22 | 24 | pub mod github; | |
| 23 | 25 | pub mod guardrails; | |
| 24 | 26 | pub mod identity; |
| 43 | 43 | Secrets, | |
| 44 | 44 | Runners, | |
| 45 | 45 | Models, | |
| 46 | + | /// Artifacts mode's docs, slides, designs and dashboards (folios in | |
| 47 | + | /// code). Not offered yet: see [`Resource::offered`]. | |
| 48 | + | Artifacts, | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 48 | 51 | impl Resource { | |
| 49 | − | pub const ALL: [Resource; 21] = [ | |
| 52 | + | pub const ALL: [Resource; 22] = [ | |
| 50 | 53 | Resource::Repo, | |
| 51 | 54 | Resource::Code, | |
| 52 | 55 | Resource::Security, | |
| ⋯ | |||
| 68 | 71 | Resource::Secrets, | |
| 69 | 72 | Resource::Runners, | |
| 70 | 73 | Resource::Models, | |
| 74 | + | Resource::Artifacts, | |
| 71 | 75 | ]; | |
| 72 | 76 | ||
| 73 | 77 | pub fn as_str(self) -> &'static str { | |
| ⋯ | |||
| 93 | 97 | Resource::Secrets => "secrets", | |
| 94 | 98 | Resource::Runners => "runners", | |
| 95 | 99 | Resource::Models => "models", | |
| 100 | + | Resource::Artifacts => "artifacts", | |
| 96 | 101 | } | |
| 97 | 102 | } | |
| 98 | 103 | ||
| ⋯ | |||
| 120 | 125 | Resource::Secrets => "Secrets and variables", | |
| 121 | 126 | Resource::Runners => "Self-hosted runners", | |
| 122 | 127 | Resource::Models => "AI Gateway", | |
| 128 | + | Resource::Artifacts => "Artifacts", | |
| 123 | 129 | } | |
| 124 | 130 | } | |
| 131 | + | ||
| 132 | + | /// Whether tokens are offered it yet. A resource that is not is in the | |
| 133 | + | /// table (so its scopes parse, and the TypeScript mirror lists it under | |
| 134 | + | /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full | |
| 135 | + | /// access, OAuth and the token form leave it out, and no operation | |
| 136 | + | /// needs it. Artifacts is offered once its API ships (Phase 3 of | |
| 137 | + | /// docs/ARTIFACTS_MODE.md). | |
| 138 | + | pub fn offered(self) -> bool { | |
| 139 | + | !matches!(self, Resource::Artifacts) | |
| 140 | + | } | |
| 125 | 141 | } | |
| 126 | 142 | ||
| 127 | 143 | /// How much of a resource. | |
| ⋯ | |||
| 194 | 210 | RunnersAdmin, | |
| 195 | 211 | ModelsRead, | |
| 196 | 212 | ModelsWrite, | |
| 213 | + | ArtifactsRead, | |
| 214 | + | ArtifactsWrite, | |
| 215 | + | ArtifactsAdmin, | |
| 197 | 216 | } | |
| 198 | 217 | ||
| 199 | 218 | impl Scope { | |
| 200 | 219 | /// Every scope, grouped by resource, least first. | |
| 201 | − | pub const ALL: [Scope; 42] = [ | |
| 220 | + | pub const ALL: [Scope; 45] = [ | |
| 202 | 221 | Scope::RepoRead, | |
| 203 | 222 | Scope::RepoWrite, | |
| 204 | 223 | Scope::RepoAdmin, | |
| ⋯ | |||
| 241 | 260 | Scope::RunnersAdmin, | |
| 242 | 261 | Scope::ModelsRead, | |
| 243 | 262 | Scope::ModelsWrite, | |
| 263 | + | Scope::ArtifactsRead, | |
| 264 | + | Scope::ArtifactsWrite, | |
| 265 | + | Scope::ArtifactsAdmin, | |
| 244 | 266 | ]; | |
| 245 | 267 | ||
| 246 | 268 | pub fn as_str(self) -> &'static str { | |
| ⋯ | |||
| 287 | 309 | Scope::RunnersAdmin => "runners:admin", | |
| 288 | 310 | Scope::ModelsRead => "models:read", | |
| 289 | 311 | Scope::ModelsWrite => "models:write", | |
| 312 | + | Scope::ArtifactsRead => "artifacts:read", | |
| 313 | + | Scope::ArtifactsWrite => "artifacts:write", | |
| 314 | + | Scope::ArtifactsAdmin => "artifacts:admin", | |
| 290 | 315 | } | |
| 291 | 316 | } | |
| 292 | 317 | ||
| ⋯ | |||
| 370 | 395 | Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings", | |
| 371 | 396 | Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status", | |
| 372 | 397 | Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit", | |
| 398 | + | Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show", | |
| 399 | + | Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them", | |
| 400 | + | Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good", | |
| 373 | 401 | } | |
| 374 | 402 | } | |
| 403 | + | ||
| 404 | + | /// Whether tokens are offered it yet: its resource's [`Resource::offered`]. | |
| 405 | + | pub fn offered(self) -> bool { | |
| 406 | + | self.resource().offered() | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | /// Every scope tokens are offered, in table order: what OAuth advertises. | |
| 411 | + | pub fn offered_scopes() -> Vec<Scope> { | |
| 412 | + | Scope::ALL.into_iter().filter(|scope| scope.offered()).collect() | |
| 375 | 413 | } | |
| 376 | 414 | ||
| 377 | 415 | impl Serialize for Scope { | |
| ⋯ | |||
| 394 | 432 | let mut scopes: Vec<Scope> = text | |
| 395 | 433 | .split(|c: char| c.is_whitespace() || c == ',') | |
| 396 | 434 | .filter_map(Scope::parse) | |
| 435 | + | .filter(|scope| scope.offered()) | |
| 397 | 436 | .collect(); | |
| 398 | 437 | normalize(&mut scopes); | |
| 399 | 438 | scopes | |
| ⋯ | |||
| 438 | 477 | pub fn group(self) -> ResourceGroup { | |
| 439 | 478 | match self { | |
| 440 | 479 | Resource::Account | Resource::Notifications => ResourceGroup::Account, | |
| 441 | − | Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models => ResourceGroup::Workspace, | |
| 480 | + | Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace, | |
| 442 | 481 | _ => ResourceGroup::Repository, | |
| 443 | 482 | } | |
| 444 | 483 | } | |
| ⋯ | |||
| 477 | 516 | ||
| 478 | 517 | /// Every resource at its highest level: all a token can be given. | |
| 479 | 518 | pub fn everything() -> Vec<Scope> { | |
| 480 | − | top_scopes(&Scope::ALL) | |
| 519 | + | top_scopes(&offered_scopes()) | |
| 481 | 520 | } | |
| 482 | 521 | ||
| 483 | 522 | /// Scopes as permissions: each resource held, by name, at its highest | |
| ⋯ | |||
| 495 | 534 | pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> { | |
| 496 | 535 | let mut scopes = Vec::new(); | |
| 497 | 536 | for (name, level) in asked { | |
| 498 | − | let Some(resource) = Resource::parse(name) else { | |
| 537 | + | let Some(resource) = Resource::parse(name).filter(|resource| resource.offered()) else { | |
| 499 | 538 | return Err(format!("There is no permission called {name}.")); | |
| 500 | 539 | }; | |
| 501 | 540 | let level = level.trim().to_ascii_lowercase(); | |
| ⋯ | |||
| 550 | 589 | ||
| 551 | 590 | /// Its scopes; `None` for full access. | |
| 552 | 591 | pub fn scopes(self) -> Option<Vec<Scope>> { | |
| 553 | − | let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read); | |
| 592 | + | let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()); | |
| 554 | 593 | match self { | |
| 555 | 594 | Preset::ReadOnly => Some(reads().collect()), | |
| 556 | 595 | Preset::Agent => { | |
| ⋯ | |||
| 1400 | 1439 | assert!(scopes.contains(&Scope::PullRequestsWrite)); | |
| 1401 | 1440 | assert!(scopes.contains(&Scope::AgentsRun)); | |
| 1402 | 1441 | assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}"); | |
| 1403 | − | for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) { | |
| 1404 | − | // Every read but the machines work runs on. | |
| 1442 | + | for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) { | |
| 1443 | + | // Every read offered but the machines work runs on. | |
| 1405 | 1444 | assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}"); | |
| 1406 | 1445 | } | |
| 1407 | 1446 | assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read)); | |
| ⋯ | |||
| 1439 | 1478 | } | |
| 1440 | 1479 | ||
| 1441 | 1480 | #[test] | |
| 1481 | + | fn artifacts_scopes_exist_but_are_not_offered_yet() { | |
| 1482 | + | for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] { | |
| 1483 | + | assert_eq!(scope.resource(), Resource::Artifacts); | |
| 1484 | + | assert!(!scope.offered()); | |
| 1485 | + | assert_eq!(Scope::parse(scope.as_str()), Some(scope)); | |
| 1486 | + | // Nothing hands it out: not presets, full access, OAuth or the form. | |
| 1487 | + | for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] { | |
| 1488 | + | assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str()); | |
| 1489 | + | } | |
| 1490 | + | assert!(!everything().contains(&scope)); | |
| 1491 | + | assert!(!offered_scopes().contains(&scope)); | |
| 1492 | + | assert!(!oauth_default().contains(&scope)); | |
| 1493 | + | assert!(parse_scopes(scope.as_str()).is_empty()); | |
| 1494 | + | // And no operation needs it yet. | |
| 1495 | + | assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope)); | |
| 1496 | + | } | |
| 1497 | + | assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite)); | |
| 1498 | + | assert!(Scope::ArtifactsAdmin.dangerous()); | |
| 1499 | + | assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace); | |
| 1500 | + | let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]); | |
| 1501 | + | assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned())); | |
| 1502 | + | assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3); | |
| 1503 | + | } | |
| 1504 | + | ||
| 1505 | + | #[test] | |
| 1442 | 1506 | fn checks_are_reported_with_checks_write_which_ci_gets() { | |
| 1443 | 1507 | assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite)); | |
| 1444 | 1508 | assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite)); | |
| ⋯ | |||
| 1543 | 1607 | assert!(!back.contains_key("code")); | |
| 1544 | 1608 | // Lower levels held beside a higher one say nothing more. | |
| 1545 | 1609 | assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]); | |
| 1546 | − | // Every resource's top, and nothing a level can lose. | |
| 1610 | + | // Every offered resource's top, and nothing a level can lose. | |
| 1547 | 1611 | let all = everything(); | |
| 1548 | − | assert_eq!(all.len(), Resource::ALL.len()); | |
| 1549 | − | for scope in Scope::ALL { | |
| 1612 | + | assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count()); | |
| 1613 | + | for scope in offered_scopes() { | |
| 1550 | 1614 | assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}"); | |
| 1551 | 1615 | } | |
| 1552 | 1616 | } | |
| ⋯ | |||
| 1688 | 1752 | .map(|(table, _)| table) | |
| 1689 | 1753 | .unwrap_or_else(|| panic!("{start} in scopes.ts")) | |
| 1690 | 1754 | }; | |
| 1691 | − | let scopes: Vec<&str> = section("export const SCOPES = [") | |
| 1692 | − | .lines() | |
| 1693 | − | .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope)) | |
| 1694 | − | .collect(); | |
| 1695 | − | let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect(); | |
| 1696 | − | assert_eq!(scopes, expected); | |
| 1755 | + | let names = |table: &str| -> Vec<String> { | |
| 1756 | + | section(table) | |
| 1757 | + | .lines() | |
| 1758 | + | .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned())) | |
| 1759 | + | .collect() | |
| 1760 | + | }; | |
| 1761 | + | // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`. | |
| 1762 | + | let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1763 | + | let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect(); | |
| 1764 | + | assert_eq!(names("export const SCOPES = ["), offered); | |
| 1765 | + | assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming); | |
| 1697 | 1766 | let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [") | |
| 1698 | 1767 | .lines() | |
| 1699 | 1768 | .filter_map(|line| { | |
| ⋯ | |||
| 1719 | 1788 | .unwrap_or_else(|| vec!["*"]); | |
| 1720 | 1789 | assert_eq!(mirrored, expected, "{}", preset.as_str()); | |
| 1721 | 1790 | } | |
| 1722 | − | // Each resource with its group, in the same order. | |
| 1723 | − | let resources = ts | |
| 1724 | − | .split_once("export const SCOPE_RESOURCES") | |
| 1725 | − | .and_then(|(_, rest)| rest.split_once(" | |
| 1791 | + | // Each resource with its group, in the same order: offered ones in | |
| 1792 | + | // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`. | |
| 1793 | + | for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] { | |
| 1794 | + | let resources = ts | |
| 1795 | + | .split_once(table) | |
| 1796 | + | .and_then(|(_, rest)| rest.split_once(" | |
| 1726 | 1797 | ];")) | |
| 1727 | − | .map(|(table, _)| table) | |
| 1728 | − | .expect("SCOPE_RESOURCES in scopes.ts"); | |
| 1729 | − | let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect(); | |
| 1730 | − | assert_eq!(rows.len(), Resource::ALL.len()); | |
| 1731 | − | for (row, resource) in rows.iter().zip(Resource::ALL) { | |
| 1732 | − | assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}"); | |
| 1733 | − | assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}"); | |
| 1798 | + | .map(|(table, _)| table) | |
| 1799 | + | .unwrap_or_else(|| panic!("{table} in scopes.ts")); | |
| 1800 | + | let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect(); | |
| 1801 | + | let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect(); | |
| 1802 | + | assert_eq!(rows.len(), expected.len(), "{table}"); | |
| 1803 | + | for (row, resource) in rows.iter().zip(expected) { | |
| 1804 | + | assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}"); | |
| 1805 | + | assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}"); | |
| 1806 | + | } | |
| 1734 | 1807 | } | |
| 1735 | 1808 | } | |
| 1736 | 1809 | } | |
| 796 | 796 | - `packages/contracts/src/docs.ts`: the source for the new `folios.ts` and `datasets.ts`. | |
| 797 | 797 | - `apps/web/app/routes.ts`, plus `apps/web/app/lib/workspace-nav.ts`, `apps/web/app/components/rail.tsx` and `apps/web/app/components/shell.tsx`: mode wiring. | |
| 798 | 798 | - `services/agents/src/tools.ts` and `apps/api/src/tools.rs`: agent and MCP tools. | |
| 799 | + | ||
| 800 | + | --- | |
| 801 | + | ||
| 802 | + | ## 10. Decided in Phase 0 | |
| 803 | + | ||
| 804 | + | Phase 0 shipped the contracts with no runtime change. Where this plan was open, it settled these: | |
| 805 | + | ||
| 806 | + | - **Scopes not offered yet.** `artifacts:read|write|admin` are in `Scope::ALL` (at the end) behind `Resource::offered()`, which is false for Artifacts. Presets, full access as a list (`everything()`), OAuth's `scopes_supported`, `parse_scopes` and `resolve_permissions` leave them out, and no operation needs them. The TypeScript mirror keeps them in `UPCOMING_SCOPES` and `UPCOMING_RESOURCES`, so the token form, the OAuth checklist and apps/docs never show them. Phase 3 makes `offered()` true, moves the rows to the end of `SCOPES` and `SCOPE_RESOURCES`, adds `artifacts:read` to the presets and adds the docs scope table. | |
| 807 | + | - **Dataset catalog.** Section 3.4 named the datasets. Phase 0 fixed their fields (`DATASETS` in `datasets.ts` and `datasets.rs`): | |
| 808 | + | - per dataset: `times` (the default first), `dimensions` (text), `measures` (numbers) and `rates` (yes-or-no, for `rate`); | |
| 809 | + | - `DatasetQuery.time` picks the time field; | |
| 810 | + | - `DatasetResult.partial` drives "Based on what you can see"; | |
| 811 | + | - limits: 10 filters, 50 values per `in`, 100 rows, and a `{ from, to }` range of at most 366 days, given as dates or RFC 3339 UTC times. | |
| 812 | + | The 5a services implement exactly these fields. | |
| 813 | + | - **Three more RPC methods.** `folio_content` and `edit_folio` are a person's (or their token's) read and edit in the agent form, for REST `/content` and the MCP `get`/`edit` actions. `query_dataset` is a person's query, for `POST /datasets/query` and MCP `query_data`. `FolioAccessChange` also carries `inherit` and `agent_mode` changes. The client sends grants and revokes to `set_folio_grant` and the rest to `set_folio_general_access`. | |
| 814 | + | - **Events.** Payloads are camelCase, like every event on the bus. `title` is null unless the whole workspace can read the folio. `folio.updated` uses `versionKind`, because `kind` is the folio's kind. `folio.stale` names the repository as `owner/name` only. `FOLIO_EVENTS` and the payload structs are in `folios.rs` (re-exported from `events.rs`). `subscribers.rs` is untouched until Phase 1 publishes them. | |
| 815 | + | - **Validators.** | |
| 816 | + | - They are pure and return an error sentence or null (`Result` in Rust), and the words are the same in both languages. | |
| 817 | + | - Shared cases in `datasets.fixtures.json` and `folios.fixtures.json` are run by both test suites. | |
| 818 | + | - Contracts files import only types from each other, because Node runs the tests on the files as they are. So `dashboardOpError` takes the query validator (`datasetQueryError`) as an argument. | |
| 819 | + | - **Ids.** `fol_` for folios and `prp_` for proposals. Versions, templates and files keep `ver_`, `tpl_` and `fil_`. | |
| 820 | + | - **Slides themes** are a slug plus an optional `#rrggbb` accent. Phase 4 names the themes. |
| 1 | + | { | |
| 2 | + | "about": "Dataset queries both validators must agree on: datasetQueryError/parseDatasetQuery (datasets.ts) and DatasetQuery::validate (crates/contracts/src/datasets.rs). error null is valid; \"*\" is any error (a malformed query, which each side words its own way).", | |
| 3 | + | "cases": [ | |
| 4 | + | { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "interval": "week", "group_by": "repo", "time": "merged_at", "range": "90d" }, "error": null }, | |
| 5 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "rate", "field": "succeeded" }, "filters": [{ "field": "workflow", "op": "in", "value": ["CI", "Deploy"] }] }, "error": null }, | |
| 6 | + | { "query": { "dataset": "issues", "measure": { "op": "p95", "field": "time_to_close_hours" }, "range": { "from": "2026-01-01", "to": "2026-04-01T00:00:00Z" }, "limit": 100 }, "error": null }, | |
| 7 | + | { "query": { "dataset": "deployments", "measure": { "op": "avg", "field": "duration_seconds" }, "filters": [{ "field": "duration_seconds", "op": "gte", "value": 30 }, { "field": "environment", "op": "eq", "value": "production" }] }, "error": null }, | |
| 8 | + | { "query": { "dataset": "spend", "measure": { "op": "sum", "field": "amount_micros" }, "group_by": "product", "interval": "month" }, "error": null }, | |
| 9 | + | { "query": { "dataset": "agent_sessions", "measure": { "op": "count" }, "group_by": "agent", "range": { "from": "2024-02-29", "to": "2024-03-01" } }, "error": null }, | |
| 10 | + | ||
| 11 | + | { "query": { "dataset": "issues", "measure": { "op": "count", "field": "comments" } }, "error": "count takes no field." }, | |
| 12 | + | { "query": { "dataset": "issues", "measure": { "op": "rate" } }, "error": "rate needs a field." }, | |
| 13 | + | { "query": { "dataset": "issues", "measure": { "op": "rate", "field": "comments" } }, "error": "issues has no yes-or-no field comments to take the rate of." }, | |
| 14 | + | { "query": { "dataset": "issues", "measure": { "op": "sum" } }, "error": "sum needs a field." }, | |
| 15 | + | { "query": { "dataset": "pull_requests", "measure": { "op": "avg", "field": "merged" } }, "error": "pull_requests has no number field merged." }, | |
| 16 | + | { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "group_by": "author" }, "error": "pull_requests can't be grouped by author." }, | |
| 17 | + | { "query": { "dataset": "pull_requests", "measure": { "op": "count" }, "time": "deployed_at" }, "error": "pull_requests has no time field deployed_at." }, | |
| 18 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "workflow", "op": "in", "value": [] }] }, "error": "in on workflow takes a list of 1 to 50 values." }, | |
| 19 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "workflow", "op": "eq", "value": 3 }] }, "error": "eq on workflow takes text." }, | |
| 20 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "branch", "op": "gte", "value": "main" }] }, "error": "branch is text: filter it with eq, neq or in." }, | |
| 21 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "duration_seconds", "op": "in", "value": ["1"] }] }, "error": "duration_seconds is a number: filter it with eq, neq, gte or lte." }, | |
| 22 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "duration_seconds", "op": "lte", "value": "60" }] }, "error": "lte on duration_seconds takes a number." }, | |
| 23 | + | { "query": { "dataset": "workflow_runs", "measure": { "op": "count" }, "filters": [{ "field": "secret", "op": "eq", "value": "x" }] }, "error": "workflow_runs can't be filtered by secret." }, | |
| 24 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2026-02-30", "to": "2026-03-10" } }, "error": "A range's from and to are dates or RFC 3339 UTC times." }, | |
| 25 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2026-03-10", "to": "2026-03-10" } }, "error": "A range's from comes before its to." }, | |
| 26 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "2025-01-01", "to": "2026-01-03" } }, "error": "A range is at most 366 days." }, | |
| 27 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": { "from": "yesterday", "to": "today" } }, "error": "A range's from and to are dates or RFC 3339 UTC times." }, | |
| 28 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "limit": 101 }, "error": "limit is between 1 and 100." }, | |
| 29 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "limit": 0 }, "error": "limit is between 1 and 100." }, | |
| 30 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "filters": [ | |
| 31 | + | { "field": "repo", "op": "eq", "value": "a" }, { "field": "repo", "op": "eq", "value": "b" }, { "field": "repo", "op": "eq", "value": "c" }, | |
| 32 | + | { "field": "repo", "op": "eq", "value": "d" }, { "field": "repo", "op": "eq", "value": "e" }, { "field": "repo", "op": "eq", "value": "f" }, | |
| 33 | + | { "field": "repo", "op": "eq", "value": "g" }, { "field": "repo", "op": "eq", "value": "h" }, { "field": "repo", "op": "eq", "value": "i" }, | |
| 34 | + | { "field": "repo", "op": "eq", "value": "j" }, { "field": "repo", "op": "eq", "value": "k" } | |
| 35 | + | ] }, "error": "A query takes at most 10 filters." }, | |
| 36 | + | ||
| 37 | + | { "query": { "dataset": "secrets", "measure": { "op": "count" } }, "error": "*" }, | |
| 38 | + | { "query": { "dataset": "issues", "measure": { "op": "median" } }, "error": "*" }, | |
| 39 | + | { "query": { "dataset": "issues" }, "error": "*" }, | |
| 40 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "range": "1y" }, "error": "*" }, | |
| 41 | + | { "query": { "dataset": "issues", "measure": { "op": "count" }, "interval": "hour" }, "error": "*" }, | |
| 42 | + | { "query": "SELECT * FROM issues", "error": "*" } | |
| 43 | + | ] | |
| 44 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync } from "node:fs"; | |
| 3 | + | import { test } from "node:test"; | |
| 4 | + | ||
| 5 | + | import { DATASETS, DATASET_IDS, datasetQueryError, datasetTime, parseDatasetQuery, type DatasetQuery } from "./datasets.ts"; | |
| 6 | + | ||
| 7 | + | const fixtures = JSON.parse(readFileSync(new URL("./datasets.fixtures.json", import.meta.url), "utf8")) as { | |
| 8 | + | cases: { query: unknown; error: string | null }[]; | |
| 9 | + | }; | |
| 10 | + | ||
| 11 | + | test("agrees with the Rust validator on every shared case", () => { | |
| 12 | + | assert.ok(fixtures.cases.length > 20); | |
| 13 | + | for (const { query, error } of fixtures.cases) { | |
| 14 | + | const parsed = parseDatasetQuery(query); | |
| 15 | + | const label = JSON.stringify(query); | |
| 16 | + | if (error === null) assert.ok("query" in parsed, `${label}: ${"error" in parsed ? parsed.error : ""}`); | |
| 17 | + | else if (error === "*") assert.ok("error" in parsed, `${label} should be refused`); | |
| 18 | + | else assert.deepEqual(parsed, { error }, label); | |
| 19 | + | } | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("a parsed query keeps only what the catalog knows", () => { | |
| 23 | + | const parsed = parseDatasetQuery({ dataset: "issues", measure: { op: "count" }, sql: "DROP TABLE issues", group_by: "repo" }); | |
| 24 | + | assert.ok("query" in parsed); | |
| 25 | + | assert.equal("sql" in parsed.query, false); | |
| 26 | + | assert.equal(parsed.query.group_by, "repo"); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("every dataset has a time field and fields that are one thing each", () => { | |
| 30 | + | assert.deepEqual(Object.keys(DATASETS), [...DATASET_IDS]); | |
| 31 | + | for (const id of DATASET_IDS) { | |
| 32 | + | const spec = DATASETS[id]; | |
| 33 | + | assert.ok(spec.times.length > 0, id); | |
| 34 | + | for (const field of spec.dimensions) assert.ok(!spec.measures.includes(field) && !spec.rates.includes(field), `${id}.${field}`); | |
| 35 | + | } | |
| 36 | + | assert.equal(DATASETS.spend.needs, "billing"); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("a typed query is checked against the catalog", () => { | |
| 40 | + | const ok: DatasetQuery = { dataset: "workflow_runs", measure: { op: "rate", field: "succeeded" }, interval: "day", range: "30d" }; | |
| 41 | + | assert.equal(datasetQueryError(ok), null); | |
| 42 | + | assert.equal(datasetQueryError({ ...ok, limit: 2.5 }), "limit is between 1 and 100."); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("range ends are real dates or UTC times", () => { | |
| 46 | + | assert.equal(datasetTime("1970-01-02"), 86_400_000); | |
| 47 | + | assert.equal(datasetTime("2026-10-02T05:16:19Z"), 1_790_918_179_000); | |
| 48 | + | assert.equal(datasetTime("2026-10-02T05:16:19.5Z"), 1_790_918_179_500); | |
| 49 | + | for (const bad of ["2026-02-29", "2026-04-31", "2026-13-01", "2026-10-02T24:00:00Z", "2026-10-02T05:16:19", "2026-10-02T05:16:19+02:00", "26-10-02", "2026-1-02", "today", ""]) { | |
| 50 | + | assert.equal(datasetTime(bad), null, bad); | |
| 51 | + | } | |
| 52 | + | }); |
| 1 | + | /** | |
| 2 | + | * Datasets: the safe query layer behind dashboards (Artifacts mode, | |
| 3 | + | * docs/ARTIFACTS_MODE.md, section 3.4). Mirrors | |
| 4 | + | * `crates/contracts/src/datasets.rs`; a Rust test keeps the catalog the | |
| 5 | + | * same and runs both validators over `datasets.fixtures.json`. | |
| 6 | + | * | |
| 7 | + | * Not SQL. A query names a dataset from a declared catalog, one measure, | |
| 8 | + | * at most one dimension to group by, a time interval, filters on declared | |
| 9 | + | * fields and a range. The service that owns the data (`service` in the | |
| 10 | + | * catalog) runs it for the viewer, over only what the viewer can read, with | |
| 11 | + | * a fixed query per measure and dimension, and caps the rows. | |
| 12 | + | * | |
| 13 | + | * Values never live in a folio: not in its Yjs document, versions, text, | |
| 14 | + | * preview, index or templates. Only the query does. | |
| 15 | + | * | |
| 16 | + | * Wire shapes are snake_case. | |
| 17 | + | */ | |
| 18 | + | ||
| 19 | + | export type DatasetId = "issues" | "pull_requests" | "workflow_runs" | "deployments" | "spend" | "agent_sessions"; | |
| 20 | + | ||
| 21 | + | export const DATASET_IDS: readonly DatasetId[] = ["issues", "pull_requests", "workflow_runs", "deployments", "spend", "agent_sessions"]; | |
| 22 | + | ||
| 23 | + | /** How rows are summed up. `count` takes no field; `rate` takes a rate field; the rest a measure field. */ | |
| 24 | + | export type DatasetMeasureOp = "count" | "sum" | "avg" | "p50" | "p95" | "rate"; | |
| 25 | + | export const DATASET_MEASURE_OPS: readonly DatasetMeasureOp[] = ["count", "sum", "avg", "p50", "p95", "rate"]; | |
| 26 | + | ||
| 27 | + | export type DatasetInterval = "day" | "week" | "month"; | |
| 28 | + | export const DATASET_INTERVALS: readonly DatasetInterval[] = ["day", "week", "month"]; | |
| 29 | + | ||
| 30 | + | export type DatasetFilterOp = "eq" | "neq" | "in" | "gte" | "lte"; | |
| 31 | + | export const DATASET_FILTER_OPS: readonly DatasetFilterOp[] = ["eq", "neq", "in", "gte", "lte"]; | |
| 32 | + | ||
| 33 | + | export type DatasetRangePreset = "7d" | "30d" | "90d"; | |
| 34 | + | export const DATASET_RANGE_PRESETS: readonly DatasetRangePreset[] = ["7d", "30d", "90d"]; | |
| 35 | + | ||
| 36 | + | /** | |
| 37 | + | * A time range: a preset counted back from now, or between two times. | |
| 38 | + | * `from` and `to` are dates (`2026-10-01`) or RFC 3339 UTC times | |
| 39 | + | * (`2026-10-01T09:00:00Z`); `to` is exclusive. | |
| 40 | + | */ | |
| 41 | + | export type DatasetRange = DatasetRangePreset | { from: string; to: string }; | |
| 42 | + | ||
| 43 | + | export type DatasetFilter = { | |
| 44 | + | field: string; | |
| 45 | + | op: DatasetFilterOp; | |
| 46 | + | /** Text for `eq`/`neq` on a dimension, a list for `in`, a number on a measure. */ | |
| 47 | + | value: string | number | string[]; | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | export type DatasetQuery = { | |
| 51 | + | dataset: DatasetId; | |
| 52 | + | measure: { op: DatasetMeasureOp; field?: string | null }; | |
| 53 | + | /** A declared dimension only. */ | |
| 54 | + | group_by?: string | null; | |
| 55 | + | /** A time series, over the dataset's time field. */ | |
| 56 | + | interval?: DatasetInterval | null; | |
| 57 | + | /** Which of the dataset's time fields the range and interval use; its first when left out. */ | |
| 58 | + | time?: string | null; | |
| 59 | + | filters?: DatasetFilter[] | null; | |
| 60 | + | /** The tile's own range; else the dashboard's. */ | |
| 61 | + | range?: DatasetRange | null; | |
| 62 | + | /** At most `DATASET_MAX_ROWS`. */ | |
| 63 | + | limit?: number | null; | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | export type DatasetColumnType = "string" | "number" | "time" | "money"; | |
| 67 | + | ||
| 68 | + | export type DatasetResult = { | |
| 69 | + | columns: { name: string; type: DatasetColumnType }[]; | |
| 70 | + | rows: (string | number | null)[][]; | |
| 71 | + | /** More rows matched than were returned. */ | |
| 72 | + | truncated: boolean; | |
| 73 | + | /** The viewer cannot read everything the query covers: shown as "Based on what you can see". */ | |
| 74 | + | partial: boolean; | |
| 75 | + | /** When the numbers were computed, RFC 3339. */ | |
| 76 | + | as_of: string; | |
| 77 | + | }; | |
| 78 | + | ||
| 79 | + | /** The services that own datasets, each answering `query_dataset` for its own. */ | |
| 80 | + | export type DatasetService = "work" | "actions" | "deployments" | "billing" | "agents"; | |
| 81 | + | ||
| 82 | + | /** What a viewer needs to query a dataset: membership, or the workspace's billing role. */ | |
| 83 | + | export type DatasetNeeds = "member" | "billing"; | |
| 84 | + | ||
| 85 | + | export type DatasetSpec = { | |
| 86 | + | label: string; | |
| 87 | + | service: DatasetService; | |
| 88 | + | needs: DatasetNeeds; | |
| 89 | + | /** Time fields, the default first. */ | |
| 90 | + | times: string[]; | |
| 91 | + | /** Fields to group and filter by (text). */ | |
| 92 | + | dimensions: string[]; | |
| 93 | + | /** Number fields for sum, avg, p50, p95, and number filters. */ | |
| 94 | + | measures: string[]; | |
| 95 | + | /** Yes-or-no fields `rate` gives the share of. */ | |
| 96 | + | rates: string[]; | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | /** The catalog. One dataset per line: the Rust mirror test reads it that way. */ | |
| 100 | + | export const DATASETS: Record<DatasetId, DatasetSpec> = { | |
| 101 | + | issues: { label: "Issues", service: "work", needs: "member", times: ["created_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "assignee_kind", "milestone"], measures: ["time_to_close_hours", "comments"], rates: ["closed"] }, | |
| 102 | + | pull_requests: { label: "Pull requests", service: "work", needs: "member", times: ["created_at", "merged_at", "closed_at"], dimensions: ["repo", "label", "state", "author_kind", "base_branch"], measures: ["cycle_time_hours", "time_to_first_review_hours", "review_count", "additions", "deletions", "changed_files"], rates: ["merged"] }, | |
| 103 | + | workflow_runs: { label: "Workflow runs", service: "actions", needs: "member", times: ["started_at", "completed_at"], dimensions: ["repo", "workflow", "branch", "event", "conclusion", "runner_kind"], measures: ["duration_seconds", "queue_seconds"], rates: ["succeeded"] }, | |
| 104 | + | deployments: { label: "Deployments", service: "deployments", needs: "member", times: ["created_at"], dimensions: ["repo", "project", "environment", "state"], measures: ["duration_seconds", "time_to_restore_hours"], rates: ["failed"] }, | |
| 105 | + | spend: { label: "Spend", service: "billing", needs: "billing", times: ["day"], dimensions: ["product", "project", "person", "model"], measures: ["amount_micros"], rates: [] }, | |
| 106 | + | agent_sessions: { label: "Agent sessions", service: "agents", needs: "member", times: ["started_at"], dimensions: ["agent", "repo", "outcome", "model", "trigger"], measures: ["duration_seconds", "cost_micros", "tokens"], rates: ["succeeded"] }, | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | /** The most rows a query returns. */ | |
| 110 | + | export const DATASET_MAX_ROWS = 100; | |
| 111 | + | /** The most filters on one query. */ | |
| 112 | + | export const DATASET_MAX_FILTERS = 10; | |
| 113 | + | /** The most values in an `in` filter. */ | |
| 114 | + | export const DATASET_MAX_IN_VALUES = 50; | |
| 115 | + | /** The longest `{ from, to }` range, in days. */ | |
| 116 | + | export const DATASET_MAX_RANGE_DAYS = 366; | |
| 117 | + | ||
| 118 | + | const DAY_MS = 86_400_000; | |
| 119 | + | const TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z)?$/; | |
| 120 | + | ||
| 121 | + | /** A range end as milliseconds since the epoch, or null when it is not a real date or RFC 3339 UTC time. */ | |
| 122 | + | export function datasetTime(text: string): number | null { | |
| 123 | + | const match = TIME.exec(text); | |
| 124 | + | if (!match) return null; | |
| 125 | + | const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; | |
| 126 | + | const [hour, minute, second] = [Number(match[4] ?? 0), Number(match[5] ?? 0), Number(match[6] ?? 0)]; | |
| 127 | + | const millis = Number((match[7] ?? "").padEnd(3, "0") || 0); | |
| 128 | + | if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null; | |
| 129 | + | if (hour > 23 || minute > 59 || second > 59) return null; | |
| 130 | + | return Date.UTC(year, month - 1, day, hour, minute, second, millis); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | function daysInMonth(year: number, month: number): number { | |
| 134 | + | if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28; | |
| 135 | + | return [4, 6, 9, 11].includes(month) ? 30 : 31; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /** | |
| 139 | + | * What is wrong with a query, or null. The same rules, and the same | |
| 140 | + | * words, as `DatasetQuery::validate` in Rust. It checks the query against | |
| 141 | + | * the catalog only; who may run it is the owning service's to decide. | |
| 142 | + | */ | |
| 143 | + | export function datasetQueryError(query: DatasetQuery): string | null { | |
| 144 | + | const spec = DATASETS[query.dataset]; | |
| 145 | + | if (!spec) return `There is no dataset called ${query.dataset}.`; | |
| 146 | + | const name = query.dataset; | |
| 147 | + | const { op } = query.measure; | |
| 148 | + | const field = query.measure.field ?? null; | |
| 149 | + | if (op === "count") { | |
| 150 | + | if (field !== null) return "count takes no field."; | |
| 151 | + | } else if (op === "rate") { | |
| 152 | + | if (field === null) return "rate needs a field."; | |
| 153 | + | if (!spec.rates.includes(field)) return `${name} has no yes-or-no field ${field} to take the rate of.`; | |
| 154 | + | } else { | |
| 155 | + | if (field === null) return `${op} needs a field.`; | |
| 156 | + | if (!spec.measures.includes(field)) return `${name} has no number field ${field}.`; | |
| 157 | + | } | |
| 158 | + | const groupBy = query.group_by ?? null; | |
| 159 | + | if (groupBy !== null && !spec.dimensions.includes(groupBy)) return `${name} can't be grouped by ${groupBy}.`; | |
| 160 | + | const time = query.time ?? null; | |
| 161 | + | if (time !== null && !spec.times.includes(time)) return `${name} has no time field ${time}.`; | |
| 162 | + | const filters = query.filters ?? []; | |
| 163 | + | if (filters.length > DATASET_MAX_FILTERS) return `A query takes at most ${DATASET_MAX_FILTERS} filters.`; | |
| 164 | + | for (const filter of filters) { | |
| 165 | + | if (spec.dimensions.includes(filter.field)) { | |
| 166 | + | if (filter.op === "in") { | |
| 167 | + | if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > DATASET_MAX_IN_VALUES) { | |
| 168 | + | return `in on ${filter.field} takes a list of 1 to ${DATASET_MAX_IN_VALUES} values.`; | |
| 169 | + | } | |
| 170 | + | } else if (filter.op === "eq" || filter.op === "neq") { | |
| 171 | + | if (typeof filter.value !== "string") return `${filter.op} on ${filter.field} takes text.`; | |
| 172 | + | } else { | |
| 173 | + | return `${filter.field} is text: filter it with eq, neq or in.`; | |
| 174 | + | } | |
| 175 | + | } else if (spec.measures.includes(filter.field)) { | |
| 176 | + | if (filter.op === "in") return `${filter.field} is a number: filter it with eq, neq, gte or lte.`; | |
| 177 | + | if (typeof filter.value !== "number" || !Number.isFinite(filter.value)) return `${filter.op} on ${filter.field} takes a number.`; | |
| 178 | + | } else { | |
| 179 | + | return `${name} can't be filtered by ${filter.field}.`; | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | const range = query.range ?? null; | |
| 183 | + | if (range !== null && typeof range === "object") { | |
| 184 | + | const from = datasetTime(range.from); | |
| 185 | + | const to = datasetTime(range.to); | |
| 186 | + | if (from === null || to === null) return "A range's from and to are dates or RFC 3339 UTC times."; | |
| 187 | + | if (from >= to) return "A range's from comes before its to."; | |
| 188 | + | if (to - from > DATASET_MAX_RANGE_DAYS * DAY_MS) return `A range is at most ${DATASET_MAX_RANGE_DAYS} days.`; | |
| 189 | + | } | |
| 190 | + | const limit = query.limit ?? null; | |
| 191 | + | if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > DATASET_MAX_ROWS)) return `limit is between 1 and ${DATASET_MAX_ROWS}.`; | |
| 192 | + | return null; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /** A query from untrusted JSON: well-formed and valid, or why not. Unknown keys are dropped. */ | |
| 196 | + | export function parseDatasetQuery(input: unknown): { query: DatasetQuery } | { error: string } { | |
| 197 | + | if (!isObject(input)) return { error: "A query is an object." }; | |
| 198 | + | if (typeof input.dataset !== "string" || !(DATASET_IDS as readonly string[]).includes(input.dataset)) { | |
| 199 | + | return { error: `There is no dataset called ${String(input.dataset)}.` }; | |
| 200 | + | } | |
| 201 | + | const measure = input.measure; | |
| 202 | + | if (!isObject(measure) || typeof measure.op !== "string" || !(DATASET_MEASURE_OPS as readonly string[]).includes(measure.op)) { | |
| 203 | + | return { error: "measure.op is count, sum, avg, p50, p95 or rate." }; | |
| 204 | + | } | |
| 205 | + | if (!optional(measure.field, "string")) return { error: "measure.field is text." }; | |
| 206 | + | if (!optional(input.group_by, "string")) return { error: "group_by is text." }; | |
| 207 | + | if (!optional(input.time, "string")) return { error: "time is text." }; | |
| 208 | + | if (input.interval != null && !(DATASET_INTERVALS as readonly unknown[]).includes(input.interval)) return { error: "interval is day, week or month." }; | |
| 209 | + | if (input.limit != null && typeof input.limit !== "number") return { error: "limit is a number." }; | |
| 210 | + | let filters: DatasetFilter[] | null = null; | |
| 211 | + | if (input.filters != null) { | |
| 212 | + | if (!Array.isArray(input.filters)) return { error: "filters is a list." }; | |
| 213 | + | filters = []; | |
| 214 | + | for (const filter of input.filters) { | |
| 215 | + | if (!isObject(filter) || typeof filter.field !== "string" || !(DATASET_FILTER_OPS as readonly unknown[]).includes(filter.op)) { | |
| 216 | + | return { error: "A filter has a field and an op: eq, neq, in, gte or lte." }; | |
| 217 | + | } | |
| 218 | + | const value = filter.value; | |
| 219 | + | const ok = | |
| 220 | + | typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) || (Array.isArray(value) && value.every((item) => typeof item === "string")); | |
| 221 | + | if (!ok) return { error: "A filter's value is text, a number or a list of text." }; | |
| 222 | + | filters.push({ field: filter.field, op: filter.op as DatasetFilterOp, value: value as DatasetFilter["value"] }); | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | let range: DatasetRange | null = null; | |
| 226 | + | if (input.range != null) { | |
| 227 | + | if (typeof input.range === "string" && (DATASET_RANGE_PRESETS as readonly string[]).includes(input.range)) range = input.range as DatasetRangePreset; | |
| 228 | + | else if (isObject(input.range) && typeof input.range.from === "string" && typeof input.range.to === "string") range = { from: input.range.from, to: input.range.to }; | |
| 229 | + | else return { error: "range is 7d, 30d, 90d or { from, to }." }; | |
| 230 | + | } | |
| 231 | + | const query: DatasetQuery = { | |
| 232 | + | dataset: input.dataset as DatasetId, | |
| 233 | + | measure: { op: measure.op as DatasetMeasureOp, field: (measure.field as string | null | undefined) ?? null }, | |
| 234 | + | group_by: (input.group_by as string | null | undefined) ?? null, | |
| 235 | + | interval: (input.interval as DatasetInterval | null | undefined) ?? null, | |
| 236 | + | time: (input.time as string | null | undefined) ?? null, | |
| 237 | + | filters, | |
| 238 | + | range, | |
| 239 | + | limit: (input.limit as number | null | undefined) ?? null, | |
| 240 | + | }; | |
| 241 | + | const error = datasetQueryError(query); | |
| 242 | + | return error ? { error } : { query }; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | function isObject(value: unknown): value is Record<string, unknown> { | |
| 246 | + | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | function optional(value: unknown, type: "string"): boolean { | |
| 250 | + | return value === undefined || value === null || typeof value === type; | |
| 251 | + | } |
| 488 | 488 | * (`stalePagesForAgent` in docs.ts). | |
| 489 | 489 | */ | |
| 490 | 490 | "doc.page.stale": DocPageEventData & { repoId: string; repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] }; | |
| 491 | + | /** | |
| 492 | + | * Artifacts (folios, services/docs): a folio was made. Like `doc.page.*`, | |
| 493 | + | * published with no `repoId`, never offered to webhooks, and readers check | |
| 494 | + | * access with the docs service before showing anything of it. Not | |
| 495 | + | * published yet: Phase 1 of docs/ARTIFACTS_MODE.md starts them. | |
| 496 | + | */ | |
| 497 | + | "folio.created": FolioEventData; | |
| 498 | + | /** A folio's content changed: a version (`versionKind`) with everyone whose changes are in it. */ | |
| 499 | + | "folio.updated": FolioEventData & { versionId: string; versionKind: "edit" | "agent" | "suggestion" | "proposal" | "restore"; authors: string[] }; | |
| 500 | + | /** A folio went to the trash (with everything under it; one event for the folio asked about). */ | |
| 501 | + | "folio.trashed": FolioEventData; | |
| 502 | + | "folio.restored": FolioEventData; | |
| 503 | + | /** Someone was given access: who (member keys) and the role. Never content. */ | |
| 504 | + | "folio.shared": FolioEventData & { principals: string[]; role: "view" | "comment" | "edit" | "manage" }; | |
| 505 | + | /** Code a folio cites changed. The repository is in `data` as `owner/name` only. */ | |
| 506 | + | "folio.stale": FolioEventData & { repo: string; commit: string; pull: number | null; paths: string[]; owners: string[] }; | |
| 507 | + | }; | |
| 508 | + | ||
| 509 | + | /** | |
| 510 | + | * What every `folio.*` event carries. `title` is null unless every member | |
| 511 | + | * of the workspace can read the folio, so a private folio's name never | |
| 512 | + | * travels. | |
| 513 | + | */ | |
| 514 | + | export type FolioEventData = { | |
| 515 | + | workspace: string; | |
| 516 | + | workspaceId: string; | |
| 517 | + | folioId: string; | |
| 518 | + | kind: "doc" | "slides" | "design" | "dashboard"; | |
| 519 | + | spaceId: string | null; | |
| 520 | + | title: string | null; | |
| 491 | 521 | }; | |
| 492 | 522 | ||
| 493 | 523 | /** What every `doc.page.*` event carries. */ |
| 1 | + | /** | |
| 2 | + | * Dashboards (`kind: "dashboard"`, beta): a dashboard's definition and the | |
| 3 | + | * changes agents make to it. Artifacts mode, docs/ARTIFACTS_MODE.md | |
| 4 | + | * section 3.4. Wire shapes are snake_case. | |
| 5 | + | * | |
| 6 | + | * In the Yjs document: `Y.Map("dashboard")` holds `DashboardSettings`; | |
| 7 | + | * `Y.Array("tiles")` holds one `Y.Map` per tile (`DashboardTile`). Only the | |
| 8 | + | * definition is stored. Numbers are computed per viewer when the page asks | |
| 9 | + | * (`query_tile`), and never saved in the folio, its versions, text, preview, | |
| 10 | + | * index or templates. | |
| 11 | + | * | |
| 12 | + | * A tile's query is a `DatasetQuery` (datasets.ts). Ops are checked here | |
| 13 | + | * with the query validator passed in (`datasetQueryError`), so this file | |
| 14 | + | * stays free of imports Node can't run as is in tests. | |
| 15 | + | */ | |
| 16 | + | import type { DatasetQuery, DatasetRange } from "./datasets"; | |
| 17 | + | ||
| 18 | + | export type DashboardTileType = "stat" | "line" | "area" | "bar" | "stacked_bar" | "table" | "list" | "markdown"; | |
| 19 | + | export const DASHBOARD_TILE_TYPES: readonly DashboardTileType[] = ["stat", "line", "area", "bar", "stacked_bar", "table", "list", "markdown"]; | |
| 20 | + | ||
| 21 | + | export const DASHBOARD_TILE_TYPE_LABELS: Record<DashboardTileType, string> = { | |
| 22 | + | stat: "Number", | |
| 23 | + | line: "Line chart", | |
| 24 | + | area: "Area chart", | |
| 25 | + | bar: "Bar chart", | |
| 26 | + | stacked_bar: "Stacked bars", | |
| 27 | + | table: "Table", | |
| 28 | + | list: "List", | |
| 29 | + | markdown: "Text", | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /** Where a tile sits on the 12-column grid: column, row, width and height in cells. */ | |
| 33 | + | export type DashboardGrid = { x: number; y: number; w: number; h: number }; | |
| 34 | + | export const DASHBOARD_COLUMNS = 12; | |
| 35 | + | export const DASHBOARD_MAX_ROWS = 200; | |
| 36 | + | export const DASHBOARD_MAX_TILES = 60; | |
| 37 | + | ||
| 38 | + | export type DashboardRefresh = "manual" | "5m" | "1h"; | |
| 39 | + | export const DASHBOARD_REFRESHES: readonly DashboardRefresh[] = ["manual", "5m", "1h"]; | |
| 40 | + | ||
| 41 | + | /** Filters every tile starts from; a tile's own `range` wins over the dashboard's. */ | |
| 42 | + | export type DashboardFilters = { range: DatasetRange; project?: string | null; repos?: string[] | null; team?: string | null }; | |
| 43 | + | ||
| 44 | + | export type DashboardSettings = { filters: DashboardFilters; refresh: DashboardRefresh }; | |
| 45 | + | ||
| 46 | + | /** How a tile draws its rows. Every field is optional and defaults by tile type. */ | |
| 47 | + | export type DashboardViz = { | |
| 48 | + | /** Show a number as money, a percentage, a duration or plain. */ | |
| 49 | + | format?: "number" | "money" | "percent" | "duration" | null; | |
| 50 | + | /** Lines and bars: show the legend. */ | |
| 51 | + | legend?: boolean; | |
| 52 | + | /** stat: draw the sparkline when the query has an interval. */ | |
| 53 | + | sparkline?: boolean; | |
| 54 | + | /** Colours by series name; else the theme's order. */ | |
| 55 | + | colors?: Record<string, string> | null; | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | export type DashboardTile = { | |
| 59 | + | id: string; | |
| 60 | + | type: DashboardTileType; | |
| 61 | + | title: string; | |
| 62 | + | description: string; | |
| 63 | + | /** Null on a `markdown` tile, which has `markdown` instead. */ | |
| 64 | + | query: DatasetQuery | null; | |
| 65 | + | markdown?: string | null; | |
| 66 | + | viz: DashboardViz; | |
| 67 | + | grid: DashboardGrid; | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | /** The Yjs names a dashboard uses. */ | |
| 71 | + | export const DASHBOARD_MAP = "dashboard"; | |
| 72 | + | export const DASHBOARD_TILES = "tiles"; | |
| 73 | + | ||
| 74 | + | /** What a card shows of a dashboard: tile boxes and titles, never numbers. */ | |
| 75 | + | export type DashboardPreview = { kind: "dashboard"; tiles: { type: DashboardTileType; title: string; grid: DashboardGrid }[] }; | |
| 76 | + | ||
| 77 | + | /** A change an agent makes to a dashboard. */ | |
| 78 | + | export type DashboardOp = | |
| 79 | + | /** Add a tile, or replace the one with its id. */ | |
| 80 | + | | { op: "upsert_tile"; tile: DashboardTile } | |
| 81 | + | | { op: "delete_tiles"; ids: string[] } | |
| 82 | + | | { op: "set_filters"; filters: DashboardFilters } | |
| 83 | + | | { op: "set_layout"; tiles: { id: string; grid: DashboardGrid }[] }; | |
| 84 | + | ||
| 85 | + | export const DASHBOARD_OPS: readonly DashboardOp["op"][] = ["upsert_tile", "delete_tiles", "set_filters", "set_layout"]; | |
| 86 | + | ||
| 87 | + | /** What is wrong with a tile's place on the grid, or null. */ | |
| 88 | + | export function dashboardGridError(grid: unknown): string | null { | |
| 89 | + | if (!isObject(grid)) return "grid is { x, y, w, h }."; | |
| 90 | + | const { x, y, w, h } = grid; | |
| 91 | + | if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n))) return "grid's x, y, w and h are whole numbers."; | |
| 92 | + | const [gx, gy, gw, gh] = [x, y, w, h] as number[]; | |
| 93 | + | if (gx < 0 || gy < 0 || gw < 1 || gh < 1) return "grid starts at 0, 0 and is at least 1 by 1."; | |
| 94 | + | if (gx + gw > DASHBOARD_COLUMNS) return `A tile fits in ${DASHBOARD_COLUMNS} columns.`; | |
| 95 | + | if (gy + gh > DASHBOARD_MAX_ROWS) return `A dashboard is at most ${DASHBOARD_MAX_ROWS} rows tall.`; | |
| 96 | + | return null; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * What is wrong with a dashboard op, or null. `queryError` checks a tile's | |
| 101 | + | * query: pass `datasetQueryError` from datasets.ts. Whether ids exist is the | |
| 102 | + | * room's to say. | |
| 103 | + | */ | |
| 104 | + | export function dashboardOpError(op: unknown, queryError: (query: DatasetQuery) => string | null): string | null { | |
| 105 | + | if (!isObject(op) || typeof op.op !== "string") return "A dashboard change has an op."; | |
| 106 | + | switch (op.op) { | |
| 107 | + | case "upsert_tile": { | |
| 108 | + | const tile = op.tile; | |
| 109 | + | if (!isObject(tile)) return "upsert_tile needs a tile."; | |
| 110 | + | if (typeof tile.id !== "string" || tile.id.length === 0) return "A tile has an id."; | |
| 111 | + | if (typeof tile.type !== "string" || !(DASHBOARD_TILE_TYPES as readonly string[]).includes(tile.type)) return `There is no tile type called ${String(tile.type)}.`; | |
| 112 | + | if (typeof tile.title !== "string" || tile.title.length > 200) return "A tile's title is text of at most 200 characters."; | |
| 113 | + | if (tile.description !== undefined && typeof tile.description !== "string") return "A tile's description is text."; | |
| 114 | + | if (tile.type === "markdown") { | |
| 115 | + | if (tile.query != null) return "A text tile has no query."; | |
| 116 | + | if (typeof tile.markdown !== "string") return "A text tile has markdown."; | |
| 117 | + | } else { | |
| 118 | + | if (!isObject(tile.query)) return "A chart tile has a query."; | |
| 119 | + | const error = queryError(tile.query as DatasetQuery); | |
| 120 | + | if (error) return error; | |
| 121 | + | } | |
| 122 | + | return dashboardGridError(tile.grid); | |
| 123 | + | } | |
| 124 | + | case "delete_tiles": | |
| 125 | + | return Array.isArray(op.ids) && op.ids.length > 0 && op.ids.every((id) => typeof id === "string" && id.length > 0) ? null : "delete_tiles needs ids."; | |
| 126 | + | case "set_filters": { | |
| 127 | + | const filters = op.filters; | |
| 128 | + | if (!isObject(filters)) return "set_filters needs filters."; | |
| 129 | + | const range = filters.range; | |
| 130 | + | const preset = typeof range === "string" && ["7d", "30d", "90d"].includes(range); | |
| 131 | + | if (!preset && !(isObject(range) && typeof range.from === "string" && typeof range.to === "string")) return "range is 7d, 30d, 90d or { from, to }."; | |
| 132 | + | if (isObject(range)) { | |
| 133 | + | const error = queryError({ dataset: "issues", measure: { op: "count" }, range: { from: range.from as string, to: range.to as string } }); | |
| 134 | + | if (error) return error; | |
| 135 | + | } | |
| 136 | + | if (filters.repos != null && !(Array.isArray(filters.repos) && filters.repos.every((repo) => typeof repo === "string"))) return "repos is a list of owner/name."; | |
| 137 | + | return null; | |
| 138 | + | } | |
| 139 | + | case "set_layout": { | |
| 140 | + | if (!Array.isArray(op.tiles) || op.tiles.length === 0) return "set_layout needs tiles."; | |
| 141 | + | for (const tile of op.tiles) { | |
| 142 | + | if (!isObject(tile) || typeof tile.id !== "string") return "set_layout's tiles are { id, grid }."; | |
| 143 | + | const error = dashboardGridError(tile.grid); | |
| 144 | + | if (error) return error; | |
| 145 | + | } | |
| 146 | + | return null; | |
| 147 | + | } | |
| 148 | + | default: | |
| 149 | + | return `There is no dashboard op called ${op.op}.`; | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | function isObject(value: unknown): value is Record<string, unknown> { | |
| 154 | + | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 155 | + | } |
| 1 | + | /** | |
| 2 | + | * Design (`kind: "design"`): a frame-based layout canvas and the changes | |
| 3 | + | * agents make to it. Artifacts mode, docs/ARTIFACTS_MODE.md section 3.3. | |
| 4 | + | * Wire shapes are snake_case. | |
| 5 | + | * | |
| 6 | + | * In the Yjs document: `Y.Map("canvas")` holds `DesignCanvas`; `Y.Map("nodes")` | |
| 7 | + | * maps each node's id to a `Y.Map` of `DesignNode` (a `text` node's content | |
| 8 | + | * and an `html` node's source are `Y.Text`). Frames lay their children out | |
| 9 | + | * themselves (`row`, `column`), so agents describe structure, not | |
| 10 | + | * coordinates. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | export type DesignNodeType = "frame" | "rect" | "ellipse" | "line" | "arrow" | "text" | "image" | "html" | "component" | "instance"; | |
| 14 | + | export const DESIGN_NODE_TYPES: readonly DesignNodeType[] = ["frame", "rect", "ellipse", "line", "arrow", "text", "image", "html", "component", "instance"]; | |
| 15 | + | ||
| 16 | + | export type DesignFrameLayout = "free" | "row" | "column"; | |
| 17 | + | export type DesignAlign = "start" | "center" | "end" | "stretch"; | |
| 18 | + | ||
| 19 | + | export type DesignCanvas = { background: string | null; grid: number | null }; | |
| 20 | + | ||
| 21 | + | /** A node as stored: every field but `id` and `type` may be absent. */ | |
| 22 | + | export type DesignNode = { | |
| 23 | + | id: string; | |
| 24 | + | type: DesignNodeType; | |
| 25 | + | /** The frame or component it sits in; null at the top. */ | |
| 26 | + | parent: string | null; | |
| 27 | + | /** Its order among its siblings: a fractional index. */ | |
| 28 | + | index: string; | |
| 29 | + | x: number; | |
| 30 | + | y: number; | |
| 31 | + | w: number; | |
| 32 | + | h: number; | |
| 33 | + | rotation?: number; | |
| 34 | + | fill?: string | null; | |
| 35 | + | stroke?: string | null; | |
| 36 | + | stroke_width?: number; | |
| 37 | + | radius?: number; | |
| 38 | + | opacity?: number; | |
| 39 | + | name?: string; | |
| 40 | + | locked?: boolean; | |
| 41 | + | hidden?: boolean; | |
| 42 | + | /** Frames and components. */ | |
| 43 | + | layout?: DesignFrameLayout; | |
| 44 | + | gap?: number; | |
| 45 | + | padding?: number; | |
| 46 | + | align?: DesignAlign; | |
| 47 | + | /** `line` and `arrow`: the nodes their ends are bound to. */ | |
| 48 | + | from_node?: string | null; | |
| 49 | + | to_node?: string | null; | |
| 50 | + | /** `image`: a file of the folio, never bytes. */ | |
| 51 | + | file?: string; | |
| 52 | + | /** `instance`: the component it is an instance of, and what it changes. */ | |
| 53 | + | component_id?: string; | |
| 54 | + | overrides?: Record<string, unknown>; | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | /** | |
| 58 | + | * A node as an agent writes it: nested, so `children` imply `parent` and | |
| 59 | + | * `index`. Sizes and positions are optional inside `row` and `column` | |
| 60 | + | * frames. `text` is a text node's content; `html` an html node's source. | |
| 61 | + | */ | |
| 62 | + | export type DesignNodeSpec = Partial<Omit<DesignNode, "parent" | "index" | "type">> & { | |
| 63 | + | type: DesignNodeType; | |
| 64 | + | text?: string; | |
| 65 | + | html?: string; | |
| 66 | + | children?: DesignNodeSpec[]; | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | /** Limits the room enforces when it saves. */ | |
| 70 | + | export const DESIGN_MAX_NODES = 5_000; | |
| 71 | + | export const DESIGN_MAX_HTML_BYTES = 200 * 1024; | |
| 72 | + | export const DESIGN_MAX_FILE_BYTES = 25 * 1024 * 1024; | |
| 73 | + | /** The deepest a node spec nests. */ | |
| 74 | + | export const DESIGN_MAX_DEPTH = 32; | |
| 75 | + | ||
| 76 | + | /** The Yjs names the canvas uses. */ | |
| 77 | + | export const DESIGN_CANVAS_MAP = "canvas"; | |
| 78 | + | export const DESIGN_NODES_MAP = "nodes"; | |
| 79 | + | ||
| 80 | + | /** What a card shows of a design: its first frame's top nodes, simplified, at most `DESIGN_PREVIEW_NODES`. */ | |
| 81 | + | export type DesignPreview = { | |
| 82 | + | kind: "design"; | |
| 83 | + | frame: { name: string; w: number; h: number } | null; | |
| 84 | + | nodes: { type: DesignNodeType; x: number; y: number; w: number; h: number; fill: string | null }[]; | |
| 85 | + | }; | |
| 86 | + | export const DESIGN_PREVIEW_NODES = 50; | |
| 87 | + | ||
| 88 | + | /** A change an agent makes to a design. */ | |
| 89 | + | export type DesignOp = | |
| 90 | + | /** Add nodes, or change the ones whose `id` exists. Nested specs are placed in their parents. */ | |
| 91 | + | | { op: "upsert_nodes"; parent?: string | null; nodes: DesignNodeSpec[] } | |
| 92 | + | | { op: "delete_nodes"; ids: string[] } | |
| 93 | + | /** Replace a frame's children (and its own fields) with a spec. */ | |
| 94 | + | | { op: "replace_frame"; frame_id: string; spec: DesignNodeSpec } | |
| 95 | + | | { op: "set_html"; node_id: string; html: string } | |
| 96 | + | | { op: "move"; ids: string[]; dx: number; dy: number }; | |
| 97 | + | ||
| 98 | + | export const DESIGN_OPS: readonly DesignOp["op"][] = ["upsert_nodes", "delete_nodes", "replace_frame", "set_html", "move"]; | |
| 99 | + | ||
| 100 | + | /** How many nodes a spec list makes, its children included, and how deep it goes. */ | |
| 101 | + | export function countDesignSpecs(specs: readonly DesignNodeSpec[], depth = 1): { nodes: number; depth: number } { | |
| 102 | + | let nodes = 0; | |
| 103 | + | let deepest = specs.length > 0 ? depth : depth - 1; | |
| 104 | + | for (const spec of specs) { | |
| 105 | + | nodes += 1; | |
| 106 | + | if (spec.children && spec.children.length > 0) { | |
| 107 | + | const inner = countDesignSpecs(spec.children, depth + 1); | |
| 108 | + | nodes += inner.nodes; | |
| 109 | + | deepest = Math.max(deepest, inner.depth); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return { nodes, depth: deepest }; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /** What is wrong with a node spec, or null. */ | |
| 116 | + | export function designSpecError(spec: unknown, depth = 1): string | null { | |
| 117 | + | if (depth > DESIGN_MAX_DEPTH) return `Node specs nest at most ${DESIGN_MAX_DEPTH} deep.`; | |
| 118 | + | if (!isObject(spec)) return "A node spec is an object."; | |
| 119 | + | if (typeof spec.type !== "string" || !(DESIGN_NODE_TYPES as readonly string[]).includes(spec.type)) return `There is no node type called ${String(spec.type)}.`; | |
| 120 | + | for (const key of ["x", "y", "w", "h", "rotation", "stroke_width", "radius", "opacity", "gap", "padding"]) { | |
| 121 | + | if (spec[key] !== undefined && (typeof spec[key] !== "number" || !Number.isFinite(spec[key]))) return `${key} is a number.`; | |
| 122 | + | } | |
| 123 | + | if (spec.opacity !== undefined && ((spec.opacity as number) < 0 || (spec.opacity as number) > 1)) return "opacity is between 0 and 1."; | |
| 124 | + | if (spec.layout !== undefined && !["free", "row", "column"].includes(spec.layout as string)) return "layout is free, row or column."; | |
| 125 | + | if (typeof spec.html === "string" && utf8Bytes(spec.html) > DESIGN_MAX_HTML_BYTES) return "An html node holds at most 200 KB."; | |
| 126 | + | if (spec.html !== undefined && spec.type !== "html") return "Only an html node has html."; | |
| 127 | + | if (spec.type === "instance" && typeof spec.component_id !== "string") return "An instance names its component_id."; | |
| 128 | + | if (spec.children !== undefined) { | |
| 129 | + | if (!Array.isArray(spec.children)) return "children is a list."; | |
| 130 | + | if (spec.children.length > 0 && spec.type !== "frame" && spec.type !== "component") return "Only frames and components have children."; | |
| 131 | + | for (const child of spec.children) { | |
| 132 | + | const error = designSpecError(child, depth + 1); | |
| 133 | + | if (error) return error; | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | return null; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /** What is wrong with a design op, or null. Checks its shape; whether its ids exist is the room's to say. */ | |
| 140 | + | export function designOpError(op: unknown): string | null { | |
| 141 | + | if (!isObject(op) || typeof op.op !== "string") return "A design change has an op."; | |
| 142 | + | const ids = (key: string) => | |
| 143 | + | Array.isArray(op[key]) && (op[key] as unknown[]).length > 0 && (op[key] as unknown[]).every((item) => typeof item === "string" && item.length > 0) ? null : `${op.op} needs ${key}.`; | |
| 144 | + | const id = (key: string) => (typeof op[key] === "string" && (op[key] as string).length > 0 ? null : `${op.op} needs ${key}.`); | |
| 145 | + | switch (op.op) { | |
| 146 | + | case "upsert_nodes": { | |
| 147 | + | if (!Array.isArray(op.nodes) || op.nodes.length === 0) return "upsert_nodes needs nodes."; | |
| 148 | + | for (const spec of op.nodes) { | |
| 149 | + | const error = designSpecError(spec); | |
| 150 | + | if (error) return error; | |
| 151 | + | } | |
| 152 | + | if (countDesignSpecs(op.nodes as DesignNodeSpec[]).nodes > DESIGN_MAX_NODES) return `A design holds at most ${DESIGN_MAX_NODES} nodes.`; | |
| 153 | + | return null; | |
| 154 | + | } | |
| 155 | + | case "delete_nodes": | |
| 156 | + | return ids("ids"); | |
| 157 | + | case "replace_frame": { | |
| 158 | + | const error = id("frame_id") ?? designSpecError(op.spec); | |
| 159 | + | if (error) return error; | |
| 160 | + | return countDesignSpecs([op.spec as DesignNodeSpec]).nodes > DESIGN_MAX_NODES ? `A design holds at most ${DESIGN_MAX_NODES} nodes.` : null; | |
| 161 | + | } | |
| 162 | + | case "set_html": | |
| 163 | + | if (id("node_id")) return id("node_id"); | |
| 164 | + | if (typeof op.html !== "string") return "set_html needs html."; | |
| 165 | + | return utf8Bytes(op.html) > DESIGN_MAX_HTML_BYTES ? "An html node holds at most 200 KB." : null; | |
| 166 | + | case "move": | |
| 167 | + | if (ids("ids")) return ids("ids"); | |
| 168 | + | return typeof op.dx === "number" && typeof op.dy === "number" && Number.isFinite(op.dx) && Number.isFinite(op.dy) ? null : "move needs dx and dy."; | |
| 169 | + | default: | |
| 170 | + | return `There is no design op called ${op.op}.`; | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /** The length of `text` in UTF-8, in bytes. */ | |
| 175 | + | function utf8Bytes(text: string): number { | |
| 176 | + | let bytes = 0; | |
| 177 | + | for (const char of text) { | |
| 178 | + | const code = char.codePointAt(0)!; | |
| 179 | + | bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; | |
| 180 | + | } | |
| 181 | + | return bytes; | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | function isObject(value: unknown): value is Record<string, unknown> { | |
| 185 | + | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 186 | + | } |
| 1 | + | /** | |
| 2 | + | * Slides (`kind: "slides"`): a deck's model and the changes agents make to | |
| 3 | + | * it. Artifacts mode, docs/ARTIFACTS_MODE.md section 3.2. Wire shapes are | |
| 4 | + | * snake_case. | |
| 5 | + | * | |
| 6 | + | * In the Yjs document: `Y.Map("deck")` holds `SlidesDeck`; `Y.Array("slides")` | |
| 7 | + | * holds one `Y.Map` per slide (`SlideMeta`); each slide region is a root | |
| 8 | + | * `XmlFragment` named by `slideFragment(id, region)`, a BlockNote editor of | |
| 9 | + | * its own. | |
| 10 | + | * | |
| 11 | + | * Agents, templates, export and the text rendition use the Markdown form: | |
| 12 | + | * slides separated by `---` lines, each starting `<!-- slide: <layout> -->`, | |
| 13 | + | * `::: left` / `::: right` for columns, and `Note:` for speaker notes. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | export type SlideLayout = "title" | "title-body" | "two-column" | "section" | "image" | "quote" | "big-number" | "blank"; | |
| 17 | + | export const SLIDE_LAYOUTS: readonly SlideLayout[] = ["title", "title-body", "two-column", "section", "image", "quote", "big-number", "blank"]; | |
| 18 | + | ||
| 19 | + | export const SLIDE_LAYOUT_LABELS: Record<SlideLayout, string> = { | |
| 20 | + | title: "Title", | |
| 21 | + | "title-body": "Title and body", | |
| 22 | + | "two-column": "Two columns", | |
| 23 | + | section: "Section", | |
| 24 | + | image: "Image", | |
| 25 | + | quote: "Quote", | |
| 26 | + | "big-number": "Big number", | |
| 27 | + | blank: "Blank", | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | export type SlideRegion = "title" | "body" | "left" | "right" | "notes"; | |
| 31 | + | ||
| 32 | + | /** The regions each layout has; every slide also has `notes`. */ | |
| 33 | + | export const SLIDE_LAYOUT_REGIONS: Record<SlideLayout, readonly SlideRegion[]> = { | |
| 34 | + | title: ["title", "body", "notes"], | |
| 35 | + | "title-body": ["title", "body", "notes"], | |
| 36 | + | "two-column": ["title", "left", "right", "notes"], | |
| 37 | + | section: ["title", "notes"], | |
| 38 | + | image: ["title", "body", "notes"], | |
| 39 | + | quote: ["body", "notes"], | |
| 40 | + | "big-number": ["title", "body", "notes"], | |
| 41 | + | blank: ["body", "notes"], | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | export type SlidesAspect = "16:9" | "4:3"; | |
| 45 | + | export const SLIDES_ASPECTS: readonly SlidesAspect[] = ["16:9", "4:3"]; | |
| 46 | + | ||
| 47 | + | /** The deck's settings: `Y.Map("deck")`. `theme` names one of the editor's themes. */ | |
| 48 | + | export type SlidesDeck = { theme: string; aspect: SlidesAspect; accent: string | null }; | |
| 49 | + | ||
| 50 | + | /** One slide's own fields: an entry of `Y.Array("slides")`. */ | |
| 51 | + | export type SlideMeta = { id: string; layout: SlideLayout; background: string | null; hidden: boolean; transition: "none" }; | |
| 52 | + | ||
| 53 | + | /** The Yjs names the deck uses. */ | |
| 54 | + | export const SLIDES_DECK_MAP = "deck"; | |
| 55 | + | export const SLIDES_ARRAY = "slides"; | |
| 56 | + | ||
| 57 | + | /** The root fragment that holds one region of one slide. */ | |
| 58 | + | export function slideFragment(slideId: string, region: SlideRegion): string { | |
| 59 | + | return `slide:${slideId}:${region}`; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** The most slides in a deck. */ | |
| 63 | + | export const SLIDES_MAX = 300; | |
| 64 | + | ||
| 65 | + | /** What a card shows of a deck: its first slide, never more. */ | |
| 66 | + | export type SlidesPreview = { kind: "slides"; aspect: SlidesAspect; count: number; layout: SlideLayout; title: string }; | |
| 67 | + | ||
| 68 | + | /** A change an agent makes to a deck. Markdown is in the deck's Markdown form. */ | |
| 69 | + | export type SlidesOp = | |
| 70 | + | /** Replace every slide. */ | |
| 71 | + | | { op: "replace_deck"; markdown: string } | |
| 72 | + | /** Add slides after one (null: at the start). */ | |
| 73 | + | | { op: "insert_slides"; after_slide_id: string | null; markdown: string } | |
| 74 | + | /** Replace one slide with what the Markdown holds (one slide or more). */ | |
| 75 | + | | { op: "replace_slide"; slide_id: string; markdown: string } | |
| 76 | + | | { op: "delete_slides"; slide_ids: string[] } | |
| 77 | + | /** Move a slide after another (null: to the start). */ | |
| 78 | + | | { op: "move_slide"; slide_id: string; after_slide_id: string | null } | |
| 79 | + | | { op: "set_notes"; slide_id: string; markdown: string } | |
| 80 | + | | { op: "set_theme"; theme: string; accent: string | null }; | |
| 81 | + | ||
| 82 | + | export const SLIDES_OPS: readonly SlidesOp["op"][] = ["replace_deck", "insert_slides", "replace_slide", "delete_slides", "move_slide", "set_notes", "set_theme"]; | |
| 83 | + | ||
| 84 | + | /** The largest Markdown one op carries, in characters. */ | |
| 85 | + | export const SLIDES_MAX_MARKDOWN = 200_000; | |
| 86 | + | ||
| 87 | + | const THEME = /^[a-z0-9-]{1,40}$/; | |
| 88 | + | const COLOR = /^#[0-9a-f]{6}$/i; | |
| 89 | + | ||
| 90 | + | /** What is wrong with a slides op, or null. Checks its shape; whether its ids exist is the room's to say. */ | |
| 91 | + | export function slidesOpError(op: unknown): string | null { | |
| 92 | + | if (!isObject(op) || typeof op.op !== "string") return "A slides change has an op."; | |
| 93 | + | const markdown = () => { | |
| 94 | + | if (typeof op.markdown !== "string") return `${op.op} needs markdown.`; | |
| 95 | + | if (op.markdown.length > SLIDES_MAX_MARKDOWN) return `${op.op}'s markdown is too long.`; | |
| 96 | + | return null; | |
| 97 | + | }; | |
| 98 | + | const id = (key: string, nullable = false) => | |
| 99 | + | (nullable && op[key] === null) || (typeof op[key] === "string" && (op[key] as string).length > 0) ? null : `${op.op} needs ${key}.`; | |
| 100 | + | switch (op.op) { | |
| 101 | + | case "replace_deck": | |
| 102 | + | return markdown(); | |
| 103 | + | case "insert_slides": | |
| 104 | + | return id("after_slide_id", true) ?? markdown(); | |
| 105 | + | case "replace_slide": | |
| 106 | + | case "set_notes": | |
| 107 | + | return id("slide_id") ?? markdown(); | |
| 108 | + | case "delete_slides": | |
| 109 | + | return Array.isArray(op.slide_ids) && op.slide_ids.length > 0 && op.slide_ids.every((item) => typeof item === "string" && item.length > 0) | |
| 110 | + | ? null | |
| 111 | + | : "delete_slides needs slide_ids."; | |
| 112 | + | case "move_slide": | |
| 113 | + | return id("slide_id") ?? id("after_slide_id", true); | |
| 114 | + | case "set_theme": | |
| 115 | + | if (typeof op.theme !== "string" || !THEME.test(op.theme)) return "set_theme needs a theme name."; | |
| 116 | + | return op.accent === null || (typeof op.accent === "string" && COLOR.test(op.accent)) ? null : "accent is a colour like #8b7cf6, or null."; | |
| 117 | + | default: | |
| 118 | + | return `There is no slides op called ${op.op}.`; | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | function isObject(value: unknown): value is Record<string, unknown> { | |
| 123 | + | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 124 | + | } |
| 1 | + | { | |
| 2 | + | "about": "Cases both folio validators must agree on: folios.ts (newFolioError, folioAccessChangeError, folioAgentEditError, folioIdFrom) and crates/contracts/src/folios.rs. error null is valid; \"*\" is any error (a malformed input, which each side words its own way).", | |
| 3 | + | "new_folio": [ | |
| 4 | + | { "input": { "kind": "doc" }, "error": null }, | |
| 5 | + | { "input": { "kind": "slides", "title": "Q4 roadmap", "space_id": "spc_1", "content": { "markdown": "<!-- slide: title -->\n# Q4" } }, "error": null }, | |
| 6 | + | { "input": { "kind": "dashboard", "content": { "spec": { "tiles": [] } }, "share_with": [{ "principal": "team:platform", "role": "view" }] }, "error": null }, | |
| 7 | + | { "input": { "kind": "design", "template_id": "tpl_1" }, "error": null }, | |
| 8 | + | { "input": { "kind": "doc", "content": { "spec": {} } }, "error": "A doc starts from markdown." }, | |
| 9 | + | { "input": { "kind": "design", "content": { "markdown": "# no" } }, "error": "A design starts from a spec." }, | |
| 10 | + | { "input": { "kind": "doc", "content": { "markdown": "# Hi" }, "template_id": "tpl_1" }, "error": "Start from a template or from content, not both." }, | |
| 11 | + | { "input": { "kind": "doc", "share_with": [{ "principal": "everyone", "role": "view" }] }, "error": "everyone is not user:, agent: or team: and an id." }, | |
| 12 | + | { "input": { "kind": "doc", "title": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "error": "A title is at most 200 characters." }, | |
| 13 | + | { "input": { "kind": "spreadsheet" }, "error": "*" }, | |
| 14 | + | { "input": { "kind": "doc", "share_with": [{ "principal": "user:usr_1", "role": "owner" }] }, "error": "*" } | |
| 15 | + | ], | |
| 16 | + | "access_change": [ | |
| 17 | + | { "change": { "op": "grant", "principal": "user:usr_1", "role": "edit" }, "error": null }, | |
| 18 | + | { "change": { "op": "grant", "principal": "agent:agt_1", "role": "comment", "notify": "Have a look" }, "error": null }, | |
| 19 | + | { "change": { "op": "revoke", "principal": "team:design" }, "error": null }, | |
| 20 | + | { "change": { "op": "general", "access": "workspace", "role": "view" }, "error": null }, | |
| 21 | + | { "change": { "op": "general", "access": "none", "role": null }, "error": null }, | |
| 22 | + | { "change": { "op": "inherit", "inherit": false }, "error": null }, | |
| 23 | + | { "change": { "op": "agent_mode", "agent_mode": null }, "error": null }, | |
| 24 | + | { "change": { "op": "grant", "principal": "usr_1", "role": "edit" }, "error": "usr_1 is not user:, agent: or team: and an id." }, | |
| 25 | + | { "change": { "op": "general", "access": "link", "role": "manage" }, "error": "General access gives view, comment or edit, never full access." }, | |
| 26 | + | { "change": { "op": "general", "access": "none", "role": "view" }, "error": "Restricted takes no role." }, | |
| 27 | + | { "change": { "op": "general", "access": "workspace", "role": null }, "error": "Everyone in the workspace needs a role." }, | |
| 28 | + | { "change": { "op": "publish" }, "error": "*" }, | |
| 29 | + | { "change": { "op": "agent_mode", "agent_mode": "always" }, "error": "*" } | |
| 30 | + | ], | |
| 31 | + | "agent_edit": [ | |
| 32 | + | { "edit": { "kind": "doc", "target": { "kind": "append" }, "markdown": "More." }, "error": null }, | |
| 33 | + | { "edit": { "kind": "doc", "target": { "kind": "section", "heading": "Pricing" }, "markdown": "## Pricing\n…", "note": "Updated prices", "marks_current": true }, "error": null }, | |
| 34 | + | { "edit": { "kind": "slides", "ops": [{ "op": "set_theme", "theme": "night", "accent": null }] }, "error": null }, | |
| 35 | + | { "edit": { "kind": "dashboard", "ops": [{ "op": "delete_tiles", "ids": ["t1"] }], "suggest_only": true }, "error": null }, | |
| 36 | + | { "edit": "replace everything", "error": "An edit is an object." }, | |
| 37 | + | { "edit": { "kind": "sheet", "ops": [] }, "error": "There is no kind of artifact called sheet." }, | |
| 38 | + | { "edit": { "ops": [] }, "error": "There is no kind of artifact called undefined." }, | |
| 39 | + | { "edit": { "kind": "doc", "target": { "kind": "append" }, "markdown": "x", "note": 5 }, "error": "note is text." }, | |
| 40 | + | { "edit": { "kind": "doc", "target": { "kind": "append" } }, "error": "A doc edit has markdown." }, | |
| 41 | + | { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "append" }, "ops": [] }, "error": "A doc edit has a target and markdown, not ops." }, | |
| 42 | + | { "edit": { "kind": "doc", "markdown": "x" }, "error": "A doc edit has a target." }, | |
| 43 | + | { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "section" } }, "error": "A section target names its heading." }, | |
| 44 | + | { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "blocks", "from_block": "a" } }, "error": "A blocks target names from_block and to_block." }, | |
| 45 | + | { "edit": { "kind": "doc", "markdown": "x", "target": { "kind": "line" } }, "error": "A doc edit's target is append, document, section or blocks." }, | |
| 46 | + | { "edit": { "kind": "slides", "markdown": "# x" }, "error": "A deck edit has ops, not a target and markdown." }, | |
| 47 | + | { "edit": { "kind": "design", "ops": [] }, "error": "A design edit has a list of ops." }, | |
| 48 | + | { "edit": { "kind": "dashboard", "ops": ["delete everything"] }, "error": "Each op is an object with an op." } | |
| 49 | + | ], | |
| 50 | + | "folio_ids": [ | |
| 51 | + | { "segment": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra" }, | |
| 52 | + | { "segment": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra" }, | |
| 53 | + | { "segment": "q4-roadmap-pag_01jb2k7x9hfq0b3zj0f5s2m8ra", "id": null }, | |
| 54 | + | { "segment": "fol_01jb2k7x9hfq0b3zj0f5s2m8rI", "id": null }, | |
| 55 | + | { "segment": "templates", "id": null } | |
| 56 | + | ] | |
| 57 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync } from "node:fs"; | |
| 3 | + | import { test } from "node:test"; | |
| 4 | + | ||
| 5 | + | import type { ServiceBinding } from "./clients.ts"; | |
| 6 | + | import { datasetQueryError } from "./datasets.ts"; | |
| 7 | + | import { dashboardGridError, dashboardOpError } from "./folios-dashboard.ts"; | |
| 8 | + | import { DESIGN_MAX_NODES, countDesignSpecs, designOpError } from "./folios-design.ts"; | |
| 9 | + | import { SLIDE_LAYOUTS, SLIDE_LAYOUT_REGIONS, slideFragment, slidesOpError } from "./folios-slides.ts"; | |
| 10 | + | import { | |
| 11 | + | FOLIO_KINDS, | |
| 12 | + | FOLIO_RPC_METHODS, | |
| 13 | + | FOLIO_ROUTE_SEGMENTS, | |
| 14 | + | folioAccessChangeError, | |
| 15 | + | folioAgentEditError, | |
| 16 | + | folioCanHaveChildren, | |
| 17 | + | folioIdFrom, | |
| 18 | + | folioListQueryError, | |
| 19 | + | folioPath, | |
| 20 | + | folioSlug, | |
| 21 | + | foliosClient, | |
| 22 | + | linkedFolioIds, | |
| 23 | + | newFolioError, | |
| 24 | + | type FolioAccessChange, | |
| 25 | + | type NewFolio, | |
| 26 | + | } from "./folios.ts"; | |
| 27 | + | import type { User } from "./identity.ts"; | |
| 28 | + | ||
| 29 | + | const fixtures = JSON.parse(readFileSync(new URL("./folios.fixtures.json", import.meta.url), "utf8")) as { | |
| 30 | + | new_folio: { input: unknown; error: string | null }[]; | |
| 31 | + | access_change: { change: unknown; error: string | null }[]; | |
| 32 | + | agent_edit: { edit: unknown; error: string | null }[]; | |
| 33 | + | folio_ids: { segment: string; id: string | null }[]; | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | function expect(label: string, actual: string | null, error: string | null) { | |
| 37 | + | if (error === null) assert.equal(actual, null, label); | |
| 38 | + | else if (error === "*") assert.notEqual(actual, null, `${label} should be refused`); | |
| 39 | + | else assert.equal(actual, error, label); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | test("agrees with the Rust validators on every shared case", () => { | |
| 43 | + | for (const { input, error } of fixtures.new_folio) expect(JSON.stringify(input), newFolioError(input as NewFolio), error); | |
| 44 | + | for (const { change, error } of fixtures.access_change) expect(JSON.stringify(change), folioAccessChangeError(change as FolioAccessChange), error); | |
| 45 | + | for (const { edit, error } of fixtures.agent_edit) expect(JSON.stringify(edit), folioAgentEditError(edit), error); | |
| 46 | + | for (const { segment, id } of fixtures.folio_ids) assert.equal(folioIdFrom(segment), id, segment); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("addresses are flat, end in the id and survive renames", () => { | |
| 50 | + | const id = "fol_01jb2k7x9hfq0b3zj0f5s2m8ra"; | |
| 51 | + | assert.equal(folioPath("acme", "Q4 roadmap: pricing", id), `/acme/-/artifacts/q4-roadmap-pricing-${id}`); | |
| 52 | + | assert.equal(folioSlug("", id), id); | |
| 53 | + | assert.equal(folioSlug("Café ✨ notes", id), `cafe-notes-${id}`); | |
| 54 | + | assert.equal(folioIdFrom(folioSlug("Anything at all", id)), id); | |
| 55 | + | assert.deepEqual(linkedFolioIds(`see /acme/-/artifacts/x-${id} and ${id}`), [id]); | |
| 56 | + | for (const segment of FOLIO_ROUTE_SEGMENTS) assert.equal(folioIdFrom(segment), null, segment); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("only a doc holds other folios", () => { | |
| 60 | + | assert.deepEqual(FOLIO_KINDS.filter(folioCanHaveChildren), ["doc"]); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("list queries are checked", () => { | |
| 64 | + | assert.equal(folioListQueryError({ tab: "shared", kinds: ["slides"], limit: 50 }), null); | |
| 65 | + | assert.equal(folioListQueryError({ tab: "everything" as "all" }), "tab is all, yours or shared."); | |
| 66 | + | assert.equal(folioListQueryError({ tab: "all", limit: 500 }), "limit is between 1 and 100."); | |
| 67 | + | assert.equal(folioListQueryError({ tab: "all", kinds: ["sheet" as "doc"] }), "There is no kind of artifact called sheet."); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("the client calls exactly the docs service's folio methods", async () => { | |
| 71 | + | const called: string[] = []; | |
| 72 | + | const bodies: Record<string, unknown>[] = []; | |
| 73 | + | const binding: ServiceBinding = { | |
| 74 | + | fetch: async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 75 | + | called.push(String(input).replace("https://service/rpc/", "")); | |
| 76 | + | bodies.push(JSON.parse(String(init?.body))); | |
| 77 | + | return new Response(JSON.stringify({ ok: true, value: null }), { headers: { "content-type": "application/json" } }); | |
| 78 | + | }, | |
| 79 | + | } as ServiceBinding; | |
| 80 | + | const client = foliosClient(binding); | |
| 81 | + | const viewer = { id: "usr_1" } as User; | |
| 82 | + | const ws = "acme"; | |
| 83 | + | const f = "fol_1"; | |
| 84 | + | await Promise.all([ | |
| 85 | + | client.list(ws, viewer, { tab: "all" }), | |
| 86 | + | client.sidebar(ws, viewer), | |
| 87 | + | client.folio(ws, viewer, f), | |
| 88 | + | client.create(ws, viewer, { kind: "doc" }), | |
| 89 | + | client.update(ws, viewer, f, { title: "x" }), | |
| 90 | + | client.move(ws, viewer, f, { space_id: null, parent_id: null }), | |
| 91 | + | client.duplicate(ws, viewer, f), | |
| 92 | + | client.trash(ws, viewer, f), | |
| 93 | + | client.restore(ws, viewer, f), | |
| 94 | + | client.delete(ws, viewer, f), | |
| 95 | + | client.trashed(ws, viewer), | |
| 96 | + | client.favorite(ws, viewer, f, true), | |
| 97 | + | client.content(ws, viewer, f), | |
| 98 | + | client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }), | |
| 99 | + | client.access(ws, viewer, f), | |
| 100 | + | client.changeAccess(ws, viewer, f, { op: "grant", principal: "user:usr_2", role: "view" }), | |
| 101 | + | client.changeAccess(ws, viewer, f, { op: "general", access: "workspace", role: "view" }), | |
| 102 | + | client.requestAccess(ws, viewer, f), | |
| 103 | + | client.joinSpace(ws, viewer, "spc_1"), | |
| 104 | + | client.leaveSpace(ws, viewer, "spc_1"), | |
| 105 | + | client.search(ws, viewer, { q: "roadmap" }), | |
| 106 | + | client.versions(ws, viewer, f), | |
| 107 | + | client.version(ws, viewer, f, "ver_1"), | |
| 108 | + | client.restoreVersion(ws, viewer, f, "ver_1"), | |
| 109 | + | client.templates(ws, viewer), | |
| 110 | + | client.saveTemplate(ws, viewer, { folio_id: f, name: "T" }), | |
| 111 | + | client.deleteTemplate(ws, viewer, "tpl_1"), | |
| 112 | + | client.export(ws, viewer, f), | |
| 113 | + | client.suggestions(ws, viewer, f), | |
| 114 | + | client.decideSuggestion(ws, viewer, "sug_1", "accept"), | |
| 115 | + | client.proposals(ws, viewer, f), | |
| 116 | + | client.decideProposal(ws, viewer, "prp_1", "reject"), | |
| 117 | + | client.thread(ws, viewer, f, { op: "resolve", thread_id: "thr_1" }), | |
| 118 | + | client.threads(ws, viewer, f), | |
| 119 | + | client.queryTile(ws, viewer, f, "t1"), | |
| 120 | + | client.queryDataset(ws, viewer, { dataset: "issues", measure: { op: "count" } }), | |
| 121 | + | client.queryDatasetForAgent(ws, "agt_1", viewer, { folio_id: f, tile_id: "t1" }), | |
| 122 | + | client.foliosForAgent(ws, "agt_1", viewer, { tab: "all" }), | |
| 123 | + | client.readForAgent(ws, "agt_1", viewer, f), | |
| 124 | + | client.createAsAgent(ws, "agt_1", viewer, { kind: "doc", title: "Notes", where: "private" }), | |
| 125 | + | client.editAsAgent(ws, "agt_1", viewer, f, { kind: "slides", ops: [{ op: "delete_slides", slide_ids: ["s1"] }] }), | |
| 126 | + | client.shareAsAgent(ws, "agt_1", viewer, f, { user_ids: ["usr_2"], role: "view" }, { kind: "people", user_ids: ["usr_1", "usr_2"] }), | |
| 127 | + | client.recallForAgent(ws, "agt_1", viewer, { query: "pricing" }), | |
| 128 | + | client.staleForAgent(ws, "agt_1", viewer), | |
| 129 | + | client.markCurrent(ws, viewer, f), | |
| 130 | + | client.reindex(ws, viewer), | |
| 131 | + | ]); | |
| 132 | + | assert.deepEqual([...new Set(called)].sort(), [...FOLIO_RPC_METHODS].sort()); | |
| 133 | + | // Every body names the workspace and the viewer, and keys are snake_case. | |
| 134 | + | for (const body of bodies) { | |
| 135 | + | assert.equal(body.workspace, ws); | |
| 136 | + | assert.ok(body.viewer); | |
| 137 | + | for (const key of Object.keys(body)) assert.match(key, /^[a-z_]+$/, key); | |
| 138 | + | } | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("slides ops are checked by shape", () => { | |
| 142 | + | assert.equal(slidesOpError({ op: "insert_slides", after_slide_id: null, markdown: "---" }), null); | |
| 143 | + | assert.equal(slidesOpError({ op: "move_slide", slide_id: "s1", after_slide_id: "s2" }), null); | |
| 144 | + | assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "#8b7cf6" }), null); | |
| 145 | + | assert.equal(slidesOpError({ op: "set_theme", theme: "night", accent: "lavender" }), "accent is a colour like #8b7cf6, or null."); | |
| 146 | + | assert.equal(slidesOpError({ op: "replace_slide", markdown: "# x" }), "replace_slide needs slide_id."); | |
| 147 | + | assert.equal(slidesOpError({ op: "delete_slides", slide_ids: [] }), "delete_slides needs slide_ids."); | |
| 148 | + | assert.equal(slidesOpError({ op: "shuffle" }), "There is no slides op called shuffle."); | |
| 149 | + | for (const layout of SLIDE_LAYOUTS) assert.ok(SLIDE_LAYOUT_REGIONS[layout].includes("notes"), layout); | |
| 150 | + | assert.equal(slideFragment("s1", "left"), "slide:s1:left"); | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | test("design ops are checked by shape and size", () => { | |
| 154 | + | const frame = { type: "frame", name: "Home", layout: "column", gap: 16, children: [{ type: "text", text: "Hello" }, { type: "html", html: "<b>hi</b>" }] }; | |
| 155 | + | assert.equal(designOpError({ op: "upsert_nodes", nodes: [frame] }), null); | |
| 156 | + | assert.deepEqual(countDesignSpecs([frame as never]), { nodes: 3, depth: 2 }); | |
| 157 | + | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "rect", children: [{ type: "text" }] }] }), "Only frames and components have children."); | |
| 158 | + | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "text", html: "<b>" }] }), "Only an html node has html."); | |
| 159 | + | assert.equal(designOpError({ op: "upsert_nodes", nodes: [{ type: "blob" }] }), "There is no node type called blob."); | |
| 160 | + | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "x".repeat(200 * 1024 + 1) }), "An html node holds at most 200 KB."); | |
| 161 | + | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024) }), null); | |
| 162 | + | assert.equal(designOpError({ op: "set_html", node_id: "n1", html: "é".repeat(100 * 1024 + 1) }), "An html node holds at most 200 KB."); | |
| 163 | + | const many = Array.from({ length: DESIGN_MAX_NODES + 1 }, () => ({ type: "rect" })); | |
| 164 | + | assert.equal(designOpError({ op: "upsert_nodes", nodes: many }), `A design holds at most ${DESIGN_MAX_NODES} nodes.`); | |
| 165 | + | assert.equal(designOpError({ op: "move", ids: ["a"], dx: 1, dy: Number.NaN }), "move needs dx and dy."); | |
| 166 | + | assert.equal(designOpError({ op: "instance" }), "There is no design op called instance."); | |
| 167 | + | }); | |
| 168 | + | ||
| 169 | + | test("dashboard ops check tiles, their queries and the grid", () => { | |
| 170 | + | const tile = { | |
| 171 | + | id: "t1", | |
| 172 | + | type: "line", | |
| 173 | + | title: "Merged per week", | |
| 174 | + | description: "", | |
| 175 | + | query: { dataset: "pull_requests", measure: { op: "count" }, interval: "week", time: "merged_at" }, | |
| 176 | + | viz: {}, | |
| 177 | + | grid: { x: 0, y: 0, w: 6, h: 4 }, | |
| 178 | + | }; | |
| 179 | + | assert.equal(dashboardOpError({ op: "upsert_tile", tile }, datasetQueryError), null); | |
| 180 | + | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, query: { ...tile.query, group_by: "secret" } } }, datasetQueryError), "pull_requests can't be grouped by secret."); | |
| 181 | + | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown", query: null, markdown: "Notes" } }, datasetQueryError), null); | |
| 182 | + | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, type: "markdown" } }, datasetQueryError), "A text tile has no query."); | |
| 183 | + | assert.equal(dashboardOpError({ op: "upsert_tile", tile: { ...tile, grid: { x: 8, y: 0, w: 6, h: 4 } } }, datasetQueryError), "A tile fits in 12 columns."); | |
| 184 | + | assert.equal(dashboardOpError({ op: "set_filters", filters: { range: "30d", repos: ["acme/web"] } }, datasetQueryError), null); | |
| 185 | + | assert.equal(dashboardOpError({ op: "set_filters", filters: { range: { from: "2026-02-30", to: "2026-03-01" } } }, datasetQueryError), "A range's from and to are dates or RFC 3339 UTC times."); | |
| 186 | + | assert.equal(dashboardOpError({ op: "set_layout", tiles: [{ id: "t1", grid: { x: 0, y: 0, w: 0, h: 1 } }] }, datasetQueryError), "grid starts at 0, 0 and is at least 1 by 1."); | |
| 187 | + | assert.equal(dashboardGridError({ x: 0, y: 0, w: 12, h: 1 }), null); | |
| 188 | + | }); |
| 1 | + | /** | |
| 2 | + | * Folios: what people call artifacts. Artifacts mode is one mode for docs, | |
| 3 | + | * slides, designs and dashboards, each private, shared with people and | |
| 4 | + | * agents, in a space or open to the workspace, and edited live together. | |
| 5 | + | * Kept by the docs service (`services/docs`). Plan and decisions: | |
| 6 | + | * docs/ARTIFACTS_MODE.md. | |
| 7 | + | * | |
| 8 | + | * Naming: code says "folio", people see "artifact" (UI text, URLs | |
| 9 | + | * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the | |
| 10 | + | * `artifacts:*` scopes). The Cloudflare Artifacts git store and workflow | |
| 11 | + | * run artifacts are something else and keep their names. | |
| 12 | + | * | |
| 13 | + | * Wire shapes are snake_case end to end. Each kind's own model is in its | |
| 14 | + | * own file: folios-slides.ts, folios-design.ts, folios-dashboard.ts. | |
| 15 | + | * `crates/contracts/src/folios.rs` mirrors what the Rust API needs, and a | |
| 16 | + | * Rust test runs both validators over `folios.fixtures.json`. | |
| 17 | + | * | |
| 18 | + | * Access, in one place (section 2.1 of the plan): | |
| 19 | + | * | |
| 20 | + | * - Every folio has one owner, always a person, who has `manage`. | |
| 21 | + | * - It sits in a space, or in its owner's Private section (`space` null). | |
| 22 | + | * - A person's role is the highest of: owner; explicit grants on it or an | |
| 23 | + | * ancestor it inherits from; their space role when it inherits up to a | |
| 24 | + | * folio in a space; and the general access of its access root | |
| 25 | + | * (`workspace`: every member; `link`: members who opened the link). | |
| 26 | + | * - `inherit: false` makes a folio its own access root ("Only people | |
| 27 | + | * invited"). "Private" (the lock) is computed: only the owner can read it. | |
| 28 | + | * - An agent's role is never higher than its asker's, narrowed to what | |
| 29 | + | * every person in the audience can read. | |
| 30 | + | */ | |
| 31 | + | import type { MemberProfile } from "./chat"; | |
| 32 | + | import type { ServiceBinding } from "./clients"; | |
| 33 | + | import type { DatasetQuery, DatasetResult } from "./datasets"; | |
| 34 | + | import type { | |
| 35 | + | DocAgentAbilities, | |
| 36 | + | DocAgentMode, | |
| 37 | + | DocAudience, | |
| 38 | + | DocBlockOutline, | |
| 39 | + | DocDiffLine, | |
| 40 | + | DocEditTarget, | |
| 41 | + | DocRepoSpace, | |
| 42 | + | DocRole, | |
| 43 | + | DocSpace, | |
| 44 | + | DocSpaceKind, | |
| 45 | + | DocSuggestion, | |
| 46 | + | DocThread, | |
| 47 | + | DocThreadAction, | |
| 48 | + | } from "./docs"; | |
| 49 | + | import type { DashboardOp, DashboardPreview } from "./folios-dashboard"; | |
| 50 | + | import type { DesignOp, DesignPreview } from "./folios-design"; | |
| 51 | + | import type { SlidesOp, SlidesPreview } from "./folios-slides"; | |
| 52 | + | import type { User } from "./identity"; | |
| 53 | + | import type { Result } from "./result"; | |
| 54 | + | ||
| 55 | + | // ── Kinds ───────────────────────────────────────────────────────────── | |
| 56 | + | ||
| 57 | + | export type FolioKind = "doc" | "slides" | "design" | "dashboard"; | |
| 58 | + | export const FOLIO_KINDS: readonly FolioKind[] = ["doc", "slides", "design", "dashboard"]; | |
| 59 | + | ||
| 60 | + | /** The kinds as the Artifacts home's tiles name them. */ | |
| 61 | + | export const FOLIO_KIND_LABELS: Record<FolioKind, string> = { | |
| 62 | + | doc: "Docs", | |
| 63 | + | slides: "Slides", | |
| 64 | + | design: "Design", | |
| 65 | + | dashboard: "Dashboard", | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | /** One of a kind, in a sentence: "a doc", "a deck"... */ | |
| 69 | + | export const FOLIO_KIND_NOUNS: Record<FolioKind, string> = { | |
| 70 | + | doc: "doc", | |
| 71 | + | slides: "deck", | |
| 72 | + | design: "design", | |
| 73 | + | dashboard: "dashboard", | |
| 74 | + | }; | |
| 75 | + | ||
| 76 | + | export function isFolioKind(value: unknown): value is FolioKind { | |
| 77 | + | return typeof value === "string" && (FOLIO_KINDS as readonly string[]).includes(value); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** Only a doc holds other folios: a doc with children is the folder. */ | |
| 81 | + | export function folioCanHaveChildren(kind: FolioKind): boolean { | |
| 82 | + | return kind === "doc"; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ── Roles and access ────────────────────────────────────────────────── | |
| 86 | + | ||
| 87 | + | /** What someone may do with a folio, weakest first. The same roles as a space's. */ | |
| 88 | + | export type FolioRole = DocRole; | |
| 89 | + | export const FOLIO_ROLES: readonly FolioRole[] = ["view", "comment", "edit", "manage"]; | |
| 90 | + | ||
| 91 | + | /** Who may open a folio besides its owner, grants and space. */ | |
| 92 | + | export type FolioGeneralAccess = "none" | "workspace" | "link"; | |
| 93 | + | export const FOLIO_GENERAL_ACCESS: readonly FolioGeneralAccess[] = ["none", "workspace", "link"]; | |
| 94 | + | ||
| 95 | + | export const FOLIO_GENERAL_ACCESS_LABELS: Record<FolioGeneralAccess, string> = { | |
| 96 | + | none: "Restricted", | |
| 97 | + | workspace: "Everyone in the workspace", | |
| 98 | + | link: "Anyone in the workspace with the link", | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | /** General access never gives `manage`. */ | |
| 102 | + | export type FolioGeneralRole = Exclude<FolioRole, "manage">; | |
| 103 | + | export const FOLIO_GENERAL_ROLES: readonly FolioGeneralRole[] = ["view", "comment", "edit"]; | |
| 104 | + | ||
| 105 | + | /** Space kinds as Artifacts names them (the database keeps workspace / team / private). */ | |
| 106 | + | export const FOLIO_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = { | |
| 107 | + | workspace: "Open", | |
| 108 | + | team: "Team", | |
| 109 | + | private: "Members only", | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | /** Who a grant names: `user:<id>`, `agent:<id>` or `team:<slug>`. */ | |
| 113 | + | export type FolioPrincipal = string; | |
| 114 | + | ||
| 115 | + | const PRINCIPAL = /^(user|agent|team):[A-Za-z0-9_.-]{1,64}$/; | |
| 116 | + | ||
| 117 | + | export function isFolioPrincipal(value: unknown): value is FolioPrincipal { | |
| 118 | + | return typeof value === "string" && PRINCIPAL.test(value); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /** The deepest a folio tree goes. */ | |
| 122 | + | export const FOLIO_MAX_DEPTH = 10; | |
| 123 | + | /** The longest title, in characters. */ | |
| 124 | + | export const FOLIO_MAX_TITLE = 200; | |
| 125 | + | /** The most people a folio is shared with in one change. */ | |
| 126 | + | export const FOLIO_MAX_SHARE = 50; | |
| 127 | + | /** A subtree larger than this has its access rebuilt by a queue job, not inline. */ | |
| 128 | + | export const FOLIO_INLINE_REACL = 2_000; | |
| 129 | + | ||
| 130 | + | // ── Addresses ───────────────────────────────────────────────────────── | |
| 131 | + | ||
| 132 | + | const FOLIO_ID = /(fol_[0-9a-hjkmnp-tv-z]{26})$/; | |
| 133 | + | ||
| 134 | + | /** The words of a title as an address: `q4-roadmap`. At most 50 characters. */ | |
| 135 | + | export function folioTitleSlug(title: string): string { | |
| 136 | + | return title | |
| 137 | + | .normalize("NFKD") | |
| 138 | + | .replace(/[̀-ͯ]/g, "") | |
| 139 | + | .toLowerCase() | |
| 140 | + | .replace(/[^a-z0-9]+/g, "-") | |
| 141 | + | .replace(/^-+|-+$/g, "") | |
| 142 | + | .slice(0, 50) | |
| 143 | + | .replace(/-+$/g, ""); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /** A folio's last address segment, `<title-slug>-<id>`: only the id is read, so renames keep links working. */ | |
| 147 | + | export function folioSlug(title: string, id: string): string { | |
| 148 | + | const words = folioTitleSlug(title); | |
| 149 | + | return words ? `${words}-${id}` : id; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** A folio's address. Flat: moving it between spaces and Private never breaks a link. */ | |
| 153 | + | export function folioPath(workspace: string, title: string, id: string): string { | |
| 154 | + | return `/${workspace}/-/artifacts/${folioSlug(title, id)}`; | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /** The folio id at the end of an address segment, or null. */ | |
| 158 | + | export function folioIdFrom(segment: string | null | undefined): string | null { | |
| 159 | + | return FOLIO_ID.exec(String(segment ?? ""))?.[1] ?? null; | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /** Every folio id a text links to, for backlinks. */ | |
| 163 | + | export function linkedFolioIds(text: string): string[] { | |
| 164 | + | return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])]; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /** Segments the site's routes use under `-/artifacts/`: never a folio. */ | |
| 168 | + | export const FOLIO_ROUTE_SEGMENTS: readonly string[] = ["live", "api", "query", "threads", "upload", "export", "new", "templates", "trash", "stale", "spaces", "repo"]; | |
| 169 | + | ||
| 170 | + | // ── Folios ──────────────────────────────────────────────────────────── | |
| 171 | + | ||
| 172 | + | /** Enough to link to a folio. */ | |
| 173 | + | export type FolioRef = { | |
| 174 | + | id: string; | |
| 175 | + | kind: FolioKind; | |
| 176 | + | title: string; | |
| 177 | + | /** An emoji, or null for the kind's icon. */ | |
| 178 | + | icon: string | null; | |
| 179 | + | /** `<title-slug>-<id>`. */ | |
| 180 | + | slug: string; | |
| 181 | + | /** `/<ws>/-/artifacts/<slug>`. */ | |
| 182 | + | path: string; | |
| 183 | + | }; | |
| 184 | + | ||
| 185 | + | /** The space a folio sits in. */ | |
| 186 | + | export type FolioSpaceRef = { id: string; slug: string; name: string; kind: DocSpaceKind }; | |
| 187 | + | ||
| 188 | + | /** Where a folio's access comes from when it inherits. */ | |
| 189 | + | export type FolioInheritedFrom = { kind: "space" | "folio"; id: string; name: string }; | |
| 190 | + | ||
| 191 | + | /** What a card draws: written when the folio is saved, never data values. */ | |
| 192 | + | export type FolioPreview = { kind: "doc"; lines: string[] } | SlidesPreview | DesignPreview | DashboardPreview; | |
| 193 | + | ||
| 194 | + | /** A folio as lists and its page show it, for one viewer. */ | |
| 195 | + | export type Folio = FolioRef & { | |
| 196 | + | workspace_id: string; | |
| 197 | + | /** Null: its owner's Private section. */ | |
| 198 | + | space: FolioSpaceRef | null; | |
| 199 | + | parent_id: string | null; | |
| 200 | + | position: number; | |
| 201 | + | owner: MemberProfile; | |
| 202 | + | created_by: MemberProfile; | |
| 203 | + | created_at: string; | |
| 204 | + | /** Any change: rename, move, share. */ | |
| 205 | + | updated_at: string; | |
| 206 | + | /** Content changes: "Edited 45m ago". */ | |
| 207 | + | edited_by: MemberProfile | null; | |
| 208 | + | edited_at: string; | |
| 209 | + | trashed_at: string | null; | |
| 210 | + | viewer_role: FolioRole; | |
| 211 | + | favorite: boolean; | |
| 212 | + | /** Only its owner can read it (the lock). */ | |
| 213 | + | private: boolean; | |
| 214 | + | /** How many people, agents and teams it is shared with directly. */ | |
| 215 | + | shared_count: number; | |
| 216 | + | /** The access root's general access. */ | |
| 217 | + | general_access: FolioGeneralAccess; | |
| 218 | + | general_role: FolioGeneralRole | null; | |
| 219 | + | /** False: "Only people invited", its own access root. */ | |
| 220 | + | inherit: boolean; | |
| 221 | + | inherited_from: FolioInheritedFrom | null; | |
| 222 | + | /** How agents change it: its own, else its space's, else `suggest`. */ | |
| 223 | + | agent_mode: DocAgentMode; | |
| 224 | + | excerpt: string; | |
| 225 | + | preview: FolioPreview | null; | |
| 226 | + | /** Where it was written up from, such as a chat thread, when it was. */ | |
| 227 | + | source: { title: string; href: string } | null; | |
| 228 | + | /** Possibly out of date: code it cites changed. */ | |
| 229 | + | stale: boolean; | |
| 230 | + | has_children: boolean; | |
| 231 | + | }; | |
| 232 | + | ||
| 233 | + | /** A row of the sidebar's trees. */ | |
| 234 | + | export type FolioTreeNode = { | |
| 235 | + | id: string; | |
| 236 | + | kind: FolioKind; | |
| 237 | + | parent_id: string | null; | |
| 238 | + | position: number; | |
| 239 | + | title: string; | |
| 240 | + | icon: string | null; | |
| 241 | + | slug: string; | |
| 242 | + | /** It restricts access below where it sits (a lock on the row). */ | |
| 243 | + | restricted: boolean; | |
| 244 | + | stale?: boolean; | |
| 245 | + | }; | |
| 246 | + | ||
| 247 | + | export type FolioListTab = "all" | "yours" | "shared"; | |
| 248 | + | export const FOLIO_LIST_TABS: readonly FolioListTab[] = ["all", "yours", "shared"]; | |
| 249 | + | ||
| 250 | + | export type FolioListQuery = { | |
| 251 | + | tab: FolioListTab; | |
| 252 | + | kinds?: FolioKind[] | null; | |
| 253 | + | space_id?: string | null; | |
| 254 | + | /** A member key, `user:<id>`. */ | |
| 255 | + | owner?: string | null; | |
| 256 | + | /** `owner/name`. */ | |
| 257 | + | project?: string | null; | |
| 258 | + | /** Words or meaning; hybrid search when given. */ | |
| 259 | + | q?: string | null; | |
| 260 | + | cursor?: string | null; | |
| 261 | + | limit?: number | null; | |
| 262 | + | }; | |
| 263 | + | ||
| 264 | + | /** The most folios a page of a list holds. */ | |
| 265 | + | export const FOLIO_LIST_MAX = 100; | |
| 266 | + | ||
| 267 | + | export type FolioList = { items: Folio[]; next_cursor: string | null }; | |
| 268 | + | ||
| 269 | + | export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] }; | |
| 270 | + | ||
| 271 | + | export type FoliosSidebar = { | |
| 272 | + | favorites: FolioRef[]; | |
| 273 | + | /** Joined open spaces, the viewer's team spaces and Members-only spaces. */ | |
| 274 | + | spaces: FoliosSidebarSpace[]; | |
| 275 | + | /** The viewer's own folios in no space. */ | |
| 276 | + | private_tree: FolioTreeNode[]; | |
| 277 | + | /** The tops of what is shared with the viewer: the highest ancestor they can read. */ | |
| 278 | + | shared: FolioRef[]; | |
| 279 | + | /** Projects' docs: repositories' `docs/` folders, read-only. */ | |
| 280 | + | repos: DocRepoSpace[]; | |
| 281 | + | can_create_space: boolean; | |
| 282 | + | trash_count: number; | |
| 283 | + | stale_count: number; | |
| 284 | + | }; | |
| 285 | + | ||
| 286 | + | /** Content to start a folio with: Markdown for docs and slides, a spec for designs and dashboards. */ | |
| 287 | + | export type FolioContentInput = { markdown: string } | { spec: unknown }; | |
| 288 | + | ||
| 289 | + | export type NewFolio = { | |
| 290 | + | kind: FolioKind; | |
| 291 | + | title?: string | null; | |
| 292 | + | icon?: string | null; | |
| 293 | + | /** Null or absent: the creator's Private section. */ | |
| 294 | + | space_id?: string | null; | |
| 295 | + | /** A doc to sit under. */ | |
| 296 | + | parent_id?: string | null; | |
| 297 | + | template_id?: string | null; | |
| 298 | + | content?: FolioContentInput | null; | |
| 299 | + | source?: { title: string; href: string } | null; | |
| 300 | + | share_with?: { principal: FolioPrincipal; role: FolioRole }[] | null; | |
| 301 | + | }; | |
| 302 | + | ||
| 303 | + | export type FolioChange = { | |
| 304 | + | title?: string; | |
| 305 | + | icon?: string | null; | |
| 306 | + | cover?: string | null; | |
| 307 | + | projects?: string[]; | |
| 308 | + | }; | |
| 309 | + | ||
| 310 | + | /** Where to put a folio: a space (null: Private) and a parent doc, before a sibling or last. */ | |
| 311 | + | export type FolioMove = { space_id: string | null; parent_id: string | null; before_id?: string | null }; | |
| 312 | + | ||
| 313 | + | // ── Sharing ─────────────────────────────────────────────────────────── | |
| 314 | + | ||
| 315 | + | /** Where a row of "Who has access" comes from. */ | |
| 316 | + | export type FolioAccessSource = | |
| 317 | + | | { kind: "owner" } | |
| 318 | + | /** A grant on this folio: editable here. */ | |
| 319 | + | | { kind: "grant" } | |
| 320 | + | /** A grant on a parent it inherits from: change it there. */ | |
| 321 | + | | { kind: "folio"; id: string; title: string; path: string } | |
| 322 | + | /** The space it inherits from. */ | |
| 323 | + | | { kind: "space"; id: string; name: string }; | |
| 324 | + | ||
| 325 | + | export type FolioAccessRow = { | |
| 326 | + | principal: FolioPrincipal; | |
| 327 | + | /** A person or agent; a team is shown by name. */ | |
| 328 | + | profile: MemberProfile | { kind: "team"; id: string; name: string; display_name: string }; | |
| 329 | + | role: FolioRole; | |
| 330 | + | source: FolioAccessSource; | |
| 331 | + | }; | |
| 332 | + | ||
| 333 | + | /** The share dialog. */ | |
| 334 | + | export type FolioAccessList = { | |
| 335 | + | folio_id: string; | |
| 336 | + | owner: MemberProfile; | |
| 337 | + | rows: FolioAccessRow[]; | |
| 338 | + | general_access: FolioGeneralAccess; | |
| 339 | + | general_role: FolioGeneralRole | null; | |
| 340 | + | inherit: boolean; | |
| 341 | + | inherited_from: FolioInheritedFrom | null; | |
| 342 | + | /** The folio's own setting; null follows its space's. */ | |
| 343 | + | agent_mode: DocAgentMode | null; | |
| 344 | + | /** The viewer may change any of it. */ | |
| 345 | + | can_share: boolean; | |
| 346 | + | /** Public links are off until a workspace turns them on (later). */ | |
| 347 | + | public_link: "off"; | |
| 348 | + | }; | |
| 349 | + | ||
| 350 | + | /** One change from the share dialog. */ | |
| 351 | + | export type FolioAccessChange = | |
| 352 | + | /** Share with someone, or change their role; `notify` is an optional message. */ | |
| 353 | + | | { op: "grant"; principal: FolioPrincipal; role: FolioRole; notify?: string | null } | |
| 354 | + | | { op: "revoke"; principal: FolioPrincipal } | |
| 355 | + | /** `none` takes no role; `workspace` and `link` take one, never `manage`. */ | |
| 356 | + | | { op: "general"; access: FolioGeneralAccess; role: FolioGeneralRole | null } | |
| 357 | + | /** Follow the space or parent (true), or "Only people invited" (false). */ | |
| 358 | + | | { op: "inherit"; inherit: boolean } | |
| 359 | + | | { op: "agent_mode"; agent_mode: DocAgentMode | null }; | |
| 360 | + | ||
| 361 | + | // ── History, proposals, templates ───────────────────────────────────── | |
| 362 | + | ||
| 363 | + | export type FolioVersionKind = "created" | "edit" | "agent" | "suggestion" | "proposal" | "restore"; | |
| 364 | + | ||
| 365 | + | export type FolioVersion = { | |
| 366 | + | id: string; | |
| 367 | + | folio_id: string; | |
| 368 | + | created_at: string; | |
| 369 | + | authors: MemberProfile[]; | |
| 370 | + | kind: FolioVersionKind; | |
| 371 | + | note: string | null; | |
| 372 | + | }; | |
| 373 | + | ||
| 374 | + | export type FolioVersionDetail = FolioVersion & { | |
| 375 | + | /** The folio's text rendition then. */ | |
| 376 | + | text: string; | |
| 377 | + | /** Against the version before it. */ | |
| 378 | + | diff: DocDiffLine[]; | |
| 379 | + | }; | |
| 380 | + | ||
| 381 | + | export type FolioProposalStatus = "open" | "accepted" | "rejected" | "stale"; | |
| 382 | + | ||
| 383 | + | /** An agent's whole change to a slides deck, design or dashboard, which a person previews and applies or rejects. */ | |
| 384 | + | export type FolioProposal = { | |
| 385 | + | id: string; | |
| 386 | + | folio_id: string; | |
| 387 | + | author: MemberProfile; | |
| 388 | + | asked_by: MemberProfile | null; | |
| 389 | + | note: string | null; | |
| 390 | + | /** "Adds slides 4–6; rewrites the title slide". */ | |
| 391 | + | summary: string; | |
| 392 | + | status: FolioProposalStatus; | |
| 393 | + | created_at: string; | |
| 394 | + | decided_by: MemberProfile | null; | |
| 395 | + | decided_at: string | null; | |
| 396 | + | }; | |
| 397 | + | ||
| 398 | + | /** A doc's tracked change, as Docs has them, on a folio. */ | |
| 399 | + | export type FolioSuggestion = Omit<DocSuggestion, "page_id"> & { folio_id: string }; | |
| 400 | + | ||
| 401 | + | export type FolioTemplate = { | |
| 402 | + | id: string; | |
| 403 | + | kind: FolioKind; | |
| 404 | + | name: string; | |
| 405 | + | description: string; | |
| 406 | + | icon: string | null; | |
| 407 | + | builtin: boolean; | |
| 408 | + | /** Markdown (doc, slides) or a JSON spec (design, dashboard). */ | |
| 409 | + | body: string; | |
| 410 | + | created_by: MemberProfile | null; | |
| 411 | + | }; | |
| 412 | + | ||
| 413 | + | // ── Live ────────────────────────────────────────────────────────────── | |
| 414 | + | ||
| 415 | + | /** JSON text frames on a folio's socket, besides the Yjs protocol's binary ones. */ | |
| 416 | + | export type FoliosLiveEvent = | |
| 417 | + | | { type: "folio.updated"; folio: Folio } | |
| 418 | + | | { type: "folio.trashed"; folio_id: string } | |
| 419 | + | | { type: "suggestion.created" | "suggestion.updated"; suggestion: FolioSuggestion } | |
| 420 | + | | { type: "proposal.created" | "proposal.updated"; proposal: FolioProposal } | |
| 421 | + | | { type: "version.created"; version: FolioVersion } | |
| 422 | + | /** Whether it is possibly out of date changed: ask again. */ | |
| 423 | + | | { type: "folio.staleness" } | |
| 424 | + | /** Its sharing changed: ask for the access list again to refresh badges. */ | |
| 425 | + | | { type: "folio.access" } | |
| 426 | + | /** The viewer's role changed, or their access ended (`role` null; the socket then closes with 4403). */ | |
| 427 | + | | { type: "access"; role: FolioRole | null }; | |
| 428 | + | ||
| 429 | + | // ── Agents ──────────────────────────────────────────────────────────── | |
| 430 | + | ||
| 431 | + | /** Who will see what an agent says. More than 20 people reads as the workspace. */ | |
| 432 | + | export type FolioAudience = DocAudience; | |
| 433 | + | ||
| 434 | + | export type FolioAgentAbilities = DocAgentAbilities; | |
| 435 | + | ||
| 436 | + | /** Options on any agent edit. */ | |
| 437 | + | export type FolioEditOptions = { | |
| 438 | + | note?: string | null; | |
| 439 | + | /** Never edit directly: file a suggestion (doc) or a proposal (others). */ | |
| 440 | + | suggest_only?: boolean | null; | |
| 441 | + | /** The edit brings it up to date with the code it cites. */ | |
| 442 | + | marks_current?: boolean | null; | |
| 443 | + | }; | |
| 444 | + | ||
| 445 | + | /** An agent's (or a token's) change to a folio, in the kind's own terms. */ | |
| 446 | + | export type FolioAgentEdit = ( | |
| 447 | + | | { kind: "doc"; target: DocEditTarget; markdown: string } | |
| 448 | + | | { kind: "slides"; ops: SlidesOp[] } | |
| 449 | + | | { kind: "design"; ops: DesignOp[] } | |
| 450 | + | | { kind: "dashboard"; ops: DashboardOp[] } | |
| 451 | + | ) & | |
| 452 | + | FolioEditOptions; | |
| 453 | + | ||
| 454 | + | /** The most ops one edit carries. */ | |
| 455 | + | export const FOLIO_MAX_OPS = 200; | |
| 456 | + | ||
| 457 | + | export type FolioAgentEditResult = | |
| 458 | + | | { mode: "applied"; version_id: string | null; folio: FolioRef; summary: string } | |
| 459 | + | | { mode: "suggested"; suggestion: FolioSuggestion; folio: FolioRef } | |
| 460 | + | | { mode: "proposed"; proposal: FolioProposal; folio: FolioRef }; | |
| 461 | + | ||
| 462 | + | /** | |
| 463 | + | * A folio in the form an agent reads and writes: a doc's Markdown with its | |
| 464 | + | * block ids; a deck's Markdown with slide ids; a design's node spec; a | |
| 465 | + | * dashboard's spec (tiles and queries, never values). | |
| 466 | + | */ | |
| 467 | + | export type FolioAgentRead = { | |
| 468 | + | folio: FolioRef & { edited_at: string }; | |
| 469 | + | space: { id: string; slug: string; name: string; agent_mode: DocAgentMode } | null; | |
| 470 | + | /** Markdown for doc and slides; JSON text for design and dashboard. */ | |
| 471 | + | content: string; | |
| 472 | + | /** doc: top-level blocks, for `blocks` targets. */ | |
| 473 | + | blocks?: DocBlockOutline[]; | |
| 474 | + | can: FolioAgentAbilities; | |
| 475 | + | /** False: someone the agent is talking to can't read it, so don't quote it there. */ | |
| 476 | + | audience_can_read: boolean; | |
| 477 | + | }; | |
| 478 | + | ||
| 479 | + | /** One passage recalled for an agent: part of a folio, or of a repository's docs. */ | |
| 480 | + | export type FolioPassage = { | |
| 481 | + | folio: FolioRef | null; | |
| 482 | + | repo_file: { repo: string; path: string; href: string } | null; | |
| 483 | + | space_name: string; | |
| 484 | + | heading: string | null; | |
| 485 | + | text: string; | |
| 486 | + | score: number; | |
| 487 | + | updated_at: string; | |
| 488 | + | stale: boolean; | |
| 489 | + | }; | |
| 490 | + | ||
| 491 | + | export type FolioSearchHit = FolioRef & { | |
| 492 | + | space_name: string | null; | |
| 493 | + | snippet: string; | |
| 494 | + | edited_at: string; | |
| 495 | + | heading?: string | null; | |
| 496 | + | matched?: "words" | "meaning" | "both" | null; | |
| 497 | + | }; | |
| 498 | + | ||
| 499 | + | // ── Validators (pure; mirrored in Rust) ─────────────────────────────── | |
| 500 | + | ||
| 501 | + | function isObject(value: unknown): value is Record<string, unknown> { | |
| 502 | + | return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | const isRole = (value: unknown): value is FolioRole => typeof value === "string" && (FOLIO_ROLES as readonly string[]).includes(value); | |
| 506 | + | ||
| 507 | + | /** What is wrong with a new folio, or null. Whether its space and parent exist and allow it is the service's to say. */ | |
| 508 | + | export function newFolioError(input: NewFolio): string | null { | |
| 509 | + | if (!isFolioKind(input.kind)) return `There is no kind of artifact called ${String(input.kind)}.`; | |
| 510 | + | const title = input.title ?? ""; | |
| 511 | + | if ([...title].length > FOLIO_MAX_TITLE) return `A title is at most ${FOLIO_MAX_TITLE} characters.`; | |
| 512 | + | const content = input.content ?? null; | |
| 513 | + | if (content !== null) { | |
| 514 | + | const wantsMarkdown = input.kind === "doc" || input.kind === "slides"; | |
| 515 | + | if (wantsMarkdown && typeof (content as { markdown?: unknown }).markdown !== "string") return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from markdown.`; | |
| 516 | + | if (!wantsMarkdown && !isObject((content as { spec?: unknown }).spec)) return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from a spec.`; | |
| 517 | + | if (input.template_id != null) return "Start from a template or from content, not both."; | |
| 518 | + | } | |
| 519 | + | const share = input.share_with ?? []; | |
| 520 | + | if (share.length > FOLIO_MAX_SHARE) return `Share with at most ${FOLIO_MAX_SHARE} at once.`; | |
| 521 | + | for (const row of share) { | |
| 522 | + | if (!isFolioPrincipal(row.principal)) return `${String(row.principal)} is not user:, agent: or team: and an id.`; | |
| 523 | + | if (!isRole(row.role)) return `There is no role called ${String(row.role)}.`; | |
| 524 | + | } | |
| 525 | + | return null; | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | /** What is wrong with a change from the share dialog, or null. */ | |
| 529 | + | export function folioAccessChangeError(change: FolioAccessChange): string | null { | |
| 530 | + | switch (change.op) { | |
| 531 | + | case "grant": | |
| 532 | + | if (!isFolioPrincipal(change.principal)) return `${String(change.principal)} is not user:, agent: or team: and an id.`; | |
| 533 | + | if (!isRole(change.role)) return `There is no role called ${String(change.role)}.`; | |
| 534 | + | if (change.notify != null && [...change.notify].length > 2_000) return "A message is at most 2000 characters."; | |
| 535 | + | return null; | |
| 536 | + | case "revoke": | |
| 537 | + | return isFolioPrincipal(change.principal) ? null : `${String(change.principal)} is not user:, agent: or team: and an id.`; | |
| 538 | + | case "general": | |
| 539 | + | if (!(FOLIO_GENERAL_ACCESS as readonly string[]).includes(change.access)) return `There is no general access called ${String(change.access)}.`; | |
| 540 | + | if (change.access === "none") return change.role === null ? null : "Restricted takes no role."; | |
| 541 | + | if (change.role === null) return `${FOLIO_GENERAL_ACCESS_LABELS[change.access]} needs a role.`; | |
| 542 | + | return (FOLIO_GENERAL_ROLES as readonly string[]).includes(change.role) ? null : "General access gives view, comment or edit, never full access."; | |
| 543 | + | case "inherit": | |
| 544 | + | return typeof change.inherit === "boolean" ? null : "inherit is true or false."; | |
| 545 | + | case "agent_mode": | |
| 546 | + | return change.agent_mode === null || change.agent_mode === "suggest" || change.agent_mode === "edit" ? null : "agent_mode is suggest, edit or null."; | |
| 547 | + | default: | |
| 548 | + | return `There is no access change called ${String((change as { op?: unknown }).op)}.`; | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | /** | |
| 553 | + | * What is wrong with an agent edit's envelope, or null: its kind, and a | |
| 554 | + | * doc's target and Markdown or the other kinds' list of ops. Each op is | |
| 555 | + | * checked by its kind's validator (`slidesOpError`, `designOpError`, | |
| 556 | + | * `dashboardOpError`). | |
| 557 | + | */ | |
| 558 | + | export function folioAgentEditError(edit: unknown): string | null { | |
| 559 | + | if (!isObject(edit)) return "An edit is an object."; | |
| 560 | + | if (!isFolioKind(edit.kind)) return `There is no kind of artifact called ${String(edit.kind)}.`; | |
| 561 | + | if (edit.note != null && typeof edit.note !== "string") return "note is text."; | |
| 562 | + | if (edit.kind === "doc") { | |
| 563 | + | if (typeof edit.markdown !== "string") return "A doc edit has markdown."; | |
| 564 | + | if (edit.ops !== undefined) return "A doc edit has a target and markdown, not ops."; | |
| 565 | + | const target = edit.target; | |
| 566 | + | if (!isObject(target)) return "A doc edit has a target."; | |
| 567 | + | switch (target.kind) { | |
| 568 | + | case "append": | |
| 569 | + | case "document": | |
| 570 | + | return null; | |
| 571 | + | case "section": | |
| 572 | + | return typeof target.heading === "string" && target.heading.length > 0 ? null : "A section target names its heading."; | |
| 573 | + | case "blocks": | |
| 574 | + | return typeof target.from_block === "string" && typeof target.to_block === "string" ? null : "A blocks target names from_block and to_block."; | |
| 575 | + | default: | |
| 576 | + | return "A doc edit's target is append, document, section or blocks."; | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | if (edit.markdown !== undefined || edit.target !== undefined) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has ops, not a target and markdown.`; | |
| 580 | + | if (!Array.isArray(edit.ops) || edit.ops.length === 0) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has a list of ops.`; | |
| 581 | + | if (edit.ops.length > FOLIO_MAX_OPS) return `An edit has at most ${FOLIO_MAX_OPS} ops.`; | |
| 582 | + | return edit.ops.every((op) => isObject(op) && typeof op.op === "string") ? null : "Each op is an object with an op."; | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | /** What is wrong with a list query, or null. */ | |
| 586 | + | export function folioListQueryError(query: FolioListQuery): string | null { | |
| 587 | + | if (!(FOLIO_LIST_TABS as readonly string[]).includes(query.tab)) return "tab is all, yours or shared."; | |
| 588 | + | for (const kind of query.kinds ?? []) if (!isFolioKind(kind)) return `There is no kind of artifact called ${String(kind)}.`; | |
| 589 | + | const limit = query.limit ?? null; | |
| 590 | + | if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > FOLIO_LIST_MAX)) return `limit is between 1 and ${FOLIO_LIST_MAX}.`; | |
| 591 | + | return null; | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // ── The docs service's folio RPC ────────────────────────────────────── | |
| 595 | + | ||
| 596 | + | /** Every method the docs service answers for folios at `/rpc/<method>` (plan section 7). */ | |
| 597 | + | export const FOLIO_RPC_METHODS = [ | |
| 598 | + | // Lists and navigation. | |
| 599 | + | "folio_list", | |
| 600 | + | "folio_sidebar", | |
| 601 | + | "folio", | |
| 602 | + | // Changing folios. | |
| 603 | + | "create_folio", | |
| 604 | + | "update_folio", | |
| 605 | + | "move_folio", | |
| 606 | + | "duplicate_folio", | |
| 607 | + | "trash_folio", | |
| 608 | + | "restore_folio", | |
| 609 | + | "delete_folio", | |
| 610 | + | "folio_trash", | |
| 611 | + | "favorite_folio", | |
| 612 | + | // A person's (or a token's) own read and edit in the agent form. | |
| 613 | + | "folio_content", | |
| 614 | + | "edit_folio", | |
| 615 | + | // Sharing and spaces. | |
| 616 | + | "folio_access", | |
| 617 | + | "set_folio_grant", | |
| 618 | + | "set_folio_general_access", | |
| 619 | + | "request_folio_access", | |
| 620 | + | "join_space", | |
| 621 | + | "leave_space", | |
| 622 | + | // Search, history, templates and export. | |
| 623 | + | "search_folios", | |
| 624 | + | "folio_versions", | |
| 625 | + | "folio_version", | |
| 626 | + | "restore_folio_version", | |
| 627 | + | "folio_templates", | |
| 628 | + | "save_folio_template", | |
| 629 | + | "delete_folio_template", | |
| 630 | + | "export_folio", | |
| 631 | + | // Suggestions, proposals and comments. | |
| 632 | + | "folio_suggestions", | |
| 633 | + | "decide_folio_suggestion", | |
| 634 | + | "folio_proposals", | |
| 635 | + | "decide_folio_proposal", | |
| 636 | + | "folio_thread", | |
| 637 | + | "folio_threads", | |
| 638 | + | // Dashboards. | |
| 639 | + | "query_tile", | |
| 640 | + | "query_dataset", | |
| 641 | + | "query_dataset_for_agent", | |
| 642 | + | // Agents. | |
| 643 | + | "folios_for_agent", | |
| 644 | + | "read_folio_for_agent", | |
| 645 | + | "create_folio_as_agent", | |
| 646 | + | "edit_folio_as_agent", | |
| 647 | + | "share_folio_as_agent", | |
| 648 | + | "recall_folios_for_agent", | |
| 649 | + | "stale_folios_for_agent", | |
| 650 | + | "mark_folio_current", | |
| 651 | + | "reindex_folios", | |
| 652 | + | ] as const; | |
| 653 | + | ||
| 654 | + | export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number]; | |
| 655 | + | ||
| 656 | + | export type FoliosApi = { | |
| 657 | + | // ── The site, and the API acting for a person ─────────────────────── | |
| 658 | + | list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>; | |
| 659 | + | sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>; | |
| 660 | + | /** A folio and the viewer's role in it; records the visit (which is what makes a link folio readable). Not found when they can't read it. */ | |
| 661 | + | folio(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>; | |
| 662 | + | create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>; | |
| 663 | + | update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>; | |
| 664 | + | /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */ | |
| 665 | + | move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>; | |
| 666 | + | duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>; | |
| 667 | + | /** To the trash, with everything under it. */ | |
| 668 | + | trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>; | |
| 669 | + | restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>; | |
| 670 | + | /** For good: only from the trash, manage role. */ | |
| 671 | + | delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>; | |
| 672 | + | trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>; | |
| 673 | + | favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>; | |
| 674 | + | /** The folio in its agent form, for a person or their token. */ | |
| 675 | + | content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>; | |
| 676 | + | /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */ | |
| 677 | + | edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>; | |
| 678 | + | ||
| 679 | + | access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>; | |
| 680 | + | /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */ | |
| 681 | + | changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>; | |
| 682 | + | /** Asks the owner and managers for access; they get an inbox item. */ | |
| 683 | + | requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>; | |
| 684 | + | joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>; | |
| 685 | + | leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>; | |
| 686 | + | ||
| 687 | + | search(workspace: string, viewer: User, query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }): Promise<Result<FolioSearchHit[]>>; | |
| 688 | + | versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>; | |
| 689 | + | version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>; | |
| 690 | + | restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>; | |
| 691 | + | templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>; | |
| 692 | + | saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>; | |
| 693 | + | deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>; | |
| 694 | + | export(workspace: string, viewer: User, folioId: string, format?: "markdown" | "json" | null): Promise<Result<{ filename: string; content_type: string; body: string }>>; | |
| 695 | + | ||
| 696 | + | suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>; | |
| 697 | + | decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>; | |
| 698 | + | proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>; | |
| 699 | + | decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>; | |
| 700 | + | thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>; | |
| 701 | + | threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>; | |
| 702 | + | ||
| 703 | + | /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */ | |
| 704 | + | queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>; | |
| 705 | + | /** A query as the viewer, for the API. */ | |
| 706 | + | queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>; | |
| 707 | + | ||
| 708 | + | // ── Agents (services/agents) ──────────────────────────────────────── | |
| 709 | + | // | |
| 710 | + | // Each takes the agent and the person it acts for (`viewer`). The agent | |
| 711 | + | // never reads or changes more than that person can, narrowed to what | |
| 712 | + | // everyone in `audience` can read. A folio it may not read is not found. | |
| 713 | + | ||
| 714 | + | foliosForAgent(workspace: string, agentId: string, viewer: User, query: FolioListQuery, audience?: FolioAudience | null): Promise<Result<FolioList>>; | |
| 715 | + | readForAgent(workspace: string, agentId: string, viewer: User, folioId: string, audience?: FolioAudience | null): Promise<Result<FolioAgentRead>>; | |
| 716 | + | /** | |
| 717 | + | * A new folio: the viewer owns it, the agent made it and gets `edit` on | |
| 718 | + | * it. `where` is a space the viewer can edit, `private`, or | |
| 719 | + | * `conversation` (Private plus `view` for these people). | |
| 720 | + | */ | |
| 721 | + | createAsAgent( | |
| 722 | + | workspace: string, | |
| 723 | + | agentId: string, | |
| 724 | + | viewer: User, | |
| 725 | + | input: { kind: FolioKind; title: string; content?: FolioContentInput | null; template_id?: string | null; where: { space_id: string } | "private" | { conversation: string[] }; parent_id?: string | null; source?: { title: string; href: string } | null }, | |
| 726 | + | ): Promise<Result<FolioRef>>; | |
| 727 | + | editAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>; | |
| 728 | + | /** `view` or `comment` for people already in the conversation, when the viewer has `manage`. Never general access, `edit` or `manage`. */ | |
| 729 | + | shareAsAgent(workspace: string, agentId: string, viewer: User, folioId: string, input: { user_ids: string[]; role: "view" | "comment" }, audience: FolioAudience): Promise<Result<FolioAccessList>>; | |
| 730 | + | recallForAgent(workspace: string, agentId: string, viewer: User, input: { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }, audience?: FolioAudience | null): Promise<Result<FolioPassage[]>>; | |
| 731 | + | staleForAgent(workspace: string, agentId: string, viewer: User, options?: { repo?: string | null; since?: string | null }, audience?: FolioAudience | null): Promise<Result<Folio[]>>; | |
| 732 | + | /** As the asker narrowed to repositories every audience member can read; spend only when the audience is the asker alone. */ | |
| 733 | + | queryDatasetForAgent(workspace: string, agentId: string, viewer: User, input: { query: DatasetQuery } | { folio_id: string; tile_id: string }, audience?: FolioAudience | null): Promise<Result<DatasetResult>>; | |
| 734 | + | markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>; | |
| 735 | + | /** Indexes the workspace's folios again in the background. Workspace owners. */ | |
| 736 | + | reindex(workspace: string, viewer: User): Promise<Result<boolean>>; | |
| 737 | + | }; | |
| 738 | + | ||
| 739 | + | export function foliosClient(service: ServiceBinding): FoliosApi { | |
| 740 | + | const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => { | |
| 741 | + | const response = await service.fetch(`https://service/rpc/${method}`, { | |
| 742 | + | method: "POST", | |
| 743 | + | headers: { "content-type": "application/json" }, | |
| 744 | + | body: JSON.stringify(args), | |
| 745 | + | }); | |
| 746 | + | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 747 | + | return (await response.json()) as T; | |
| 748 | + | }; | |
| 749 | + | return { | |
| 750 | + | list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }), | |
| 751 | + | sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }), | |
| 752 | + | folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }), | |
| 753 | + | create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }), | |
| 754 | + | update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }), | |
| 755 | + | move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }), | |
| 756 | + | duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }), | |
| 757 | + | trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }), | |
| 758 | + | restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }), | |
| 759 | + | delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }), | |
| 760 | + | trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }), | |
| 761 | + | favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }), | |
| 762 | + | content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }), | |
| 763 | + | edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }), | |
| 764 | + | access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }), | |
| 765 | + | changeAccess: (workspace, viewer, folioId, change) => | |
| 766 | + | call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }), | |
| 767 | + | requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }), | |
| 768 | + | joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }), | |
| 769 | + | leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }), | |
| 770 | + | search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }), | |
| 771 | + | versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }), | |
| 772 | + | version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }), | |
| 773 | + | restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }), | |
| 774 | + | templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }), | |
| 775 | + | saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }), | |
| 776 | + | deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }), | |
| 777 | + | export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }), | |
| 778 | + | suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }), | |
| 779 | + | decideSuggestion: (workspace, viewer, suggestionId, decision) => call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }), | |
| 780 | + | proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }), | |
| 781 | + | decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }), | |
| 782 | + | thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }), | |
| 783 | + | threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }), | |
| 784 | + | queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }), | |
| 785 | + | queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }), | |
| 786 | + | foliosForAgent: (workspace, agentId, viewer, query, audience) => call("folios_for_agent", { workspace, agent_id: agentId, viewer, query, audience: audience ?? null }), | |
| 787 | + | readForAgent: (workspace, agentId, viewer, folioId, audience) => call("read_folio_for_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, audience: audience ?? null }), | |
| 788 | + | createAsAgent: (workspace, agentId, viewer, input) => call("create_folio_as_agent", { workspace, agent_id: agentId, viewer, input }), | |
| 789 | + | editAsAgent: (workspace, agentId, viewer, folioId, edit) => call("edit_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, edit }), | |
| 790 | + | shareAsAgent: (workspace, agentId, viewer, folioId, input, audience) => | |
| 791 | + | call("share_folio_as_agent", { workspace, agent_id: agentId, viewer, folio_id: folioId, ...input, audience }), | |
| 792 | + | recallForAgent: (workspace, agentId, viewer, input, audience) => | |
| 793 | + | call("recall_folios_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }), | |
| 794 | + | staleForAgent: (workspace, agentId, viewer, options, audience) => | |
| 795 | + | call("stale_folios_for_agent", { workspace, agent_id: agentId, viewer, repo: options?.repo ?? null, since: options?.since ?? null, audience: audience ?? null }), | |
| 796 | + | queryDatasetForAgent: (workspace, agentId, viewer, input, audience) => | |
| 797 | + | call("query_dataset_for_agent", { workspace, agent_id: agentId, viewer, ...input, audience: audience ?? null }), | |
| 798 | + | markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }), | |
| 799 | + | reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }), | |
| 800 | + | }; | |
| 801 | + | } |
| 1 | 1 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 3 | − | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds"; | |
| 3 | + | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 14 | 14 | export * from "./connectors"; | |
| 15 | 15 | export * from "./context"; | |
| 16 | 16 | export * from "./d1"; | |
| 17 | + | export * from "./datasets"; | |
| 17 | 18 | export * from "./docs"; | |
| 18 | 19 | export * from "./deploy-keys"; | |
| 19 | 20 | export * from "./deployments"; | |
| 20 | 21 | export * from "./events"; | |
| 22 | + | export * from "./folios"; | |
| 23 | + | export * from "./folios-dashboard"; | |
| 24 | + | export * from "./folios-design"; | |
| 25 | + | export * from "./folios-slides"; | |
| 21 | 26 | export * from "./github"; | |
| 22 | 27 | export * from "./guardrails"; | |
| 23 | 28 | export * from "./identity"; |
| 30 | 30 | | "webhooks" | |
| 31 | 31 | | "secrets" | |
| 32 | 32 | | "runners" | |
| 33 | − | | "models"; | |
| 33 | + | | "models" | |
| 34 | + | | "artifacts"; | |
| 34 | 35 | ||
| 35 | 36 | export type ScopeLevel = "read" | "write" | "run" | "delete" | "admin"; | |
| 36 | 37 | ||
| ⋯ | |||
| 80 | 81 | { scope: "models:write", description: "Send model requests through the AI Gateway, which uses the workspace's AI credit" }, | |
| 81 | 82 | ] as const; | |
| 82 | 83 | ||
| 83 | − | export type Scope = (typeof SCOPES)[number]["scope"]; | |
| 84 | + | /** | |
| 85 | + | * Scopes of resources being built that tokens are not offered yet (Rust: | |
| 86 | + | * `Resource::offered`). They parse in Rust and are typed here, but no | |
| 87 | + | * preset, full access, OAuth request or token form hands them out, and no | |
| 88 | + | * operation needs them. When one ships, its rows move to the end of | |
| 89 | + | * `SCOPES` and `SCOPE_RESOURCES`. | |
| 90 | + | */ | |
| 91 | + | export const UPCOMING_SCOPES = [ | |
| 92 | + | { scope: "artifacts:read", description: "List, read and search artifacts you can see, their versions, and the numbers their dashboards show" }, | |
| 93 | + | { scope: "artifacts:write", description: "Create, rename, move, edit, trash and restore artifacts, and propose changes to them" }, | |
| 94 | + | { scope: "artifacts:admin", description: "Share artifacts, change who can open them, and delete them for good" }, | |
| 95 | + | ] as const; | |
| 96 | + | ||
| 97 | + | export type Scope = (typeof SCOPES)[number]["scope"] | (typeof UPCOMING_SCOPES)[number]["scope"]; | |
| 84 | 98 | ||
| 85 | 99 | /** Where a resource sits on the token form; only a person's token may hold account ones. */ | |
| 86 | 100 | export type ResourceGroup = "repository" | "workspace" | "account"; | |
| ⋯ | |||
| 110 | 124 | { resource: "models", label: "AI Gateway", group: "workspace" }, | |
| 111 | 125 | ]; | |
| 112 | 126 | ||
| 127 | + | /** Resources not offered yet, as `UPCOMING_SCOPES`: settings never show them. */ | |
| 128 | + | export const UPCOMING_RESOURCES: { resource: ScopeResource; label: string; group: ResourceGroup }[] = [ | |
| 129 | + | { resource: "artifacts", label: "Artifacts", group: "workspace" }, | |
| 130 | + | ]; | |
| 131 | + | ||
| 113 | 132 | const LEVEL_ORDER: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, delete: 3, admin: 4 }; | |
| 114 | 133 | ||
| 115 | 134 | export function scopeResource(scope: Scope): ScopeResource { | |
| ⋯ | |||
| 131 | 150 | } | |
| 132 | 151 | ||
| 133 | 152 | export function describeScope(scope: Scope): string { | |
| 134 | − | return SCOPES.find((row) => row.scope === scope)?.description ?? scope; | |
| 153 | + | return [...SCOPES, ...UPCOMING_SCOPES].find((row) => row.scope === scope)?.description ?? scope; | |
| 135 | 154 | } | |
| 136 | 155 | ||
| 137 | 156 | /** Whether holding `held` gives `needed`: the same resource, at its level or lower. */ | |