Skip to main content

adk_ui/
application.rs

1//! Multi-page application schema and deterministic runtime updates.
2//!
3//! `UiApplication` is additive to `UiResponse`: hosts that only understand a single
4//! surface can continue rendering page components, while application-aware hosts
5//! gain routing, page composition, and state-preserving navigation.
6
7use crate::schema::Component;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::{HashMap, HashSet};
12
13pub const MAX_APPLICATION_PAGES: usize = 24;
14pub const MAX_PAGE_REGIONS: usize = 16;
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct UiApplication {
18    pub id: String,
19    pub name: String,
20    #[serde(default = "default_application_version")]
21    pub version: u64,
22    #[serde(default)]
23    pub owner: String,
24    #[serde(default)]
25    pub updated_at: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub kit_id: Option<String>,
28    pub initial_route: String,
29    #[serde(default)]
30    pub navigation: Vec<UiNavigationItem>,
31    pub pages: Vec<UiApplicationPage>,
32    #[serde(default)]
33    pub state: HashMap<String, Value>,
34}
35
36fn default_application_version() -> u64 {
37    1
38}
39
40fn timestamp() -> String {
41    chrono::Utc::now().to_rfc3339()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct UiNavigationItem {
46    pub label: String,
47    pub route: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub icon: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct UiApplicationPage {
54    pub id: String,
55    pub route: String,
56    pub title: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub description: Option<String>,
59    #[serde(default)]
60    pub template: UiPageTemplate,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub eyebrow: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub hero: Option<UiPageHero>,
65    pub regions: Vec<UiPageRegion>,
66    #[serde(default)]
67    pub aside: Vec<Component>,
68    #[serde(default)]
69    pub footer: Vec<Component>,
70    #[serde(default)]
71    pub atmosphere: UiPageAtmosphere,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum UiPageTemplate {
77    Marketing,
78    #[default]
79    Dashboard,
80    Workspace,
81    Detail,
82    Form,
83    Immersive,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
87pub struct UiPageHero {
88    pub title: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub description: Option<String>,
91    #[serde(default)]
92    pub content: Vec<Component>,
93    #[serde(default)]
94    pub actions: Vec<Component>,
95    #[serde(default)]
96    pub visual: Vec<Component>,
97    #[serde(default)]
98    pub alignment: UiHeroAlignment,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum UiHeroAlignment {
104    #[default]
105    Split,
106    Center,
107    Editorial,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct UiPageRegion {
112    pub id: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub title: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117    #[serde(default)]
118    pub layout: UiRegionLayout,
119    #[serde(default = "default_region_columns")]
120    pub columns: u8,
121    #[serde(default)]
122    pub tone: UiRegionTone,
123    pub components: Vec<Component>,
124}
125
126fn default_region_columns() -> u8 {
127    3
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum UiRegionLayout {
133    #[default]
134    Stack,
135    Grid,
136    Bento,
137    Split,
138    Editorial,
139    Stats,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
143#[serde(rename_all = "snake_case")]
144pub enum UiRegionTone {
145    #[default]
146    Default,
147    Muted,
148    Accent,
149    Contrast,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
153#[serde(rename_all = "snake_case")]
154pub enum UiPageAtmosphere {
155    #[default]
156    Clean,
157    Grid,
158    Glow,
159    Gradient,
160    Spatial,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(tag = "operation", rename_all = "snake_case")]
165pub enum UiApplicationUpdate {
166    Navigate { route: String },
167    Replace { application: UiApplication },
168    UpsertPage { page: UiApplicationPage },
169    RemovePage { page_id: String },
170    PatchState { values: HashMap<String, Value> },
171}
172
173impl UiApplication {
174    pub fn normalize_metadata(&mut self, owner: impl Into<String>) {
175        if self.version == 0 {
176            self.version = 1;
177        }
178        if self.owner.trim().is_empty() {
179            self.owner = owner.into();
180        }
181        if self.updated_at.trim().is_empty() {
182            self.updated_at = timestamp();
183        }
184    }
185
186    fn touch(&mut self) {
187        self.version = self.version.saturating_add(1).max(1);
188        self.updated_at = timestamp();
189    }
190
191    pub fn validate(&self) -> Result<(), Vec<String>> {
192        let mut errors = Vec::new();
193        if self.id.trim().is_empty() {
194            errors.push("id must not be empty".to_string());
195        }
196        if self.name.trim().is_empty() {
197            errors.push("name must not be empty".to_string());
198        }
199        if self.pages.is_empty() {
200            errors.push("pages must contain at least one page".to_string());
201        }
202        if self.pages.len() > MAX_APPLICATION_PAGES {
203            errors.push(format!("pages must not exceed {MAX_APPLICATION_PAGES}"));
204        }
205
206        let mut ids = HashSet::new();
207        let mut routes = HashSet::new();
208        for (page_index, page) in self.pages.iter().enumerate() {
209            if page.id.trim().is_empty() || !ids.insert(page.id.as_str()) {
210                errors.push(format!(
211                    "pages[{page_index}].id must be non-empty and unique"
212                ));
213            }
214            if !is_route(&page.route) || !routes.insert(page.route.as_str()) {
215                errors.push(format!(
216                    "pages[{page_index}].route must be a unique absolute application route"
217                ));
218            }
219            if page.title.trim().is_empty() {
220                errors.push(format!("pages[{page_index}].title must not be empty"));
221            }
222            if page.regions.len() > MAX_PAGE_REGIONS {
223                errors.push(format!(
224                    "pages[{page_index}].regions must not exceed {MAX_PAGE_REGIONS}"
225                ));
226            }
227            let mut region_ids = HashSet::new();
228            for (region_index, region) in page.regions.iter().enumerate() {
229                if region.id.trim().is_empty() || !region_ids.insert(region.id.as_str()) {
230                    errors.push(format!("pages[{page_index}].regions[{region_index}].id must be non-empty and unique"));
231                }
232                if !(1..=6).contains(&region.columns) {
233                    errors.push(format!("pages[{page_index}].regions[{region_index}].columns must be between 1 and 6"));
234                }
235            }
236        }
237
238        if !routes.contains(self.initial_route.as_str()) {
239            errors.push("initial_route must reference an application page".to_string());
240        }
241        for (index, item) in self.navigation.iter().enumerate() {
242            if item.label.trim().is_empty() {
243                errors.push(format!("navigation[{index}].label must not be empty"));
244            }
245            if !routes.contains(item.route.as_str()) {
246                errors.push(format!(
247                    "navigation[{index}].route must reference an application page"
248                ));
249            }
250        }
251
252        if errors.is_empty() {
253            Ok(())
254        } else {
255            Err(errors)
256        }
257    }
258
259    pub fn apply_update(
260        &mut self,
261        update: UiApplicationUpdate,
262    ) -> Result<Option<String>, Vec<String>> {
263        match update {
264            UiApplicationUpdate::Navigate { route } => {
265                if self.pages.iter().any(|page| page.route == route) {
266                    Ok(Some(route))
267                } else {
268                    Err(vec![
269                        "navigate route must reference an application page".to_string(),
270                    ])
271                }
272            }
273            UiApplicationUpdate::Replace { application } => {
274                let mut application = application;
275                application.validate()?;
276                application.version = self.version.saturating_add(1).max(1);
277                if application.owner.trim().is_empty() {
278                    application.owner = self.owner.clone();
279                }
280                application.updated_at = timestamp();
281                *self = application;
282                Ok(Some(self.initial_route.clone()))
283            }
284            UiApplicationUpdate::UpsertPage { page } => {
285                let mut candidate = self.clone();
286                if let Some(index) = candidate
287                    .pages
288                    .iter()
289                    .position(|candidate| candidate.id == page.id)
290                {
291                    let previous_route = candidate.pages[index].route.clone();
292                    candidate.pages[index] = page;
293                    let next_route = candidate.pages[index].route.clone();
294                    if previous_route != next_route {
295                        if candidate.initial_route == previous_route {
296                            candidate.initial_route = next_route.clone();
297                        }
298                        for item in &mut candidate.navigation {
299                            if item.route == previous_route {
300                                item.route = next_route.clone();
301                            }
302                        }
303                    }
304                } else {
305                    candidate.pages.push(page);
306                }
307                candidate.validate()?;
308                candidate.touch();
309                *self = candidate;
310                Ok(None)
311            }
312            UiApplicationUpdate::RemovePage { page_id } => {
313                let mut candidate = self.clone();
314                candidate.pages.retain(|page| page.id != page_id);
315                candidate
316                    .navigation
317                    .retain(|item| candidate.pages.iter().any(|page| page.route == item.route));
318                candidate.validate()?;
319                candidate.touch();
320                *self = candidate;
321                Ok(None)
322            }
323            UiApplicationUpdate::PatchState { values } => {
324                self.state.extend(values);
325                self.touch();
326                Ok(None)
327            }
328        }
329    }
330}
331
332/// Atomically upsert a page in a persisted application surface.
333pub async fn upsert_persisted_application_page(
334    store: &dyn crate::persistence::SurfaceStore,
335    owner: &str,
336    application_id: &str,
337    expected_version: u64,
338    page: UiApplicationPage,
339) -> Result<UiApplication, crate::persistence::SurfaceStoreError> {
340    let saved = store.load(owner, application_id).await?;
341    if saved.version != expected_version {
342        return Err(crate::persistence::SurfaceStoreError::VersionConflict {
343            expected: expected_version,
344            actual: saved.version,
345        });
346    }
347    let mut application: UiApplication = serde_json::from_value(saved.payload)
348        .map_err(|error| crate::persistence::SurfaceStoreError::Json(error.to_string()))?;
349    application
350        .apply_update(UiApplicationUpdate::UpsertPage { page })
351        .map_err(|errors| crate::persistence::SurfaceStoreError::Json(errors.join("; ")))?;
352    application.owner = owner.to_string();
353    let payload = serde_json::to_value(&application)
354        .map_err(|error| crate::persistence::SurfaceStoreError::Json(error.to_string()))?;
355    let persisted = store
356        .save(
357            owner,
358            application_id,
359            &saved.name,
360            payload,
361            Some(expected_version),
362        )
363        .await?;
364    application.version = persisted.version;
365    application.updated_at = persisted.updated_at;
366    Ok(application)
367}
368
369fn is_route(route: &str) -> bool {
370    route.starts_with('/')
371        && !route.starts_with("//")
372        && !route.contains("..")
373        && !route.contains(['?', '#', '\\'])
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn page(id: &str, route: &str) -> UiApplicationPage {
381        UiApplicationPage {
382            id: id.to_string(),
383            route: route.to_string(),
384            title: id.to_string(),
385            description: None,
386            template: UiPageTemplate::Dashboard,
387            eyebrow: None,
388            hero: None,
389            regions: vec![],
390            aside: vec![],
391            footer: vec![],
392            atmosphere: UiPageAtmosphere::Clean,
393        }
394    }
395
396    #[test]
397    fn validates_multi_page_application() {
398        let app = UiApplication {
399            id: "ops".into(),
400            name: "Operations".into(),
401            version: 1,
402            owner: "test".into(),
403            updated_at: timestamp(),
404            kit_id: None,
405            initial_route: "/".into(),
406            navigation: vec![UiNavigationItem {
407                label: "Home".into(),
408                route: "/".into(),
409                icon: None,
410            }],
411            pages: vec![page("home", "/"), page("detail", "/detail")],
412            state: HashMap::new(),
413        };
414        assert!(app.validate().is_ok());
415    }
416
417    #[test]
418    fn rejects_unknown_navigation_route() {
419        let mut app = UiApplication {
420            id: "ops".into(),
421            name: "Operations".into(),
422            version: 1,
423            owner: "test".into(),
424            updated_at: timestamp(),
425            kit_id: None,
426            initial_route: "/".into(),
427            navigation: vec![UiNavigationItem {
428                label: "Missing".into(),
429                route: "/missing".into(),
430                icon: None,
431            }],
432            pages: vec![page("home", "/")],
433            state: HashMap::new(),
434        };
435        assert!(app.validate().is_err());
436        assert!(
437            app.apply_update(UiApplicationUpdate::Navigate {
438                route: "/missing".into()
439            })
440            .is_err()
441        );
442    }
443
444    #[test]
445    fn upsert_page_rewrites_routes_and_increments_version() {
446        let mut app = UiApplication {
447            id: "ops".into(),
448            name: "Operations".into(),
449            version: 3,
450            owner: "agent".into(),
451            updated_at: timestamp(),
452            kit_id: None,
453            initial_route: "/".into(),
454            navigation: vec![UiNavigationItem {
455                label: "Home".into(),
456                route: "/".into(),
457                icon: None,
458            }],
459            pages: vec![page("home", "/")],
460            state: HashMap::new(),
461        };
462        app.apply_update(UiApplicationUpdate::UpsertPage {
463            page: page("home", "/home"),
464        })
465        .unwrap();
466        assert_eq!(app.version, 4);
467        assert_eq!(app.initial_route, "/home");
468        assert_eq!(app.navigation[0].route, "/home");
469    }
470
471    #[tokio::test]
472    async fn persisted_upsert_uses_optimistic_versioning() {
473        let store = crate::persistence::InMemorySurfaceStore::new();
474        let app = UiApplication {
475            id: "ops".into(),
476            name: "Operations".into(),
477            version: 1,
478            owner: "agent".into(),
479            updated_at: timestamp(),
480            kit_id: None,
481            initial_route: "/".into(),
482            navigation: vec![],
483            pages: vec![page("home", "/")],
484            state: HashMap::new(),
485        };
486        let payload = serde_json::to_value(&app).unwrap();
487        crate::persistence::SurfaceStore::save(
488            &store,
489            "agent",
490            "ops",
491            "Operations",
492            payload,
493            Some(0),
494        )
495        .await
496        .unwrap();
497        let updated =
498            upsert_persisted_application_page(&store, "agent", "ops", 1, page("detail", "/detail"))
499                .await
500                .unwrap();
501        assert_eq!(updated.version, 2);
502        assert_eq!(updated.pages.len(), 2);
503    }
504}