diff --git a/crates/shell/resources/glance/fixtures/news-digest-run.json b/crates/shell/resources/glance/fixtures/news-digest-run.json new file mode 100644 index 00000000..f34499d8 --- /dev/null +++ b/crates/shell/resources/glance/fixtures/news-digest-run.json @@ -0,0 +1,123 @@ +{ + "run_id": "glance", + "app_id": "os.news", + "template": { + "id": "news-digest", + "version": "1.1.0", + "digest": "92ab809c79d8b83c370efeb37b9a7c6de632d313c45456fd1e91e45c43261f02" + }, + "status": "ready", + "data": { + "digest": { + "language": "en", + "points": [ + { + "citations": [ + "s72ee00603e06" + ], + "text": "Harbor City's council approved an order for 120 electric buses on Tuesday." + }, + { + "citations": [ + "s39327104dbcc" + ], + "text": "Ocho bibliotecas de Puerto Claro abrirán los domingos durante un programa piloto de tres meses." + }, + { + "citations": [ + "s3820b1dec583" + ], + "text": "Clearwater Utilities reported that a six-month sensor pilot detected 43 underground leaks across two districts." + } + ], + "summary": "Sources: Harbor City approves electric bus order; Ocho bibliotecas amplían su horario durante un piloto; Water utility reports early leak-detection results", + "task": "digest" + }, + "language": "en", + "missing": 0, + "sources": [ + { + "evidence_sha256": "d7006e8135e503bb16aedc89b4f5e6848da09c42d82097795a84fcbc609d8ba8", + "id": "s72ee00603e06", + "published_at": "2026-09-19T10:00:00Z", + "source": "Harbor Daily", + "title": "Harbor City approves electric bus order", + "url": "https://example.invalid/news/buses" + }, + { + "evidence_sha256": "fb9bd23cf16648691f79ed6f64b5add1fb35506cf838dac8785f428be7c102bd", + "id": "s39327104dbcc", + "published_at": "2026-09-19T08:30:00Z", + "source": "Diario de Puerto Claro", + "title": "Ocho bibliotecas amplían su horario durante un piloto", + "url": "https://example.invalid/news/libraries" + }, + { + "evidence_sha256": "1464f7fa5fe6edd95282a0f93cd0a62d0ad8cfcfb6d9271303d79bc3e0b6c2b1", + "id": "s3820b1dec583", + "published_at": "2026-09-18T16:00:00Z", + "source": "Clearwater Courier", + "title": "Water utility reports early leak-detection results", + "url": "https://example.invalid/news/water" + } + ], + "topic": "city infrastructure", + "unreadable": 0 + }, + "provenance": [ + { + "id": "s72ee00603e06", + "url": "https://example.invalid/news/buses", + "title": "Harbor City approves electric bus order", + "source": "Harbor Daily", + "language": "en", + "published_at": "2026-09-19T10:00:00Z", + "retrieved_at": "2026-09-20T08:00:01Z", + "evidence_sha256": "d7006e8135e503bb16aedc89b4f5e6848da09c42d82097795a84fcbc609d8ba8", + "via": "article" + }, + { + "id": "s39327104dbcc", + "url": "https://example.invalid/news/libraries", + "title": "Ocho bibliotecas amplían su horario durante un piloto", + "source": "Diario de Puerto Claro", + "language": "en", + "published_at": "2026-09-19T08:30:00Z", + "retrieved_at": "2026-09-20T08:00:02Z", + "evidence_sha256": "fb9bd23cf16648691f79ed6f64b5add1fb35506cf838dac8785f428be7c102bd", + "via": "article" + }, + { + "id": "s3820b1dec583", + "url": "https://example.invalid/news/water", + "title": "Water utility reports early leak-detection results", + "source": "Clearwater Courier", + "language": "en", + "published_at": "2026-09-18T16:00:00Z", + "retrieved_at": "2026-09-20T08:00:03Z", + "evidence_sha256": "1464f7fa5fe6edd95282a0f93cd0a62d0ad8cfcfb6d9271303d79bc3e0b6c2b1", + "via": "article" + } + ], + "diagnostics": [], + "stats": { + "calls": 7, + "model_calls": 1, + "pages": 3, + "search_fetches": 2, + "denied": 0, + "failed": 0, + "peak_concurrency": 3, + "elapsed_ms": 412.0 + }, + "trace": [ + { + "event": "start", + "call": 0, + "tool": "research.search", + "ms": 0.0 + } + ], + "started_at": "2026-09-20T08:00:00Z", + "result_path": "toolbox/runs/news-digest/glance.json" +} diff --git a/crates/shell/resources/glance/news-brief.card b/crates/shell/resources/glance/news-brief.card new file mode 100644 index 00000000..2fe3f7df --- /dev/null +++ b/crates/shell/resources/glance/news-brief.card @@ -0,0 +1,47 @@ +# ledger news.brief@1.0.0 +# level: L0 +# profile: ui/l0 +# +# The News digest card on the glance screen (ADR 0002 M4). The card carries +# no finding of its own: the summary, the points, their citations and every +# source come from `sys.digest`, which the shell resolves from the digest it +# holds for the publishing app (glance_digest.rs). News's agent runs the +# `news-digest` workflow with the run id `glance`, so each run replaces the +# digest this card binds, and republishes the card. + +source brief sys.digest(app: "os.news", id: "glance", + fields: [topic, summary, points, sources, + id, text, cite, n, title, source]) + +copy label { class: vocabulary, en: "NEWS DIGEST", zh: "新闻摘要" } +copy nothing { class: vocabulary, en: "No digest yet. News will brief you after its next read.", zh: "暂无摘要。新闻读完下一批后会为你汇总。" } +copy sources { class: vocabulary, en: "SOURCES", zh: "来源" } + +view root Surface(pad: .page) { + Col(gap: 6) { + Row(gap: 8) { + TextCaption(text: copy.label, width: .fill) + TextCaption(text: brief.topic) + } + when brief.$state == .failed { TextBody(text: copy.nothing, width: .fill) } + when brief.$state == .ready { + Col(gap: 6) { + TextBody(text: brief.summary, width: .fill) + for p in brief.points key p.id { + Row(gap: 8) { + TextRow(text: p.text, width: .fill) + TextCaption(text: p.cite) + } + } + TextCaption(text: copy.sources) + for s in brief.sources key s.id { + Row(gap: 8) { + TextCaption(text: s.n) + TextCaption(text: s.source) + TextCaption(text: s.title, width: .fill) + } + } + } + } + } + } diff --git a/crates/shell/resources/glance/news-digest.card b/crates/shell/resources/glance/news-digest.card index b524793c..b0f0f234 100644 --- a/crates/shell/resources/glance/news-digest.card +++ b/crates/shell/resources/glance/news-digest.card @@ -5,8 +5,8 @@ # The News digest on the glance screen (OctoSense #63): a test fixture the # shell publishes as `os.news` under OCTOSENSE_GLANCE_DEMO=1. Every value is # a source the publisher supplies in `data`; the card states nothing else. -# The sources are the catalogued News digest helpers; the `sys.digest(app:)` -# source ADR 0002 names is not in the pinned L0 catalog yet. +# The sources are the catalogued News digest helpers, fed by the publisher; +# news-brief.card is the same card bound to a host-held `sys.digest`. source status sys.news_status(fields: [message, count]) source stories sys.news_digest(count: 3, fields: [id, title, publisher]) diff --git a/crates/shell/src/glance.rs b/crates/shell/src/glance.rs index 9a0f0734..82230006 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -28,7 +28,11 @@ //! no logic, as a card bundle is. A `sys.chat` source (an in-card chat //! with the app's agent) must name the publishing app, and the host //! answers it from its own transcript, whatever `data` says -//! (glance_chat.rs). An L2 `source` is refused: it cannot be +//! (glance_chat.rs). A `sys.digest(app:, id:)` source is not the +//! publisher's to supply either: the shell resolves it from the digests +//! it holds for the calling app, and a card naming another app is refused +//! (glance_digest.rs). A card bound to a current digest expires no later +//! than the digest does. An L2 `source` is refused: it cannot be //! lowered, and a card that needs handlers and host requests is a //! `script`. //! - `script`: a Splash program, the same thing a script app's `main.splash` @@ -75,6 +79,7 @@ //! ([`shown`]). use serde_json::{json, Value}; use std::collections::VecDeque; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -150,6 +155,9 @@ pub struct GlanceCard { /// The publisher is a contained app: its tile runs under that app's /// resolved policy. A native module's tile runs with no grants. pub contained: bool, + /// The digest run ids the card binds (`sys.digest`), kept from pruning + /// while the card is live. + pub digests: Vec, /// For a `source` card, the L0 card and its data as published: the card /// window (glance_sheet.rs) carries out its taps against them and /// re-lowers. `None` for a `script` card. @@ -174,6 +182,9 @@ impl GlanceCard { pub struct GlanceStore { cards: Vec, publishes: Vec<(String, VecDeque)>, + /// Where the host keeps each app's toolbox folder (`sys.digest`); no + /// digest resolves without one. + digest_root: Option, } fn text<'a>(args: &'a Value, key: &str) -> Option<&'a str> { @@ -195,7 +206,67 @@ pub fn check_level(source: &str) -> Result<(), String> { Ok(()) } +/// The card's `sys.digest` sources, resolved for `caller` into `data`: the +/// value under the source's name and its lifecycle under `$status`, both +/// replacing anything the publisher sent. Returns the bound run ids and the +/// earliest expiry of a current digest. +fn resolve_digests(caller: &Caller, source: &str, data: &mut Value, root: Option<&std::path::Path>, now_ms: u64) -> Result<(Vec, Option), String> { + use octoscript_ui_l0::SourceArg; + let plan = octoscript_ui_l0::source_plan(source); + let mut ids = Vec::new(); + let mut expires: Option = None; + for request in plan.requests.iter().filter(|r| r.helper == "sys.digest") { + let arg = |name: &str| request.args.iter().find(|(n, _)| n == name).map(|(_, a)| a); + match arg("app") { + Some(SourceArg::Text(app)) if app == caller.app() || app == caller.launch_id() => {} + _ => return Err(format!("a card binds only its own app's digests: `sys.digest(app:)` must be {:?}", caller.app())), + } + let id = match arg("id") { + Some(SourceArg::Text(id)) => id.clone(), + // An id kept in card state: its declared initial. + Some(SourceArg::Path(path)) => match path.strip_prefix("state.") { + Some(state) => octoscript_ui_l0::state_initials(source).get(state).and_then(Value::as_str).unwrap_or("").to_string(), + None => path.split('.').try_fold(&*data, |v, k| v.get(k)).and_then(Value::as_str).unwrap_or("").to_string(), + }, + _ => String::new(), + }; + let resolved = crate::glance_digest::resolve(root, caller.app(), &id, now_ms); + if let Some(at) = resolved.expires_ms { + expires = Some(expires.map_or(at, |e| e.min(at))); + } + if crate::glance_digest::valid_id(&id) && !ids.contains(&id) { + ids.push(id); + } + let Some(fields) = data.as_object_mut() else { return Err("data must be an object of source values".into()) }; + // A dotted source name navigates the data, as realization reads it. + let mut segments: Vec<&str> = request.name.split('.').collect(); + let last = segments.pop().unwrap_or_default(); + let mut at = fields; + for segment in segments { + let slot = at.entry(segment.to_string()).or_insert_with(|| json!({})); + if !slot.is_object() { + *slot = json!({}); + } + at = slot.as_object_mut().expect("an object"); + } + at.insert(last.to_string(), resolved.value); + let status = data.as_object_mut().expect("an object").entry("$status").or_insert_with(|| json!({})); + if !status.is_object() { + *status = json!({}); + } + status[request.name.as_str()] = json!(resolved.state); + } + Ok((ids, expires)) +} + impl GlanceStore { + /// A store whose `sys.digest` sources resolve under `root` + /// (`//toolbox/runs/…`, glance_digest.rs). + pub fn with_digest_root(mut self, root: Option) -> Self { + self.digest_root = root; + self + } + /// `glance.publish`, for `caller`, at `now_ms`. pub fn publish(&mut self, caller: &Caller, args: &Value, now_ms: u64) -> Result { self.expire(now_ms); @@ -230,7 +301,7 @@ impl GlanceStore { if kind == "script" && args.get("data").is_some_and(|d| !d.is_null()) { return Err("data is for a source card; a script carries its own values".into()); } - let data = args.get("data").cloned().unwrap_or_else(|| json!({})); + let mut data = args.get("data").cloned().unwrap_or_else(|| json!({})); if !data.is_object() { return Err("data must be an object of source values".into()); } @@ -266,27 +337,33 @@ impl GlanceStore { // Charged before the costly part (check, realize, lower), so a // stream of refused cards is bounded too. self.charge(&app, now_ms)?; - let body: Arc = if kind == "script" { - source.into() + let (body, digests, digest_expires): (Arc, Vec, Option) = if kind == "script" { + (source.into(), Vec::new(), None) } else { check_level(source)?; + // A card's `sys.digest` values are the host's (glance_digest.rs), + // resolved into the data the card keeps. + let (digests, digest_expires) = resolve_digests(caller, source, &mut data, self.digest_root.as_deref(), now_ms)?; // A card's `sys.chat` is its own app's (glance_chat.rs), and its // transcript is the host's, never the data's. crate::glance_chat::check_publisher(source, &app)?; let seeded = crate::glance_chat::seed(&app, source, &data, &Default::default()); - crate::glance_card::lower(source, &seeded)?.into() + (crate::glance_card::lower(source, &seeded)?.into(), digests, digest_expires) }; + let binds_digests = !digests.is_empty(); let card = GlanceCard { app: app.clone(), card_id: card_id.to_string(), title: title.to_string(), priority, published_ms: now_ms, - expires_ms: now_ms + expires_s * 1000, + // Never past the digest it shows. + expires_ms: digest_expires.map_or(now_ms + expires_s * 1000, |d| d.min(now_ms + expires_s * 1000)), open_app: caller.launch_id().to_string(), route, body, contained: matches!(caller, Caller::Contained { .. }), + digests, l0: (kind == "source").then(|| Arc::new(L0Source { source: source.to_string(), data })), }; let expires_at = card.expires_ms; @@ -300,9 +377,35 @@ impl GlanceStore { self.cards.remove(i); } } + if binds_digests { + self.retain_digests(&app); + } Ok(json!({"card_id": card_id, "replaced": replacing.is_some(), "expires_at": expires_at})) } + /// Digest retention for `app` (glance_digest.rs): each template folder + /// keeps its newest results and every run a live card of the app binds. + /// Called after a publish that binds digests. + fn retain_digests(&self, app: &str) { + let Some(root) = &self.digest_root else { return }; + let pinned: Vec = self.cards.iter().filter(|c| c.app == app).flat_map(|c| c.digests.iter().cloned()).collect(); + crate::glance_digest::retain(root, app, &pinned); + } + + /// [`note_summary`] for the card just published under `key`, read from + /// the card's data as the store keeps it: a `sys.digest` value there is + /// the host's (glance_digest.rs), never what the publisher sent. + fn note_summary_for(&self, key: &str, args: &Value) -> String { + match self.cards.iter().find(|c| c.key() == key).and_then(|c| c.l0.as_ref()) { + Some(l0) => { + let mut kept = args.clone(); + kept["data"] = l0.data.clone(); + note_summary(&kept) + } + None => note_summary(args), + } + } + /// Count one publish against `app`'s window, or refuse it. fn charge(&mut self, app: &str, now_ms: u64) -> Result<(), String> { let at = match self.publishes.iter().position(|(a, _)| a == app) { @@ -477,7 +580,7 @@ pub fn now_ms() -> u64 { fn with_store(f: impl FnOnce(&mut GlanceStore) -> R) -> R { let mut guard = STORE.lock().unwrap(); - f(guard.get_or_insert_with(GlanceStore::default)) + f(guard.get_or_insert_with(|| GlanceStore::default().with_digest_root(crate::glance_digest::digest_root()))) } fn changed() { @@ -505,12 +608,14 @@ pub fn request(caller: &Caller, service: &str, args: &Value) -> Result/.host/toolbox/os.news/toolbox/runs/*/glance.json`; any +/// other value but `0` publishes the sample News digest as `os.news`. pub fn publish_demo_if_asked() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { @@ -750,7 +860,7 @@ pub fn publish_demo_if_asked() { } } } - let (source, data) = demo_digest(); + let (source, data) = if demo == "digest" { (NEWS_BRIEF_CARD.to_string(), json!({})) } else { demo_digest() }; let args = json!({ "card_id": "digest", "title": "News digest", "source": source, "data": data, "priority": 70, "open": {"app": "news"} @@ -842,6 +952,112 @@ mod tests { } } + mod digest { + use super::*; + use crate::glance_digest::tests::{news_digest_result, now, root_with}; + + fn brief(card: &str) -> Value { + json!({"card_id": "brief", "title": "News digest", "source": card, "open": {"app": "news"}}) + } + + #[test] + fn a_digest_card_shows_the_digest_the_host_holds() { + let root = root_with("glance-bound", &[("os.news", "news-digest", news_digest_result())]); + let mut store = GlanceStore::default().with_digest_root(Some(root)); + let mut args = brief(NEWS_BRIEF_CARD); + // The publisher cannot supply the digest: its value is replaced. + args["data"] = json!({"brief": {"summary": "Forged", "points": [], "sources": []}, "$status": {"brief": "ready"}}); + let ok = store.publish(&news(), &args, now()).unwrap(); + let card = &store.shown(now(), SHOWN_CARDS)[0]; + for want in ["Harbor City's council approved an order for 120 electric buses on Tuesday.", "Clearwater Courier", "city infrastructure", "SOURCES"] { + assert!(card.body.contains(want), "{want:?} not in {}", card.body); + } + assert!(!card.body.contains("Forged") && !card.body.contains("No digest yet"), "{}", card.body); + assert_eq!(card.digests, ["glance"]); + // The card expires with the digest (48 h from the run), not 24 h + // from now, whichever is sooner. + let digest_expires = crate::glance_digest::parse_rfc3339_ms("2026-09-20T08:00:00Z").unwrap() + crate::glance_digest::MAX_AGE_MS; + assert_eq!(ok["expires_at"], json!((now() + EXPIRES_DEFAULT_S * 1000).min(digest_expires))); + let mut long = brief(NEWS_BRIEF_CARD); + long["expires"] = json!(EXPIRES_MAX_S); + assert_eq!(store.publish(&news(), &long, now()).unwrap()["expires_at"], json!(digest_expires)); + } + + /// A digest card's notification line is the digest's summary, read + /// from the card's data as kept, never a summary the publisher put + /// in `data`; its own `summary` argument still comes first. + #[test] + fn a_digest_cards_notification_summarizes_the_hosts_digest() { + let root = root_with("glance-note", &[("os.news", "news-digest", news_digest_result())]); + let mut store = GlanceStore::default().with_digest_root(Some(root)); + let mut args = brief(NEWS_BRIEF_CARD); + args["data"] = json!({"brief": {"summary": "Forged"}}); + args["notify"] = json!(true); + store.publish(&news(), &args, now()).unwrap(); + assert_eq!(note_summary(&args), "Forged", "the raw arguments carry the forged summary"); + let summary = store.note_summary_for("os.news/brief", &args); + assert!(summary.starts_with("Sources: Harbor City approves electric bus order"), "{summary}"); + args["summary"] = json!("Your morning brief"); + assert_eq!(store.note_summary_for("os.news/brief", &args), "Your morning brief"); + // A card the store does not hold reads the arguments, as before. + assert_eq!(store.note_summary_for("os.news/other", &json!({"data": {"pkg": {"summary": "Out"}}})), "Out"); + } + + #[test] + fn a_card_binds_only_its_own_apps_digests() { + let root = root_with("glance-own", &[("os.news", "news-digest", news_digest_result())]); + let mut store = GlanceStore::default().with_digest_root(Some(root)); + let other = NEWS_BRIEF_CARD.replace("app: \"os.news\"", "app: \"os.mail\""); + let err = store.publish(&news(), &brief(&other), now()).unwrap_err(); + assert!(err.contains("own app's digests"), "{err}"); + // Maps naming itself gets its own (empty) folder, never News's. + let maps = Caller::granted("os.maps"); + let mut args = brief(&NEWS_BRIEF_CARD.replace("app: \"os.news\"", "app: \"os.maps\"")); + args["open"] = Value::Null; + store.publish(&maps, &args, now()).unwrap(); + let card = &store.shown(now(), SHOWN_CARDS)[0]; + assert!(card.body.contains("No digest yet") && !card.body.contains("Harbor"), "{}", card.body); + // The launcher id names the publisher too. + let short = NEWS_BRIEF_CARD.replace("app: \"os.news\"", "app: \"news\""); + store.publish(&news(), &brief(&short), now()).unwrap(); + assert!(store.shown(now(), SHOWN_CARDS).iter().any(|c| c.app == "os.news" && c.body.contains("Harbor"))); + } + + #[test] + fn a_missing_or_expired_digest_renders_the_cards_own_absence() { + let mut store = GlanceStore::default(); + store.publish(&news(), &brief(NEWS_BRIEF_CARD), now()).unwrap(); + let card = &store.shown(now(), SHOWN_CARDS)[0]; + assert!(card.body.contains("No digest yet"), "{}", card.body); + assert_eq!(card.expires_ms, now() + EXPIRES_DEFAULT_S * 1000); + let root = root_with("glance-expired", &[("os.news", "news-digest", news_digest_result())]); + let mut store = GlanceStore::default().with_digest_root(Some(root)); + let late = now() + crate::glance_digest::MAX_AGE_MS; + store.publish(&news(), &brief(NEWS_BRIEF_CARD), late).unwrap(); + assert!(store.shown(late, SHOWN_CARDS)[0].body.contains("No digest yet")); + } + + #[test] + fn publishing_a_digest_card_prunes_old_runs_but_not_bound_ones() { + let mut runs = vec![("os.news", "news-digest", news_digest_result())]; + for i in 0..crate::glance_digest::KEEP_PER_TEMPLATE + 2 { + let mut r = news_digest_result(); + r["run_id"] = json!(format!("r{i:02}")); + runs.push(("os.news", "news-digest", r)); + } + let root = root_with("glance-retain", &runs); + let dir = root.join("os.news/toolbox/runs/news-digest"); + // `glance` is the oldest file, yet a live card binds it. + let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); + std::fs::File::options().write(true).open(dir.join("glance.json")).unwrap().set_modified(old).unwrap(); + let mut store = GlanceStore::default().with_digest_root(Some(root)); + store.publish(&news(), &brief(NEWS_BRIEF_CARD), now()).unwrap(); + let left = std::fs::read_dir(&dir).unwrap().count(); + assert_eq!(left, crate::glance_digest::KEEP_PER_TEMPLATE + 1); + assert!(dir.join("glance.json").exists()); + } + } + #[test] fn a_contained_app_needs_the_glance_capability_whoever_it_is() { let mut store = GlanceStore::default(); diff --git a/crates/shell/src/glance_digest.rs b/crates/shell/src/glance_digest.rs new file mode 100644 index 00000000..dc3aaf61 --- /dev/null +++ b/crates/shell/src/glance_digest.rs @@ -0,0 +1,531 @@ +//! `sys.digest(app:, id:)` for glance cards (ADR 0002 §7.1, M4): a card binds +//! to a digest the host holds for its own app, with provenance, instead of +//! carrying the findings as text. +//! +//! **Where digests live.** A digest is a system-toolbox run result +//! (`crates/toolbox`, `RunOptions::write_result`) at +//! `/toolbox/runs/