From fbcc9fbb0a3b3d00a249138ac9a6963779827ee9 Mon Sep 17 00:00:00 2001 From: hishboy Date: Fri, 4 Sep 2026 14:37:28 +0000 Subject: [PATCH] feat(sidebar): show inbox counts on department cards Signed-off-by: hishboy --- CHANGELOG.md | 3 + DECISIONS.md | 1 + .../crates/chief-cli/src/actuate/client.rs | 9 +- .../chief-cli/src/actuate/launch_catalog.rs | 50 ++++- .../crates/chief-cli/src/actuate/resident.rs | 81 +++++++ .../crates/chief-cli/src/sidebar/brain.rs | 39 +++- .../chief-cli/src/sidebar/brain/tests.rs | 110 ++++++++-- .../chief-cli/src/sidebar/department_card.rs | 64 ++++-- .../src/sidebar/department_card/tests.rs | 142 ++++++++++-- .../crates/chiefd-api/src/docstore/desired.rs | 206 +++++++++++++++++- .../chiefd-core/src/runtime/launch_catalog.rs | 14 ++ .../crates/chiefd-core/src/store/mailbox.rs | 25 +++ .../chiefd-core/src/store/mailbox/tests.rs | 17 ++ .../chiefd-core/src/store/mailbox_view.rs | 6 +- .../chiefd-host/src/converge_apply/cycle.rs | 1 + .../src/converge_apply/cycle/tests.rs | 41 ++++ 16 files changed, 722 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8563489..bb0a686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +- **feat(sidebar): department cards show each person's inbox count.** A new right-aligned `inbox` column reports zero and multi-digit counts without moving the model column between rows. The count follows the product's durable inbox view: `pending` and fence-archived `delivered` messages are visible, while the four pane-drain states are not. + **The card stays current without a second reader.** The launch-catalog route already reads the whole company mailbox once per converge pass, so it now publishes one exact count for every roster person, including a person whose launch gate is refused. The actuator subscribes to the existing `mailbox/` changefeed prefix, then hands the count one way through the session brain into the card snapshot; the card never reads chiefd or a pane itself. Launch demand stays unchanged and continues to use only the existing `pendingMail` Boolean. + - **fix(sidebar): one failed focus-window census can no longer freeze every company action.** The permanent focus window was created after a client-side read: two sidebar owners could both observe absence, and a tmux error was also indistinguishable from absence. Both paths could mint `__focus__` twice. The actuator then correctly failed closed on the ambiguous topology before step zero, which left new people at `starting` and kept settled people on the glass. **Creation is now decided inside tmux's serialized command queue.** The create, ownership tags, and parked-notice tag are one guarded batch. A stale or empty client read therefore resolves to the winner instead of creating a second window. Sessions already in the broken state self-repair only by removing an inactive window proved, again at the mutation boundary, to contain exactly Chief's rail and parked notice; an active, person-owned, unknown, or changed window is never deleted. Live-tmux tests pin simultaneous creation, a failed census, the observed two-inactive-window incident, active-window choice, unknown state, and both sides of the delete race. diff --git a/DECISIONS.md b/DECISIONS.md index 3a78ce9..cf4a279 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -113,3 +113,4 @@ - 2026-08-25 — CHANGELOG.md and DECISIONS.md start FRESH in the public tree, and the private ledger is archived rather than published. Those two files had grown to about 16,800 lines of internal narrative — detailed incident write-ups, named hosts, and roughly thirty-six leaked machine names — written for the people who were in the room. The rejected alternative was a mass rewording of shipped entries through the `doc-append-only` guard's per-entry exception registry, which would have taught every future contributor that an append-only file may be reworded when the reason seems good enough. That is a repeal of the rule, not an exception to it. The same philosophy as the `plans/` ruling, applied to the same kind of content: the history is kept, and it is kept private. What still BINDS the code is carried forward as new dated entries below; `CLAUDE.md` and `AGENTS.md` already carry the organization model, the operator wake lease and the placement rule, and duplicating them here would create two sources for one rule. The guard treats this as a ONE-TIME reset, detected by the first line of this entry and of the changelog's, so ordinary append-only resumes from the very next commit with no exception left behind to rot. - 2026-09-03 — The sidebar rail is furniture and never takes the operator's cursor. It is minted active on purpose — the tag and the resize that follow the split name a WINDOW, which tmux resolves to the active pane — so every mint frame ends with `select-pane -l`, restoring the pane that was active before the split. The rejected alternative was `split-window -d`, which cannot work without a second tmux invocation to name the pane the split has not reported yet, and a rail that is observable untagged is the gap that once put two rails in one window. Pinned by `the_rail_frame_hands_the_cursor_back_after_every_other_write` and `a_real_rail_mint_leaves_the_person_pane_active` (`attach.rs`) and by `a_repaired_rail_gives_the_cursor_back_after_it_is_tagged` and `real_rail_repair_leaves_the_person_pane_active_and_not_the_rail` (`actuate/interpret/tests.rs`). - 2026-09-04 — A session has one permanent focus window, and its creation decision belongs to tmux's serialized command queue; an unreadable client census is never authority to create a duplicate. Recovery may delete only an inactive focus window proved at the mutation boundary to contain Chief's exact parked rail and notice, while active, person-owned, unknown, or changed content remains fail-closed. Pinned by `concurrent_real_tmux_focus_ensures_mint_one_window`, `an_empty_client_census_cannot_duplicate_an_existing_real_focus_window`, `a_real_inactive_duplicate_made_only_of_focus_furniture_is_repaired`, `a_real_duplicate_with_unknown_local_state_is_left_for_fail_closed_planning`, `a_duplicate_that_changes_after_snapshot_is_not_deleted`, and `a_keeper_that_changes_after_snapshot_preserves_the_other_focus_window` (`sidebar/tests.rs`). +- 2026-09-04 — A department card's `inbox` count is the same durable view the person's footer uses: `pending` plus fence-archived `delivered`, with the four pane-drain states excluded. It is a roster fact published from chiefd's existing whole-mailbox read and projected one way through the client; it never comes from a pane, and it does not change the separate pending-only launch-demand rule. Pinned by `the_launch_catalog_route_keeps_inbox_visibility_separate_from_launch_demand` (`docstore/desired.rs`), `inbox_counts_name_every_roster_person_including_a_refusal` (`actuate/launch_catalog.rs`), and `a_rendered_inbox_count_is_whole_or_hidden_at_its_exact_width_boundary` (`sidebar/department_card/tests.rs`). diff --git a/apps/chiefd/crates/chief-cli/src/actuate/client.rs b/apps/chiefd/crates/chief-cli/src/actuate/client.rs index 5f15fbb..42f9573 100644 --- a/apps/chiefd/crates/chief-cli/src/actuate/client.rs +++ b/apps/chiefd/crates/chief-cli/src/actuate/client.rs @@ -145,7 +145,10 @@ pub const LAUNCH_CATALOG_BUDGET: Duration = Duration::from_secs(20); /// runtime identity a restart is fenced on. /// * `converge-safety` — shadow/apply, the breaker, the budgets. /// * `org-manifest` — the structural authority: people, departments, the tree. -pub const WAKE_STORES: [&str; 4] = ["activity", "supervision", "converge-safety", "org-manifest"]; +/// * `mailbox/` — every per-person inbox; a drain changes the card count even +/// when no runtime authority changes with it. +pub const WAKE_STORES: [&str; 5] = + ["activity", "supervision", "converge-safety", "org-manifest", "mailbox/"]; /// How long a wake waits for the rest of its burst before it is answered. /// @@ -1072,7 +1075,7 @@ mod tests { !WAKE_STORES.contains(&"runtime-actuation"), "the actuation store is deleted; nothing may subscribe to it" ); - for expected in ["activity", "supervision", "converge-safety", "org-manifest"] { + for expected in ["activity", "supervision", "converge-safety", "org-manifest", "mailbox/"] { assert!(WAKE_STORES.contains(&expected), "{expected} is work somebody else commits"); } } @@ -1251,7 +1254,7 @@ mod tests { assert_eq!(client.document_key(), "acme@abc123"); assert_eq!( client.watch_url(Some(3)), - "http://127.0.0.1:8791/v1/docs/watch?slug=acme@abc123&stores=activity,supervision,converge-safety,org-manifest&after=3", + "http://127.0.0.1:8791/v1/docs/watch?slug=acme@abc123&stores=activity,supervision,converge-safety,org-manifest,mailbox/&after=3", "a trailing slash on the base must never double the separator" ); // A3: this actuator is its OWN principal. It never borrows the diff --git a/apps/chiefd/crates/chief-cli/src/actuate/launch_catalog.rs b/apps/chiefd/crates/chief-cli/src/actuate/launch_catalog.rs index 7928101..59dcd7f 100644 --- a/apps/chiefd/crates/chief-cli/src/actuate/launch_catalog.rs +++ b/apps/chiefd/crates/chief-cli/src/actuate/launch_catalog.rs @@ -209,6 +209,8 @@ pub struct LaunchCatalog { pub roster: Vec, /// Current model facts for every validated roster person. pub models: BTreeMap, + /// Messages in the durable inbox view, for every roster person. + pub inbox_counts: BTreeMap, /// The people the on-disk gate ADMITTED. pub people: BTreeMap, /// Why each person in `roster` but not in `people` was declined. @@ -232,10 +234,22 @@ impl LaunchCatalog { catalog.schema_version ))); } + let roster: BTreeSet<&str> = catalog.roster.iter().map(String::as_str).collect(); + if roster.len() != catalog.roster.len() { + return Err(::custom( + "launch catalog roster contains duplicate person ids", + )); + } + let counted: BTreeSet<&str> = catalog.inbox_counts.keys().map(String::as_str).collect(); + if counted != roster { + return Err(::custom(format!( + "launch catalog inboxCounts must name every roster person exactly; roster={roster:?}, counts={counted:?}" + ))); + } Ok(catalog) } - /// Turn the wire body into the three values the interpreter wants. + /// Turn the wire body into the facts the interpreter wants. /// /// Done ONCE per pass rather than per step: `LaunchSpec` owns its strings, /// and rebuilding the whole map for every start in a plan would re-clone @@ -275,6 +289,7 @@ impl LaunchCatalog { specs, roster: self.roster.iter().cloned().collect(), models: self.models.clone(), + inbox_counts: self.inbox_counts.clone(), refusals, } } @@ -295,6 +310,8 @@ pub struct ResolvedCatalog { pub roster: BTreeSet, /// Backend-owned current model facts by person id. pub models: BTreeMap, + /// Durable inbox-message counts by person id, including refused people. + pub inbox_counts: BTreeMap, /// chiefd's own re-derived reason for each declined person. pub refusals: BTreeMap, } @@ -306,7 +323,7 @@ mod tests { #[test] fn the_retired_launch_catalog_schema_is_refused_without_a_compatibility_arm() { let error = LaunchCatalog::from_json( - r#"{"schemaVersion":1,"company":"acme","roster":[],"people":{},"models":{},"refusals":{}}"#, + r#"{"schemaVersion":1,"company":"acme","roster":[],"people":{},"models":{},"inboxCounts":{},"refusals":{}}"#, ) .expect_err("schema 1 is retired"); assert!(error.to_string().contains("expected 2")); @@ -332,6 +349,7 @@ mod tests { "vera": {"state":"selected","provider":"openai","model":"gpt-5.6"}, "nolan": {"state":"unavailable","provider":null,"model":null} }, + "inboxCounts": {"vera":12,"nolan":0}, "people": { "vera": { "piBinary": "/opt/pi/bin/pi", @@ -373,10 +391,38 @@ mod tests { assert_eq!(catalog.models["vera"].state, PersonModelState::Selected); assert_eq!(catalog.models["vera"].provider.as_deref(), Some("openai")); assert_eq!(catalog.models["vera"].model.as_deref(), Some("gpt-5.6")); + assert_eq!(catalog.inbox_counts["vera"], 12); + assert_eq!(catalog.inbox_counts["nolan"], 0); assert_eq!(catalog.people.len(), 1, "only the admitted person carries an entry"); assert_eq!(catalog.refusals["nolan"], "required directory 'workspace' is missing"); } + #[test] + fn inbox_counts_name_every_roster_person_including_a_refusal() { + let mut missing: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON"); + missing["inboxCounts"].as_object_mut().expect("count map").remove("nolan"); + let error = LaunchCatalog::from_json(&serde_json::to_string(&missing).expect("JSON")) + .expect_err("a refused person still needs an inbox count"); + assert!(error.to_string().contains("inboxCounts must name every roster person exactly")); + + let mut unknown: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON"); + unknown["inboxCounts"]["stranger"] = serde_json::json!(1); + let error = LaunchCatalog::from_json(&serde_json::to_string(&unknown).expect("JSON")) + .expect_err("an unknown person cannot enter the card through the count map"); + assert!(error.to_string().contains("inboxCounts must name every roster person exactly")); + } + + #[test] + fn duplicate_roster_ids_are_refused_before_they_collapse_into_a_set() { + let mut duplicate: serde_json::Value = serde_json::from_str(BODY).expect("fixture JSON"); + duplicate["roster"] = serde_json::json!(["vera", "nolan", "vera"]); + + let error = LaunchCatalog::from_json(&serde_json::to_string(&duplicate).expect("JSON")) + .expect_err("one person cannot occupy two roster positions"); + + assert!(error.to_string().contains("roster contains duplicate person ids")); + } + /// Every field, because a field this client silently dropped would be a /// launch input chiefd authorized and the pane never received — and the /// symptom would be a Pi that starts with the wrong tools, or a rail whose diff --git a/apps/chiefd/crates/chief-cli/src/actuate/resident.rs b/apps/chiefd/crates/chief-cli/src/actuate/resident.rs index 0454c16..9503f2c 100644 --- a/apps/chiefd/crates/chief-cli/src/actuate/resident.rs +++ b/apps/chiefd/crates/chief-cli/src/actuate/resident.rs @@ -898,6 +898,29 @@ fn spawn_person(step: &plan::Step) -> Option { } } +/// Missing and unknown inbox-count owners at the display handoff. +/// +/// The roster and launch catalog are separate HTTP reads. The launch catalog +/// validates its count map against its OWN roster, but the brain draws the +/// roster read by this pass. Keep that boundary explicit: a mismatch is not an +/// empty inbox and is not launch authority. +fn inbox_count_roster_mismatch<'a>( + roster_people: impl IntoIterator, + inbox_counts: &BTreeMap, +) -> Option<(Vec, Vec)> { + let roster: BTreeSet<&str> = roster_people.into_iter().collect(); + let counted: BTreeSet<&str> = inbox_counts.keys().map(String::as_str).collect(); + let missing: Vec = + roster.difference(&counted).map(|person| (*person).to_owned()).collect(); + let unknown: Vec = + counted.difference(&roster).map(|person| (*person).to_owned()).collect(); + if missing.is_empty() && unknown.is_empty() { + None + } else { + Some((missing, unknown)) + } +} + impl TmuxActuator { /// Hand the session brain the company this pass just read. /// @@ -925,6 +948,20 @@ impl TmuxActuator { launch: &ResolvedCatalog, crashing: BTreeMap, ) { + if let Some((missing, unknown)) = inbox_count_roster_mismatch( + roster.people.iter().map(|person| person.id.as_str()), + &launch.inbox_counts, + ) { + self.brain.unreadable(); + tracing::warn!( + event = "sidebar.company.inbox-counts-inconsistent", + company = %self.company, + ?missing, + ?unknown, + "the launch catalog's inbox counts do not exactly cover this pass's roster; the display was not updated" + ); + return; + } let Ok(board) = self.client.lifecycle_status().await else { tracing::debug!( event = "sidebar.company.lifecycle-unreadable", @@ -947,6 +984,7 @@ impl TmuxActuator { hashes: desired.hashes(), accents, models: launch.models.clone(), + inbox_counts: launch.inbox_counts.clone(), // THE ACTUATOR'S OWN CRASH REPORT, HANDED TO THE GLASS. This // process is the only one that knows a person's boot keeps dying, // how many times, since when, and what tmux said about it. Until it @@ -1389,6 +1427,49 @@ mod tests { use super::*; use crate::actuate::desired::DesiredPerson; + fn inbox_counts(people: &[&str]) -> BTreeMap { + people.iter().enumerate().map(|(count, person)| ((*person).to_owned(), count)).collect() + } + + #[test] + fn exact_inbox_count_keys_cover_the_display_roster() { + let counts = inbox_counts(&["chief", "vera"]); + assert_eq!(inbox_count_roster_mismatch(["chief", "vera"], &counts), None); + } + + #[test] + fn a_missing_inbox_count_is_not_an_empty_inbox() { + let counts = inbox_counts(&["chief"]); + assert_eq!( + inbox_count_roster_mismatch(["chief", "vera"], &counts), + Some((vec!["vera".to_owned()], Vec::new())) + ); + } + + #[test] + fn an_unknown_inbox_count_cannot_enter_the_display() { + let counts = inbox_counts(&["chief", "stranger"]); + assert_eq!( + inbox_count_roster_mismatch(["chief"], &counts), + Some((Vec::new(), vec!["stranger".to_owned()])) + ); + } + + #[test] + fn inbox_count_validation_is_display_only_and_placement_still_runs() { + let source = include_str!("resident.rs"); + let handoff = source + .find("self.feed_brain(&roster, desired, &launch, crashing.clone()).await;") + .expect("the display handoff"); + let placement = source[handoff..] + .find("crate::placement::desired_topology(&roster, &hashes, &self.session)") + .expect("placement follows the display handoff"); + assert!( + placement > 0, + "the handoff returns unit to converge; an unreadable display does not block placement" + ); + } + fn observed_person(person: &str, organization: &str, hash: &str) -> ObservedTopology { ObservedTopology { session_exists: true, diff --git a/apps/chiefd/crates/chief-cli/src/sidebar/brain.rs b/apps/chiefd/crates/chief-cli/src/sidebar/brain.rs index 90f0563..7751a31 100644 --- a/apps/chiefd/crates/chief-cli/src/sidebar/brain.rs +++ b/apps/chiefd/crates/chief-cli/src/sidebar/brain.rs @@ -174,6 +174,9 @@ pub struct Facts { pub accents: BTreeMap, /// Backend-owned current model facts by person id. pub models: BTreeMap, + /// Durable inbox-message counts by person id. Every roster person has one, + /// including a person whose launch gate is refused. + pub inbox_counts: BTreeMap, /// Whose boot the ACTUATOR keeps retrying because it keeps dying, and the /// numbers the operator reads about it. /// @@ -642,6 +645,7 @@ pub struct Brain { accents: BTreeMap, /// Backend-owned model facts last read with the launch catalog. models: BTreeMap, + inbox_counts: BTreeMap, /// The operator's LATEST gesture, for converge's own line. gesture: Option, /// Every attached thin client. @@ -704,6 +708,7 @@ impl Brain { expanded_columns, accents: BTreeMap::new(), models: BTreeMap::new(), + inbox_counts: BTreeMap::new(), gesture: None, clients: BTreeMap::new(), decoders: BTreeMap::new(), @@ -758,7 +763,17 @@ impl Brain { fn absorb(&mut self, facts: Facts) { let first_company_read = !self.view.is_read(); - let Facts { roster, desired, idle, hashes, accents, models, crashing, refusals } = facts; + let Facts { + roster, + desired, + idle, + hashes, + accents, + models, + inbox_counts, + crashing, + refusals, + } = facts; let live = effects::live_person_ids(self.tmux.as_ref(), &self.session); let unseen_expired = self.watch_unseen_waking(&roster.company.slug, &desired, &live); if let Some(parked) = effects::park_orphan_waking_focus( @@ -788,6 +803,7 @@ impl Brain { let (names, roles) = roster_presentations(&roster); self.accents = accents; self.models = models; + self.inbox_counts = inbox_counts; // EVERY RAIL IN THE SESSION, not just one. There is one brain and N // rails now, so the process that knows the company is the process that // has to title all of their borders — and the accents are kept so a @@ -1656,14 +1672,11 @@ impl Brain { .filter(|candidate| candidate.depth == row.depth + 1) .map(|candidate| candidate.name.clone()) .collect(); - let members = self - .view - .everybody() - .get(department_id) - .map(|people| { - people - .iter() - .map(|person| super::department_card::Member { + let members = match self.view.everybody().get(department_id) { + Some(people) => people + .iter() + .map(|person| { + Some(super::department_card::Member { name: person.name.clone(), role: person.title.clone(), state: person.state(), @@ -1672,11 +1685,13 @@ impl Brain { .get(&person.id) .map(crate::actuate::launch_catalog::PersonModel::label) .unwrap_or_default(), + inbox_messages: self.inbox_counts.get(&person.id).copied()?, head: person.manager, }) - .collect::>() - }) - .unwrap_or_default(); + }) + .collect::>>()?, + None => Vec::new(), + }; let card = super::department_card::Card { name: row.name.clone(), path, members, children }; let payload = serde_json::to_string(&card).ok()?; Some(vec![program, "department-card".to_owned(), payload]) diff --git a/apps/chiefd/crates/chief-cli/src/sidebar/brain/tests.rs b/apps/chiefd/crates/chief-cli/src/sidebar/brain/tests.rs index 1e8b723..bcac00b 100644 --- a/apps/chiefd/crates/chief-cli/src/sidebar/brain/tests.rs +++ b/apps/chiefd/crates/chief-cli/src/sidebar/brain/tests.rs @@ -166,6 +166,10 @@ fn placement_of_a_two_person_quant() -> (crate::roster::Roster, BTreeMap BTreeMap { + roster.people.iter().map(|person| (person.id.clone(), 0)).collect() +} + /// A retained two-window company used to pin the brain's first selection. fn retained_company_facts() -> Facts { use crate::roster::{Roster, RosterCompany, RosterDepartment, RosterPerson}; @@ -179,35 +183,38 @@ fn retained_company_facts() -> Facts { desired_active: true, employment_state: "active".to_owned(), }; + let roster = Roster { + company: RosterCompany { slug: "acme".to_owned(), display_name: "Acme".to_owned() }, + root_department_id: "executive".to_owned(), + departments: vec![ + RosterDepartment { + id: "executive".to_owned(), + name: "Executive".to_owned(), + parent_department_id: None, + head_person_id: "chief".to_owned(), + order: 0, + state: "active".to_owned(), + }, + RosterDepartment { + id: "quant".to_owned(), + name: "Quant".to_owned(), + parent_department_id: Some("executive".to_owned()), + head_person_id: "analyst".to_owned(), + order: 1, + state: "active".to_owned(), + }, + ], + people: vec![person(0, "chief", "executive"), person(1, "analyst", "quant")], + }; + let inbox_counts = empty_inbox_counts(&roster); Facts { - roster: Roster { - company: RosterCompany { slug: "acme".to_owned(), display_name: "Acme".to_owned() }, - root_department_id: "executive".to_owned(), - departments: vec![ - RosterDepartment { - id: "executive".to_owned(), - name: "Executive".to_owned(), - parent_department_id: None, - head_person_id: "chief".to_owned(), - order: 0, - state: "active".to_owned(), - }, - RosterDepartment { - id: "quant".to_owned(), - name: "Quant".to_owned(), - parent_department_id: Some("executive".to_owned()), - head_person_id: "analyst".to_owned(), - order: 1, - state: "active".to_owned(), - }, - ], - people: vec![person(0, "chief", "executive"), person(1, "analyst", "quant")], - }, + roster, desired: ["chief".to_owned(), "analyst".to_owned()].into_iter().collect(), idle: BTreeSet::new(), hashes: BTreeMap::new(), accents: BTreeMap::new(), models: BTreeMap::new(), + inbox_counts, crashing: BTreeMap::new(), refusals: BTreeMap::new(), } @@ -473,6 +480,10 @@ fn brain_against( let (mut brain, events) = Brain::new(tmux, client, "org-acme_".to_owned(), PathBuf::from("/company")); brain.view = view_of_one_sleeper(); + brain.inbox_counts = + [("chief".to_owned(), 0), ("quant-head".to_owned(), 0), ("analyst".to_owned(), 0)] + .into_iter() + .collect(); if placed { brain.placement = Some(placement_of_a_two_person_quant()); } @@ -483,6 +494,7 @@ fn brain_against( fn sleeper_facts(desired: &[&str]) -> Facts { let (roster, hashes) = placement_of_a_two_person_quant(); + let inbox_counts = empty_inbox_counts(&roster); Facts { roster, desired: desired.iter().map(|person| (*person).to_owned()).collect(), @@ -490,6 +502,7 @@ fn sleeper_facts(desired: &[&str]) -> Facts { hashes, accents: BTreeMap::new(), models: BTreeMap::new(), + inbox_counts, crashing: BTreeMap::new(), refusals: BTreeMap::new(), } @@ -764,6 +777,7 @@ async fn a_department_row_shows_its_department_and_never_hijacks_to_its_manager( }], people, ); + brain.inbox_counts = [("analyst".to_owned(), 0)].into_iter().collect(); brain.perform( Action::SelectDepartment("quant".to_owned()), @@ -1610,6 +1624,10 @@ async fn a_department_card_carries_the_units_own_people_and_never_another_units( ], people, ); + brain.inbox_counts = + [("chief".to_owned(), 0), ("quant-head".to_owned(), 0), ("analyst".to_owned(), 12)] + .into_iter() + .collect(); let launch = brain.department_card_launch("quant").expect("the card builds from the roster"); let payload = launch.last().expect("the payload is the last argument"); @@ -1625,6 +1643,11 @@ async fn a_department_card_carries_the_units_own_people_and_never_another_units( ); let names: Vec<&str> = card.members.iter().map(|member| member.name.as_str()).collect(); assert_eq!(names, ["Quinn", "Ana"], "this unit's people, in roster order"); + assert_eq!( + card.members.iter().map(|member| member.inbox_messages).collect::>(), + [0, 12], + "the durable inbox count follows each person into the serialized card" + ); assert!( !names.contains(&"Chief"), "and NEVER another unit's — a card that borrowed the root's people is the \ @@ -1852,6 +1875,42 @@ fn a_company_read_that_changes_a_state_repaints_the_card_in_place() { ); } +/// A mailbox row can move without a roster or runtime fact moving with it. +/// That one durable count must refresh the card once, in place, and the same +/// count on the next pass must be silent. +#[test] +fn an_inbox_count_change_repaints_the_card_once() { + let (mut brain, tmux) = brain_watching_the_quant_card(); + let settled = tmux.calls().len(); + let mut facts = retained_company_facts(); + facts.inbox_counts.insert("analyst".to_owned(), 12); + + brain.absorb(facts.clone()); + + let calls = tmux.calls(); + let changed: Vec<&String> = + calls[settled..].iter().filter(|call| call.contains("respawn-pane")).collect(); + assert_eq!(changed.len(), 1, "only Quant changed: {changed:?}"); + assert!(changed[0].contains("respawn-pane -k -t %8"), "the same card pane is reused"); + assert!( + changed[0].contains(r#""inbox_messages":12"#), + "the new count reaches the payload: {}", + changed[0] + ); + assert!( + !changed[0].contains("select-layout") && !changed[0].contains("select-window"), + "a count refresh does not move the glass: {}", + changed[0] + ); + + let once = tmux.calls().len(); + brain.absorb(facts); + assert!( + !tmux.calls()[once..].iter().any(|call| call.contains("respawn-pane")), + "the same count does not repaint twice" + ); +} + /// **EVERY STANDING CARD, NEVER "THE SELECTED ONE".** /// /// MEASURED on a live company and it is the sharpest failure this work had: the @@ -2498,13 +2557,16 @@ async fn sleeper_selection_marks_nobody_starting_before_a_button_action() { ); // The company is read again. Selection is still not a wake action. + let roster = placement_of_a_two_person_quant().0; + let inbox_counts = empty_inbox_counts(&roster); brain.absorb(Facts { - roster: placement_of_a_two_person_quant().0, + roster, desired: BTreeSet::new(), idle: BTreeSet::new(), hashes: BTreeMap::new(), accents: BTreeMap::new(), models: BTreeMap::new(), + inbox_counts, crashing: BTreeMap::new(), refusals: BTreeMap::new(), }); diff --git a/apps/chiefd/crates/chief-cli/src/sidebar/department_card.rs b/apps/chiefd/crates/chief-cli/src/sidebar/department_card.rs index cf17c2d..6d3c234 100644 --- a/apps/chiefd/crates/chief-cli/src/sidebar/department_card.rs +++ b/apps/chiefd/crates/chief-cli/src/sidebar/department_card.rs @@ -129,6 +129,8 @@ pub struct Member { /// provider resolution of its own; that is `launch_catalog`'s job and one /// implementation of it is enough. pub model: String, + /// Messages still in this person's durable inbox view. + pub inbox_messages: usize, /// Whether this person heads the department. pub head: bool, } @@ -408,14 +410,15 @@ fn draw_rollup(frame: &mut Frame<'_>, area: Rect, card: &Card, palette: &Palette frame.render_widget(Paragraph::new(lines), area); } -/// One cell between each of the four columns. -const COLUMN_SPACING: u16 = 3; +/// One cell between each of the five columns. +const COLUMN_SPACING: u16 = 4; -/// Where each column sits in the `[name, role, state, model]` array. +/// Where each column sits in the `[name, role, state, inbox, model]` array. const NAME: usize = 0; const ROLE: usize = 1; const STATE: usize = 2; -const MODEL: usize = 3; +const INBOX: usize = 3; +const MODEL: usize = 4; /// The glyph and its trailing space, in front of every name. const GLYPH_WIDTH: u16 = 2; @@ -430,15 +433,13 @@ const HEAD_MARKER_WIDTH: u16 = 8; /// The model is last because it is the column an operator asked for by name, /// and the role is first because it is the one they can most afford to lose — /// a truncated job title is still recognisable, a truncated model id is not. -const SHRINK_ORDER: [(usize, u16); 4] = [(ROLE, 0), (NAME, 6), (STATE, 4), (MODEL, MODEL_FLOOR)]; - /// The narrowest the model column may be made while anything else still has /// cells to give. Below this a model id is unrecognisable, which is the same as /// not drawing it. const MODEL_FLOOR: u16 = 12; /// What each column needs to draw every one of `members` in full. -fn wants(members: &[Member]) -> [u16; 4] { +fn wants(members: &[Member]) -> [u16; 5] { let longest = |pick: &dyn Fn(&Member) -> usize| -> u16 { u16::try_from(members.iter().map(pick).max().unwrap_or_default()).unwrap_or(u16::MAX) }; @@ -449,11 +450,12 @@ fn wants(members: &[Member]) -> [u16; 4] { + if member.head { usize::from(HEAD_MARKER_WIDTH) } else { 0 } }), longest(&|member| label(member.state).chars().count()), + longest(&|member| member.inbox_messages.to_string().chars().count()).max(5), longest(&|member| member.model.chars().count()), ] } -/// Column widths for the member table — `[name, role, state, model]` — given +/// Column widths for the member table — `[name, role, state, inbox, model]` — given /// the pane's width and the people it has to draw. /// /// # It allocates from the CONTENT, and filling the pane is not the goal @@ -475,14 +477,24 @@ fn wants(members: &[Member]) -> [u16; 4] { /// correct picture of a short answer, not wasted space. /// /// Public so the tests can pin the two invariants that outlive any of this -/// arithmetic: the four columns plus their spacing never overflow the pane at +/// arithmetic: the five columns plus their spacing never overflow the pane at /// ANY width, and the model column is the last one to give up a cell. #[must_use] -pub fn columns(width: u16, members: &[Member]) -> [u16; 4] { +pub fn columns(width: u16, members: &[Member]) -> [u16; 5] { let usable = width.saturating_sub(COLUMN_SPACING); let mut given = wants(members); let mut over = given.iter().copied().sum::().saturating_sub(usable); - for (column, floor) in SHRINK_ORDER { + // The inbox header may give way, but the decimal answer never does. This + // keeps a large count exact before the model gives up a cell. + let inbox_floor = members + .iter() + .map(|member| member.inbox_messages.to_string().chars().count()) + .max() + .and_then(|width| u16::try_from(width).ok()) + .unwrap_or_default(); + for (column, floor) in + [(ROLE, 0), (NAME, 6), (STATE, 4), (INBOX, inbox_floor), (MODEL, MODEL_FLOOR)] + { if over == 0 { break; } @@ -496,9 +508,16 @@ pub fn columns(width: u16, members: &[Member]) -> [u16; 4] { // leaves the columns past the end at zero; when the floors DO fit it // changes nothing. let mut left = usable; - let mut fitted = [0_u16; 4]; - for column in [NAME, MODEL, STATE, ROLE] { - fitted[column] = given[column].min(left); + let mut fitted = [0_u16; 5]; + for column in [NAME, INBOX, MODEL, STATE, ROLE] { + fitted[column] = if column == INBOX && given[column] > left { + // A partial decimal is a different number. Hide this column when + // the pane cannot hold every digit; the next wider frame restores + // it whole. + 0 + } else { + given[column].min(left) + }; left -= fitted[column]; } fitted @@ -506,7 +525,7 @@ pub fn columns(width: u16, members: &[Member]) -> [u16; 4] { /// The member table. fn draw_members(frame: &mut Frame<'_>, area: Rect, card: &Card, palette: &Palette) { - let [name_w, role_w, state_w, model_w] = columns(area.width, &card.members); + let [name_w, role_w, state_w, inbox_w, model_w] = columns(area.width, &card.members); let rows: Vec> = card .members .iter() @@ -535,6 +554,10 @@ fn draw_members(frame: &mut Frame<'_>, area: Rect, card: &Card, palette: &Palett Span::styled(name, Style::default().fg(colour)), Span::styled(role, Style::default().fg(palette.dim)), Span::styled(fit(label(state), usize::from(state_w)), Style::default().fg(colour)), + Span::styled( + format!("{:>width$}", member.inbox_messages, width = usize::from(inbox_w)), + Style::default().fg(palette.dim), + ), Span::styled( fit(&member.model, usize::from(model_w)), Style::default().fg(palette.dim), @@ -548,9 +571,20 @@ fn draw_members(frame: &mut Frame<'_>, area: Rect, card: &Card, palette: &Palett Constraint::Length(name_w), Constraint::Length(role_w), Constraint::Length(state_w), + Constraint::Length(inbox_w), Constraint::Length(model_w), ], ) + .header(Row::new(vec![ + Span::raw(""), + Span::raw(""), + Span::raw(""), + Span::styled( + format!("{:>width$}", "inbox", width = usize::from(inbox_w)), + Style::default().fg(palette.dim), + ), + Span::raw(""), + ])) .column_spacing(1); frame.render_widget(table, area); } diff --git a/apps/chiefd/crates/chief-cli/src/sidebar/department_card/tests.rs b/apps/chiefd/crates/chief-cli/src/sidebar/department_card/tests.rs index 1530829..98d796e 100644 --- a/apps/chiefd/crates/chief-cli/src/sidebar/department_card/tests.rs +++ b/apps/chiefd/crates/chief-cli/src/sidebar/department_card/tests.rs @@ -7,22 +7,32 @@ //! fails, because nobody goes looking. use super::*; +use ratatui::backend::TestBackend; fn member(name: &str, role: &str, state: PersonState, model: &str, head: bool) -> Member { - Member { name: name.to_owned(), role: role.to_owned(), state, model: model.to_owned(), head } + Member { + name: name.to_owned(), + role: role.to_owned(), + state, + model: model.to_owned(), + inbox_messages: 0, + head, + } } fn engineering() -> Card { + let mut members = vec![ + member("Ada", "Head of Engineering", PersonState::Working, "deepseek-v4-flash", true), + member("Owen", "Planner", PersonState::Working, "deepseek-v4-flash", false), + member("Rhea", "Software Engineer", PersonState::Sleeping, "deepseek-v4-flash", false), + member("Kai", "Software Engineer", PersonState::Working, "glm-5.2", false), + member("Wren", "Code Reviewer", PersonState::Refused, "", false), + ]; + members[1].inbox_messages = 12; Card { name: "Engineering".to_owned(), path: vec!["Taperoom Inc".to_owned()], - members: vec![ - member("Ada", "Head of Engineering", PersonState::Working, "deepseek-v4-flash", true), - member("Owen", "Planner", PersonState::Working, "deepseek-v4-flash", false), - member("Rhea", "Software Engineer", PersonState::Sleeping, "deepseek-v4-flash", false), - member("Kai", "Software Engineer", PersonState::Working, "glm-5.2", false), - member("Wren", "Code Reviewer", PersonState::Refused, "", false), - ], + members, children: vec!["Platform".to_owned()], } } @@ -276,7 +286,7 @@ fn a_wide_pane_draws_a_long_model_whole_rather_than_clipping_it_beside_blank_spa ], children: Vec::new(), }; - let [_, _, _, model_w] = columns(200, &card.members); + let [_, _, _, _, model_w] = columns(200, &card.members); let width = u16::try_from(model.chars().count()).expect("a model id fits a u16"); assert_eq!(model_w, width, "a 200-column pane can hold this model whole"); assert_eq!(fit(model, usize::from(model_w)), model, "so it is drawn whole, with no ellipsis"); @@ -288,10 +298,11 @@ fn a_wide_pane_draws_a_long_model_whole_rather_than_clipping_it_beside_blank_spa #[test] fn a_wide_pane_draws_every_column_whole_including_the_head_marker() { let card = engineering(); - let [name_w, role_w, state_w, model_w] = columns(200, &card.members); + let [name_w, role_w, state_w, inbox_w, model_w] = columns(200, &card.members); assert_eq!(name_w, 6, "the longest name is four characters, plus the glyph and its space"); assert_eq!(role_w, 27, "`Head of Engineering` plus room for ` (head)`"); assert_eq!(state_w, 12, "`cannot start`, whole"); + assert_eq!(inbox_w, 5, "the `inbox` header is whole"); assert_eq!(model_w, 17, "`deepseek-v4-flash`, whole"); let ada = &card.members[0]; assert_eq!( @@ -319,8 +330,8 @@ fn a_wide_pane_does_not_stretch_the_columns_to_fill_it() { #[test] fn a_narrow_pane_keeps_the_model_column_and_shrinks_the_role() { let card = engineering(); - let [_, wide_role, _, wide_model] = columns(129, &card.members); - let [_, narrow_role, _, narrow_model] = columns(60, &card.members); + let [_, wide_role, _, _, wide_model] = columns(129, &card.members); + let [_, narrow_role, _, _, narrow_model] = columns(60, &card.members); assert!(narrow_role < wide_role, "the role column gives way first"); assert!(narrow_model >= MODEL_FLOOR, "the model column never collapses: {narrow_model}"); assert_eq!(narrow_model, wide_model, "and it gives up nothing while the role still can"); @@ -331,15 +342,16 @@ fn a_narrow_pane_keeps_the_model_column_and_shrinks_the_role() { #[test] fn the_model_is_the_last_column_to_give_up_a_cell() { let card = engineering(); - let mut previous = columns(u16::MAX, &card.members)[3]; + let mut previous = columns(u16::MAX, &card.members)[4]; let mut model_shrank_at = None; for width in (10..=140_u16).rev() { - let [name, role, state, model] = columns(width, &card.members); + let [name, role, state, inbox, model] = columns(width, &card.members); if model < previous { model_shrank_at = Some(width); assert_eq!(role, 0, "the role had nothing left to give at width {width}"); assert_eq!(name, 6, "and the name was at its floor: {name}"); assert_eq!(state, 4, "and so was the state: {state}"); + assert_eq!(inbox, 2, "and the two-digit inbox answer stayed whole: {inbox}"); break; } previous = model; @@ -355,10 +367,10 @@ fn the_model_is_the_last_column_to_give_up_a_cell() { fn the_columns_always_fit_inside_the_pane() { let card = engineering(); for width in 0..=512_u16 { - let [name, role, state, model] = columns(width, &card.members); + let [name, role, state, inbox, model] = columns(width, &card.members); assert!( - name + role + state + model + COLUMN_SPACING <= width.max(COLUMN_SPACING), - "columns overflow at width {width}: {name}+{role}+{state}+{model}" + name + role + state + inbox + model + COLUMN_SPACING <= width.max(COLUMN_SPACING), + "columns overflow at width {width}: {name}+{role}+{state}+{inbox}+{model}" ); } } @@ -369,8 +381,8 @@ fn the_columns_always_fit_inside_the_pane() { fn a_one_cell_pane_produces_no_columns_rather_than_panicking() { let card = engineering(); for width in [0_u16, 1, 2, 3, 4] { - let [name, role, state, model] = columns(width, &card.members); - assert!(name + role + state + model <= width, "width {width}"); + let [name, role, state, inbox, model] = columns(width, &card.members); + assert!(name + role + state + inbox + model <= width, "width {width}"); } } @@ -378,8 +390,8 @@ fn a_one_cell_pane_produces_no_columns_rather_than_panicking() { /// table with no rows rather than an arithmetic fault. #[test] fn an_empty_department_allocates_no_columns_and_does_not_panic() { - assert_eq!(columns(200, &[]), [2, 0, 0, 0], "only the glyph a name would carry"); - assert_eq!(columns(0, &[]), [0, 0, 0, 0]); + assert_eq!(columns(200, &[]), [2, 0, 0, 5, 0], "the empty table keeps its inbox header"); + assert_eq!(columns(0, &[]), [0, 0, 0, 0, 0]); } /// A role longer than any pane must not make the arithmetic wrap or the table @@ -392,7 +404,91 @@ fn an_absurdly_long_role_shrinks_rather_than_overflowing() { members: vec![member("A", &"r".repeat(4000), PersonState::Working, "m", false)], children: Vec::new(), }; - let [name, role, state, model] = columns(80, &card.members); - assert!(name + role + state + model + COLUMN_SPACING <= 80); + let [name, role, state, inbox, model] = columns(80, &card.members); + assert!(name + role + state + inbox + model + COLUMN_SPACING <= 80); assert!(role > 0, "and it still draws as much of the role as it can: {role}"); } + +#[test] +fn the_inbox_column_keeps_zero_and_multi_digit_counts_right_aligned() { + let card = engineering(); + let backend = TestBackend::new(100, 24); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| draw(frame, &card, true)).expect("draw"); + let buffer = terminal.backend().buffer(); + let width = usize::from(buffer.area.width); + let rows: Vec = buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol()).collect()) + .collect(); + let header = rows.iter().find(|row| row.contains("inbox")).expect("labelled inbox column"); + let inbox_byte = header.find("inbox").expect("inbox starts"); + let inbox_at = header[..inbox_byte].chars().count(); + let slice = |row: &str| row.chars().skip(inbox_at).take(5).collect::(); + let ada = rows.iter().find(|row| row.contains("Ada")).expect("Ada row"); + let owen = rows.iter().find(|row| row.contains("Owen")).expect("Owen row"); + assert_eq!(slice(header), "inbox"); + assert_eq!(slice(ada), " 0", "an empty inbox is an explicit zero"); + assert_eq!(slice(owen), " 12", "counts share one right edge"); +} + +#[test] +fn the_inbox_column_never_truncates_the_decimal_answer() { + let mut card = engineering(); + card.members[0].inbox_messages = 123_456; + assert_eq!(columns(200, &card.members)[INBOX], 6); + assert_eq!(columns(60, &card.members)[INBOX], 6, "the header gives way before a digit does"); +} + +#[test] +fn a_rendered_inbox_count_is_whole_or_hidden_at_its_exact_width_boundary() { + let count = "987654"; + let card = Card { + name: "Unit".to_owned(), + path: Vec::new(), + members: vec![Member { + name: "Zed".to_owned(), + role: String::new(), + state: PersonState::Sleeping, + model: String::new(), + inbox_messages: 987_654, + head: false, + }], + children: Vec::new(), + }; + let mut first_visible = None; + for terminal_width in 14..=24_u16 { + let backend = TestBackend::new(terminal_width, 16); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| draw(frame, &card, true)).expect("draw"); + let buffer = terminal.backend().buffer(); + let width = usize::from(buffer.area.width); + let member_row: String = buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .find(|row| row.contains("Zed")) + .expect("the member row stays visible around the inbox boundary"); + let member_area_width = terminal_width.saturating_sub(4); + let inbox_width = columns(member_area_width, &card.members)[INBOX]; + assert!( + inbox_width == 0 || inbox_width == 6, + "width {terminal_width} allocated a partial {inbox_width}-cell decimal" + ); + if inbox_width == 6 { + assert!( + member_row.contains(count), + "width {terminal_width} allocated the count but did not draw it whole: {member_row:?}" + ); + first_visible.get_or_insert(terminal_width); + } else { + assert!( + !count.chars().any(|digit| member_row.contains(digit)), + "width {terminal_width} drew a clipped count: {member_row:?}" + ); + } + } + let boundary = first_visible.expect("the sweep reaches a width that can show the count"); + assert_eq!(boundary, 19, "the count appears at the first frame that has all six cells"); +} diff --git a/apps/chiefd/crates/chiefd-api/src/docstore/desired.rs b/apps/chiefd/crates/chiefd-api/src/docstore/desired.rs index cdce1e3..65e9538 100644 --- a/apps/chiefd/crates/chiefd-api/src/docstore/desired.rs +++ b/apps/chiefd/crates/chiefd-api/src/docstore/desired.rs @@ -65,6 +65,7 @@ use chiefd_core::runtime::launch_catalog::LaunchCatalog; use chiefd_core::runtime::launch_hash::{desired_launch_hash, LaunchInputs}; use chiefd_core::runtime::roster::project_desired_roster; use chiefd_core::store::converge_safety::ConvergeSafetyState; +use chiefd_core::store::mailbox::MailboxState; use super::org_slice::{failed, live, Refused, SlugRequest}; use super::router::SupervisionLiveSource; @@ -383,25 +384,216 @@ pub(crate) async fn org_runtime_launch_catalog( // the same fail-open the pending-mail projection already refuses to make // ("an unobservable store must not silently read as 'no demand anywhere'"). let (mailbox, _seq) = source.company.mailbox_read().await.map_err(|error| failed(&error))?; - let people_with_pending_mail: std::collections::BTreeSet = mailbox + let typed_mailbox = mailbox .entries - .into_iter() - .filter(|entry| entry.state == "pending") - .map(|entry| entry.person) - .collect(); - Ok(Json(chiefd_host::converge_apply::build_launch_catalog_for_session_epoch( + .iter() + .map(|entry| { + MailboxState::parse(&entry.state) + .map(|state| (entry.person.as_str(), state)) + .ok_or_else(|| { + Refused::fault( + "mailbox-state-unreadable", + format!( + "mailbox entry '{}' has unknown state '{}'", + entry.envelope_id(), + entry.state + ), + ) + }) + }) + .collect::, _>>()?; + let (people_with_pending_mail, inbox_counts) = + mailbox_facts(&manifest.people_order, typed_mailbox); + let mut catalog = chiefd_host::converge_apply::build_launch_catalog_for_session_epoch( &manifest, config, session_epoch, &identity_refusals, &people_with_pending_mail, - ))) + ); + catalog.inbox_counts = inbox_counts; + Ok(Json(catalog)) +} + +/// Derive the two mailbox facts the launch catalog publishes from one durable +/// snapshot. Launch demand is only a `pending` row. The operator-facing inbox +/// view also includes a fence-archived `delivered` row, which is the same rule +/// the person's own footer uses. The four pane-drain states are excluded. +fn mailbox_facts<'a>( + roster: &[String], + entries: impl IntoIterator, +) -> (std::collections::BTreeSet, std::collections::BTreeMap) { + let mut pending_people = std::collections::BTreeSet::new(); + let mut inbox_counts: std::collections::BTreeMap = + roster.iter().map(|person| (person.clone(), 0)).collect(); + for (person, state) in entries { + let known = inbox_counts.contains_key(person); + if known && state.supplies_launch_demand() { + pending_people.insert(person.to_owned()); + } + if known && state.is_inbox_message() { + if let Some(count) = inbox_counts.get_mut(person) { + *count += 1; + } + } + } + (pending_people, inbox_counts) } #[cfg(test)] mod tests { use super::*; + fn mailbox_entry( + id: &str, + person: &str, + state: MailboxState, + ) -> chiefd_core::store::mailbox_rows::MailboxEntry { + chiefd_core::store::mailbox_rows::MailboxEntry { + envelope: chiefd_core::store::mailbox::MailboxEnvelope { + schema_version: chiefd_core::store::mailbox::MAILBOX_ENVELOPE_SCHEMA_VERSION, + id: id.to_owned(), + organization: "northstar-conformance".to_owned(), + from_person_id: "chief".to_owned(), + to: person.to_owned(), + recipients: vec![person.to_owned()], + body: format!("message {id}"), + urgency: chiefd_core::store::mailbox::Urgency::Normal, + reply_to: None, + health_incident: None, + created_at: "2026-07-15T12:00:00.000Z".to_owned(), + }, + person: person.to_owned(), + state: state.as_str().to_owned(), + updated_at: 1_784_116_800_000, + extra: std::collections::BTreeMap::new(), + } + } + + fn admit_launch_subject(dir: &std::path::Path, person_id: &str) { + let home = chiefd_host::agent_home::agent_home(dir, person_id); + std::fs::create_dir_all(home.join("sessions")).expect("sessions"); + std::fs::create_dir_all(home.join(".pi/skills")).expect("project skills"); + std::os::unix::fs::symlink("../../../../skills", home.join(".pi/skills/worker")) + .expect("role skill link"); + } + + /// The real route joins one real mailbox snapshot to the launch catalog. + /// Delivered mail remains visible but cannot change the launch sentence. + #[tokio::test] + async fn the_launch_catalog_route_keeps_inbox_visibility_separate_from_launch_demand() { + use chiefd_core::actor::{CompanyDb, MutationClass, MutationName}; + use chiefd_core::clock::SystemClock; + use chiefd_core::store::{activity, organization, supervision}; + + let dir = tempfile::tempdir().expect("tempdir"); + let company = std::sync::Arc::new( + CompanyDb::open( + "northstar-conformance", + &dir.path().join("company.db"), + std::sync::Arc::new(SystemClock::default()), + ) + .expect("company"), + ); + let manifest = chiefd_core::test_support::northstar_manifest(1_784_116_800_000); + let seeded = manifest.clone(); + company + .mutate(MutationClass::Normal, MutationName("test.seed"), move |ledgers| { + organization::create(ledgers, &seeded)?; + supervision::seed(ledgers, &seeded)?; + activity::seed(ledgers, &seeded)?; + Ok(()) + }) + .await + .expect("seed company"); + + let chief = "chief"; + let delivered_only = "quant-head"; + admit_launch_subject(dir.path(), delivered_only); + let operator = dir.path().join("pi-agent"); + chiefd_host::files::publish_atomically(&operator.join("auth.json"), "{}", 0o600) + .expect("operator provider credential"); + let mint = chiefd_host::identity_key::host_identity_key_mint(); + for (person, outcome) in chiefd_host::identity_enrolment::provision_people( + &company, + dir.path(), + chief, + [chief.to_owned(), delivered_only.to_owned()], + &mint, + ) + .await + { + assert!(outcome.is_authenticable(), "{person} identity: {outcome:?}"); + } + + company + .mailbox_publish(chiefd_core::store::mailbox_rows::MailboxSnapshot { + entries: vec![ + mailbox_entry("pending", chief, MailboxState::Pending), + mailbox_entry("fence-archive", chief, MailboxState::Delivered), + mailbox_entry("delivered-only", delivered_only, MailboxState::Delivered), + mailbox_entry("settled", "signal-researcher", MailboxState::Accepted), + ], + }) + .await + .expect("publish mailbox rows"); + + let source = SupervisionLiveSource::new( + std::sync::Arc::clone(&company), + "northstar-conformance".to_owned(), + ) + .with_reconcile_actuator_config(chiefd_host::converge_apply::ActuatorConfig { + socket: "test-socket".to_owned(), + watching_since: "1970-01-01T00:00:00.000Z".to_owned(), + dir: dir.path().to_path_buf(), + home: dir.path().to_path_buf(), + pi_binary: dir.path().join("pi"), + floor: std::time::Duration::ZERO, + launcher_root: dir.path().to_path_buf(), + root_pi_agent_dir: operator, + }); + let Json(catalog) = org_runtime_launch_catalog( + Extension(Some(source)), + Json(SlugRequest { slug: "northstar-conformance".to_owned() }), + ) + .await + .expect("route answer"); + + assert_eq!(catalog.inbox_counts[chief], 2); + assert_eq!(catalog.inbox_counts[delivered_only], 1); + assert_eq!(catalog.inbox_counts["signal-researcher"], 0); + assert_eq!(catalog.inbox_counts["it-head"], 0); + assert!(catalog.people[chief].pending_mail, "pending mail supplies launch demand"); + assert!( + !catalog.people[delivered_only].pending_mail, + "delivered-only mail stays visible without supplying launch demand" + ); + } + + /// Inbox is the durable VIEW, not only launch demand: a fence-archived + /// delivered row stays visible. Every roster person gets an explicit zero, + /// and a stale row for an unknown person cannot add a card fact for somebody + /// the roster does not contain. + #[test] + fn inbox_counts_include_pending_and_delivered_for_every_roster_person() { + let roster = vec!["vera".to_owned(), "nolan".to_owned(), "rhea".to_owned()]; + let (pending, inbox) = mailbox_facts( + &roster, + [ + ("vera", MailboxState::Pending), + ("vera", MailboxState::Delivered), + ("vera", MailboxState::Accepted), + ("nolan", MailboxState::Delivered), + ("unknown", MailboxState::Pending), + ], + ); + assert_eq!(pending, ["vera".to_owned()].into_iter().collect()); + assert_eq!(inbox["vera"], 2, "pending and delivered are both still in the inbox"); + assert_eq!(inbox["nolan"], 1, "a delivered row remains visible"); + assert_eq!(inbox["rhea"], 0, "an empty inbox is explicit"); + assert!(!inbox.contains_key("unknown"), "only roster people get card facts"); + } + /// A surface with no actuator configuration says so, in a code a client can /// act on — it never answers an empty catalog. An empty catalog would be a /// *successful* answer meaning "nobody in this company may launch", which diff --git a/apps/chiefd/crates/chiefd-core/src/runtime/launch_catalog.rs b/apps/chiefd/crates/chiefd-core/src/runtime/launch_catalog.rs index f90ecff..04ada14 100644 --- a/apps/chiefd/crates/chiefd-core/src/runtime/launch_catalog.rs +++ b/apps/chiefd/crates/chiefd-core/src/runtime/launch_catalog.rs @@ -212,6 +212,15 @@ pub struct LaunchCatalog { pub roster: Vec, /// Current model facts for every validated roster person. pub models: BTreeMap, + /// Messages in the inbox view for every person in [`Self::roster`]. + /// + /// This is a top-level roster fact because a person whose launch gate is + /// refused still has an inbox and must still appear on the department + /// card. `pending` and fence-archived `delivered` rows are in that view; + /// the four pane-drain states are not. The route that owns the company + /// mailbox supplies the exact count; the pure builder initializes every + /// roster person to zero. + pub inbox_counts: BTreeMap, /// The people the on-disk gate ADMITTED, keyed by person id. pub people: BTreeMap, /// Why each person in `roster` but not in `people` was declined, re-derived @@ -237,6 +246,7 @@ impl LaunchCatalog { company: company.into(), roster: Vec::new(), models: BTreeMap::new(), + inbox_counts: BTreeMap::new(), people: BTreeMap::new(), refusals: BTreeMap::new(), } @@ -313,6 +323,8 @@ mod tests { PersonModel::selected("openai".to_owned(), "gpt-5.6".to_owned()), ); catalog.models.insert("nolan".to_owned(), PersonModel::unavailable(None, None)); + catalog.inbox_counts.insert("vera".to_owned(), 12); + catalog.inbox_counts.insert("nolan".to_owned(), 0); catalog.people.insert("vera".to_owned(), entry()); catalog .refusals @@ -356,6 +368,8 @@ mod tests { // decoding `session` strictly must find the key. assert!(vera["session"].is_null()); assert_eq!(vera["pendingMail"], true, "the client decodes this field strictly"); + assert_eq!(body["inboxCounts"]["vera"], 12); + assert_eq!(body["inboxCounts"]["nolan"], 0); assert_eq!(vera["piHome"], "/data/cobalt/.chief/agent/vera"); assert_eq!(vera["env"][0]["name"], "ORG_LAUNCHER_ORGANIZATION"); assert_eq!(vera["env"][0]["value"], "cobalt"); diff --git a/apps/chiefd/crates/chiefd-core/src/store/mailbox.rs b/apps/chiefd/crates/chiefd-core/src/store/mailbox.rs index c2d20f6..e3ebf83 100644 --- a/apps/chiefd/crates/chiefd-core/src/store/mailbox.rs +++ b/apps/chiefd/crates/chiefd-core/src/store/mailbox.rs @@ -144,6 +144,31 @@ impl MailboxState { } } + /// Whether this row still appears in the recipient's durable inbox view. + /// + /// `Delivered` is the fence archive: it no longer supplies launch demand, + /// but it stays in the inbox view. + #[must_use] + pub const fn is_inbox_message(self) -> bool { + match self { + Self::Pending | Self::Delivered => true, + Self::Accepted | Self::Superseded | Self::Rejected | Self::Resolved => false, + } + } + + /// Whether this row supplies pending-mail launch demand. + #[must_use] + pub const fn supplies_launch_demand(self) -> bool { + match self { + Self::Pending => true, + Self::Delivered + | Self::Accepted + | Self::Superseded + | Self::Rejected + | Self::Resolved => false, + } + } + /// Whether this bucket is a terminal (archive) state. #[must_use] pub const fn is_terminal(self) -> bool { diff --git a/apps/chiefd/crates/chiefd-core/src/store/mailbox/tests.rs b/apps/chiefd/crates/chiefd-core/src/store/mailbox/tests.rs index b557c1d..f7ce331 100644 --- a/apps/chiefd/crates/chiefd-core/src/store/mailbox/tests.rs +++ b/apps/chiefd/crates/chiefd-core/src/store/mailbox/tests.rs @@ -57,6 +57,23 @@ impl WakeDecider for InFlight { } } +#[test] +fn inbox_visibility_and_launch_demand_are_different_typed_rules() { + assert!(MailboxState::Pending.is_inbox_message()); + assert!(MailboxState::Pending.supplies_launch_demand()); + assert!(MailboxState::Delivered.is_inbox_message()); + assert!(!MailboxState::Delivered.supplies_launch_demand()); + for state in [ + MailboxState::Accepted, + MailboxState::Superseded, + MailboxState::Rejected, + MailboxState::Resolved, + ] { + assert!(!state.is_inbox_message(), "{state:?} is settled"); + assert!(!state.supplies_launch_demand(), "{state:?} cannot launch a person"); + } +} + // --- enqueue: durable, idempotent, content-fenced --------------------------- #[test] diff --git a/apps/chiefd/crates/chiefd-core/src/store/mailbox_view.rs b/apps/chiefd/crates/chiefd-core/src/store/mailbox_view.rs index 5856e9e..fbc5c87 100644 --- a/apps/chiefd/crates/chiefd-core/src/store/mailbox_view.rs +++ b/apps/chiefd/crates/chiefd-core/src/store/mailbox_view.rs @@ -85,11 +85,15 @@ pub fn view(tx: &Transaction<'_>, slug: &str, person: &str) -> Result out.pending.push(entry), + Some(state) if state.is_inbox_message() => out.pending.push(entry), Some(MailboxState::Accepted) => out.accepted.push(entry), Some(MailboxState::Superseded) => out.superseded.push(entry), Some(MailboxState::Rejected) => out.rejected.push(entry), Some(MailboxState::Resolved) => out.resolved.push(entry), + // Exhaustive even though the guard above has already handled both + // states. If its rule changes, these rows stay out of a terminal + // bucket rather than being silently misclassified. + Some(MailboxState::Pending | MailboxState::Delivered) => {} // `reconstruct_person` already fails closed (a store error) // on an unparseable state, so this arm is unreachable in practice; // it is kept exhaustive rather than a wildcard so a state that is diff --git a/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle.rs b/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle.rs index 652efe2..9408663 100644 --- a/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle.rs +++ b/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle.rs @@ -375,6 +375,7 @@ fn build_launch_catalog_for_cycle( // them". let Some(person) = org.people.get(person_id) else { continue }; catalog.roster.push(person_id.clone()); + catalog.inbox_counts.insert(person_id.clone(), 0); let entry = launch_entry( org, config, diff --git a/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle/tests.rs b/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle/tests.rs index 4cf185b..63bacf6 100644 --- a/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle/tests.rs +++ b/apps/chiefd/crates/chiefd-host/src/converge_apply/cycle/tests.rs @@ -542,6 +542,47 @@ fn build_launch_catalog_admits_the_materialized_and_refuses_the_rest_in_one_answ assert!(!catalog.people.contains_key(person_id)); assert!(catalog.refusal(person_id).is_some(), "{person_id} must be refused by name"); } + let zero_counts: std::collections::BTreeMap = + manifest.people_order.iter().cloned().map(|person| (person, 0)).collect(); + assert_eq!( + catalog.inbox_counts, zero_counts, + "every roster person gets an exact zero, including people the launch gate refused" + ); +} + +#[test] +fn pending_mail_marks_only_the_admitted_person_named_by_the_builder_input() { + let manifest = northstar_manifest(EPOCH); + let company_dir = tempfile::tempdir().expect("tempdir"); + let with_pending_mail = manifest.people_order.first().expect("first roster person").clone(); + let without_pending_mail = manifest.people_order.get(1).expect("second roster person").clone(); + admit_launch_subject(company_dir.path(), &with_pending_mail); + admit_launch_subject(company_dir.path(), &without_pending_mail); + let mut config = config(); + config.dir = company_dir.path().to_path_buf(); + config.root_pi_agent_dir = operator_pi_agent_dir(company_dir.path()); + let pending = std::collections::BTreeSet::from([with_pending_mail.clone()]); + + let catalog = crate::converge_apply::build_launch_catalog_for_session_epoch( + &manifest, + &config, + None, + &std::collections::BTreeMap::new(), + &pending, + ); + + assert!( + catalog.people.get(&with_pending_mail).expect("pending person is admitted").pending_mail, + "the pending set must create launch demand for its admitted person" + ); + assert!( + !catalog + .people + .get(&without_pending_mail) + .expect("non-pending person is admitted") + .pending_mail, + "an admitted person outside the pending set must not get launch demand" + ); } /// The `reconcile.people.withheld` line must never render a reason list it does