From fbaf9bd33a86b5e8a0aab2d1a808f7591ad684f2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 3 Nov 2026 05:11:48 +0100 Subject: [PATCH] wear-facts: Mind::roots_touched, the newest admission per root with a campaign's repos in force, through the MindPort and the wire Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 1 + crates/huginn-daemon/src/daemon.rs | 30 ++++ crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/query.rs | 160 +++++++++++++++++- crates/huginn-mind/src/wire.rs | 14 +- crates/mind-body/src/queue.rs | 48 +++++- crates/mind-body/src/testkit.rs | 9 + crates/mind-body/tests/public_surface.txt | 2 +- .../huginn.mind_request.v1.schema.json | 21 +++ .../huginn.mind_response.v1.schema.json | 44 +++++ 10 files changed, 323 insertions(+), 8 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index 0ec6647..635d76c 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -509,6 +509,7 @@ fn unexpected(answer: &HuginnMindResponse) -> Trouble { HuginnMindResponse::PersonaPut(_) => "a persona put", HuginnMindResponse::Sources(_) => "source hits", HuginnMindResponse::Activity(_) => "repo activity", + HuginnMindResponse::RootsTouched(_) => "roots touched", HuginnMindResponse::Refused(_) => "a refusal", HuginnMindResponse::Deferred(_) => "a deferral", }; diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 421d7f1..41220bc 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -200,6 +200,10 @@ impl> Daemon { HuginnMindRequest::Query { selection, semantic: Some(semantic), .. } => { return self.begin_search(selection, semantic); } + HuginnMindRequest::RootsTouched { .. } => match self.mind.roots_touched() { + Ok(roots) => HuginnMindResponse::RootsTouched(roots), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, HuginnMindRequest::StewardedActivity { instance, repos, after } => { let refused = |message: &str| MindRefusal::SelectionInvalid { field: "repos".into(), value: None, message: message.into() }; if repos.len() > ACTIVITY_REPOS_MAX { @@ -1073,6 +1077,32 @@ pub(crate) mod tests { } + /// A roots read through the daemon's handle answers from the mind it serves: the campaign with its repos in force and the + /// instance's own root with none; a request naming another instance is refused at the door, as every read is, before the mind + /// is asked. + #[test] + fn roots_touched_answers_on_the_wire() { + let (_root, mut daemon) = seeded_wide(); + let HuginnMindResponse::RootsTouched(roots) = daemon.handle(HuginnMindRequest::RootsTouched { instance: slug(INSTANCE) }, now()).answered() else { + panic!("a roots read answers the roots"); + }; + let by_root = |name: &str| roots.iter().find(|root| root.root.0 == name).unwrap_or_else(|| panic!("{name} in {roots:?}")); + assert_eq!(by_root(CAMPAIGN).repos, [OrgRepo(REPO.into())]); + assert!(by_root(INSTANCE).repos.is_empty()); + assert_eq!(roots.len(), 2, "{roots:?}"); + + let bytes = rmp_serde::to_vec_named(&HuginnMindResponse::RootsTouched(roots.clone())).unwrap(); + assert_eq!(rmp_serde::from_slice::(&bytes).unwrap(), HuginnMindResponse::RootsTouched(roots)); + + for declared in [OTHER, NEAR, PREFIXED, CASED] { + assert_eq!( + daemon.handle(HuginnMindRequest::RootsTouched { instance: slug(declared) }, now()).answered(), + HuginnMindResponse::Refused(MindRefusal::ForeignInstance { declared: declared.into(), mind: INSTANCE.into() }), + "{declared}" + ); + } + } + fn activity_request(repos: &[&str]) -> HuginnMindRequest { HuginnMindRequest::StewardedActivity { instance: slug(INSTANCE), repos: repos.iter().map(|repo| OrgRepo::from(*repo)).collect(), after: Some(1) } } diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index c868b99..526bffd 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -48,7 +48,7 @@ pub use mind::{HuginnMindEpoch, Mind}; pub use persona::{PersonaPutOutcome, PersonaStateView}; pub use query::{ AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems, - Members, PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery, + Members, PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, RootTouched, SUMMARY_MAX_BYTES, SemanticQuery, }; pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; pub use refusal::{MindRefusal, RunId}; diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index c722abf..1897849 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -28,7 +28,7 @@ use cultnet_rs::{ }; use eureka_pipeline::{ ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MemoryOrigin, MemorySort, OrgRepo, - PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, + PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Short, Slug, Title, pipeline_key, }; use schemars::JsonSchema; @@ -39,7 +39,7 @@ use crate::mind::{Mind, schema_cache}; use crate::persona::HuginnPersonaEntry; use crate::receipt::{self, PipelineProvenance}; use crate::refusal::MindRefusal; -use crate::rows::{SelectionRow, Vocabulary, refuse_values}; +use crate::rows::{SelectionRow, Vocabulary, refuse_values, root_and_local}; use crate::store::MindStore; /// What admission recorded about a document when it landed: the receipt that @@ -383,6 +383,23 @@ fn integrity(type_id: &str, key: &str, fault: &str) -> MindRefusal { MindRefusal::Unavailable { detail: format!("document {type_id}/{key} {fault}") } } +/// A root of the mind and the newest admission under it. `repos` is the repo set in force of a root that is an in-force campaign, +/// and empty for any other root. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct RootTouched { + pub root: Slug, + pub admitted_at: String, + #[schemars(range(min = 1))] + pub ordinal: u64, + pub repos: Vec, +} + +impl RootTouched { + fn of(root: &str, facts: &AdmissionFacts, repos: &[OrgRepo]) -> Self { + Self { root: Slug(root.to_string()), admitted_at: facts.admitted_at.clone(), ordinal: facts.ordinal, repos: repos.to_vec() } + } +} + /// One read call's working set: the image as of one snapshot and the facts it /// was built from. Built once per call and thrown away with it; nothing here /// outlives the answer. @@ -455,6 +472,31 @@ impl Reader { current } + fn roots_touched(&self) -> Vec { + let mut newest: BTreeMap<&str, &AdmissionFacts> = BTreeMap::new(); + for ((type_id, key), facts) in &self.facts.0 { + if kind_of_type(type_id).is_none() { + continue; + } + let root = root_and_local(key).0; + if newest.get(root).is_none_or(|seen| seen.ordinal < facts.ordinal) { + newest.insert(root, facts); + } + } + let docs = &self.docs; + let campaigns: BTreeMap<&str, &PipelineCampaign> = docs + .of_kind(PipelineKind::Campaign) + .filter_map(|(key, document)| match document { + PipelineDocument::Campaign(campaign) if docs.in_force(PipelineKind::Campaign, key) => Some((campaign.slug.0.as_str(), campaign)), + _ => None, + }) + .collect(); + newest + .into_iter() + .map(|(root, facts)| RootTouched::of(root, facts, campaigns.get(root).map_or(&[][..], |campaign| docs.repos_in_force(campaign)))) + .collect() + } + fn rows(&self) -> Result, MindRefusal> { self.docs .image @@ -563,6 +605,14 @@ impl Mind { Self::page(&selection, evaluation, as_of, None) } + /// Every root this mind holds a document under, with its newest admission and, for a root that is an in-force + /// campaign, the repos in force (`Docs::repos_in_force`, never the campaign row's opening set). A resolution's + /// key carries its subject's root, so a later resolution is a touch of that root. One pass at the head, no rows. + pub fn roots_touched(&self) -> Result, MindRefusal> { + let head = receipt::head(self)?; + Ok(Reader::at(self, head)?.roots_touched()) + } + /// What a semantic query must satisfy before anything is embedded: the /// selection passes the same two doors `query` does, carries no cursor and /// no `descending` (a ranked answer is neither pageable nor reversible), @@ -2521,4 +2571,110 @@ mod tests { let count = mind.read_back().unwrap(); println!("read back {count} stored documents in {:?}", started.elapsed()); } + + fn at_hour(hour: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 9, 16, 12 + hour, 0, 0).unwrap() + } + + fn touched(mind: &Mind) -> BTreeMap { + mind.roots_touched().unwrap().into_iter().map(|root| (root.root.0.clone(), root)).collect() + } + + /// A target revision naming `repos`, and the supersession of its predecessor. + fn repo_revision(campaign: &str, revision: u32, repos: &[&str]) -> Vec { + let D::Target(mut named) = target(revision, &[INVARIANT]) else { panic!("a target") }; + named.campaign = campaign.into(); + named.repos = repos.iter().map(|name| repo(name)).collect(); + let key = |n: u32| format!("{campaign}:target:r{n}"); + let mut batch = vec![D::Target(named)]; + if revision > 1 { + batch.push(resolution(r(K::Target, &key(revision - 1)), superseded(&[r(K::Target, &key(revision))]))); + } + batch + } + + /// Each root answers its newest admission: a later document counts, and so does a later resolution of an older one; an + /// instance's root is listed with no repos; a root is listed once, in the order of its name. + #[test] + fn roots_touched_is_the_newest_admission_per_root() { + let mut mind = seeded(); + let first = touched(&mind); + assert_eq!(first.keys().map(String::as_str).collect::>(), [CAMPAIGN, INSTANCE]); + assert_eq!((first[CAMPAIGN].admitted_at.as_str(), first[INSTANCE].admitted_at.as_str()), ("2026-09-16T12:00:00Z", "2026-09-16T12:00:00Z")); + assert!(first[INSTANCE].repos.is_empty(), "an instance root is no campaign"); + + let D::Campaign(mut other) = campaign(&["GameCult/Third"]) else { panic!("a campaign") }; + other.slug = "other-campaign".into(); + committed(admit_at(&mut mind, vec![D::Campaign(other)], at_hour(1))); + committed(admit_at(&mut mind, vec![question_n(1)], at_hour(2))); + let second = touched(&mind); + assert_eq!(second.keys().map(String::as_str).collect::>(), [CAMPAIGN, "other-campaign", INSTANCE]); + assert_eq!(second["other-campaign"].admitted_at, "2026-09-16T13:00:00Z"); + assert_eq!(second[CAMPAIGN].admitted_at, "2026-09-16T14:00:00Z", "a later document touches its root"); + assert_eq!(second[INSTANCE].admitted_at, "2026-09-16T12:00:00Z", "and no other root"); + assert!(second[INSTANCE].ordinal < second["other-campaign"].ordinal && second["other-campaign"].ordinal < second[CAMPAIGN].ordinal, "{second:?}"); + + committed(admit_at(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())], at_hour(5))); + let third = touched(&mind); + assert_eq!(third[CAMPAIGN].admitted_at, "2026-09-16T17:00:00Z", "a later resolution is a touch of its subject's root"); + assert!(third[CAMPAIGN].ordinal > second[CAMPAIGN].ordinal); + assert_eq!(third["other-campaign"], second["other-campaign"]); + } + + /// A campaign answers the repos in force, not the opening set: the target that changes the set decides, a closed target does + /// not, another campaign's target does not; a campaign closed answers none. + #[test] + fn roots_touched_repos_are_the_ones_in_force() { + let mut mind = seeded(); + assert_eq!(touched(&mind)[CAMPAIGN].repos, [repo(REPO)], "no target names repos: the opening set"); + + committed(admit(&mut mind, repo_revision(CAMPAIGN, 2, &[REPO, OTHER_REPO]))); + assert_eq!(touched(&mind)[CAMPAIGN].repos, [repo(REPO), repo(OTHER_REPO)], "a target that changes the set decides"); + + let D::Campaign(mut other) = campaign(&[REPO]) else { panic!("a campaign") }; + other.slug = "other-campaign".into(); + let mut batch = vec![D::Campaign(other)]; + batch.extend(repo_revision("other-campaign", 1, &["GameCult/Third"])); + committed(admit(&mut mind, batch)); + let both = touched(&mind); + assert_eq!(both["other-campaign"].repos, [repo("GameCult/Third")]); + assert_eq!(both[CAMPAIGN].repos, [repo(REPO), repo(OTHER_REPO)], "another campaign's higher target decides nothing here"); + + committed(admit(&mut mind, repo_revision(CAMPAIGN, 3, &[OTHER_REPO]))); + assert_eq!(touched(&mind)[CAMPAIGN].repos, [repo(OTHER_REPO)], "neither the opening set nor the closed revision decides"); + } + + /// A campaign with a resolution in force answers no repos, and one beside it that is open keeps its own. Admission has no row to + /// close a campaign, so the image is built by hand. + #[test] + fn a_closed_campaign_answers_no_repos() { + let held = |kind: K, key: String, document: D| Held { kind, key, document }; + let (open_key, closed_key, closing_key) = ("open:campaign:open".to_string(), "closed:campaign:closed".to_string(), "closed:resolution:campaign.n1".to_string()); + let named = |slug: &str, repo_name: &str| { + let D::Campaign(mut c) = campaign(&[repo_name]) else { panic!("a campaign") }; + c.slug = slug.into(); + D::Campaign(c) + }; + let image = vec![ + held(K::Campaign, open_key.clone(), named("open", REPO)), + held(K::Campaign, closed_key.clone(), named("closed", OTHER_REPO)), + held(K::Resolution, closing_key.clone(), resolution(r(K::Campaign, &closed_key), withdrawn())), + ]; + let landed = |ordinal: u64| AdmissionFacts { + receipt_id: format!("receipt-{ordinal}"), + admitted_at: "2026-09-16T12:00:00Z".into(), + provenance: provenance(Faculty::Hands), + ordinal, + }; + let facts = AdmissionIndex(BTreeMap::from([ + ((K::Campaign.type_id().to_string(), open_key), landed(1)), + ((K::Campaign.type_id().to_string(), closed_key), landed(2)), + ((K::Resolution.type_id().to_string(), closing_key), landed(3)), + ])); + let roots = Reader { docs: Docs { image, batch: Vec::new() }, facts }.roots_touched(); + let by_root: BTreeMap<&str, &RootTouched> = roots.iter().map(|root| (root.root.0.as_str(), root)).collect(); + assert_eq!(by_root["open"].repos, [repo(REPO)]); + assert!(by_root["closed"].repos.is_empty(), "{roots:?}"); + assert_eq!(by_root["closed"].ordinal, 3, "the closure is a touch of the campaign it closes"); + } } diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index c4415b4..4f438ab 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -14,7 +14,7 @@ use serde_json::Value; use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome}; use crate::mind::Mind; use crate::persona::{PersonaPutOutcome, PersonaStateView}; -use crate::query::{PipelineDocumentView, PipelineSelectionPage, SemanticQuery}; +use crate::query::{PipelineDocumentView, PipelineSelectionPage, RootTouched, SemanticQuery}; use crate::receipt::{HuginnCommitReceipt, PipelineProvenance}; use crate::refusal::MindRefusal; use crate::store::MindStore; @@ -98,6 +98,9 @@ pub enum HuginnMindRequest { /// in time. The repos are the asker's to name (her stewardships); the /// instance's own login is dropped from the answer. StewardedActivity { instance: Slug, repos: Vec, after: Option }, + /// Every root the mind holds a document under, with its newest admission and a campaign's repos in force: the mind's own + /// records, one pass at the head. + RootsTouched { instance: Slug }, } /// The most repos one activity read names. @@ -250,6 +253,7 @@ impl HuginnMindRequest { Self::PersonaPut { .. } => "persona_put", Self::SearchSources { .. } => "search_sources", Self::StewardedActivity { .. } => "stewarded_activity", + Self::RootsTouched { .. } => "roots_touched", } } @@ -263,7 +267,8 @@ impl HuginnMindRequest { | Self::PersonaGet { instance } | Self::PersonaPut { instance, .. } | Self::SearchSources { instance, .. } - | Self::StewardedActivity { instance, .. } => Some(instance), + | Self::StewardedActivity { instance, .. } + | Self::RootsTouched { instance } => Some(instance), } } } @@ -282,6 +287,7 @@ pub enum HuginnMindResponse { PersonaPut(PersonaPutOutcome), Sources(SourceHits), Activity(RepoActivity), + RootsTouched(Vec), /// The answer did not fit one send and is being held for the client to /// fetch. Nothing in a mind produces this: the daemon's serve module does, /// in place of an answer, and the answer it stands for is the named @@ -417,6 +423,7 @@ mod tests { instance: slug(INSTANCE), query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())) }, }, + HuginnMindRequest::RootsTouched { instance: slug(INSTANCE) }, ] } @@ -466,6 +473,7 @@ mod tests { text: "x".into(), }], }), + HuginnMindResponse::RootsTouched(seeded().roots_touched().unwrap()), HuginnMindResponse::Deferred(DeferredAnswer { manifest: cultnet_rs::pack_content("huginn.mind_response", "package", "", "", "", b"body", 4) .unwrap() @@ -480,7 +488,7 @@ mod tests { #[test] fn the_wire_vocabulary_is_the_minds_methods_and_status_is_derived_from_the_response() { let names: Vec<&str> = requests().iter().map(|request| request.operation()).collect(); - assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put", "search_sources"]); + assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put", "search_sources", "roots_touched"]); for request in requests() { let declared = request.instance().is_none(); assert_eq!(declared, matches!(request, HuginnMindRequest::Whoami), "{request:?}"); diff --git a/crates/mind-body/src/queue.rs b/crates/mind-body/src/queue.rs index edf988d..8533ed3 100644 --- a/crates/mind-body/src/queue.rs +++ b/crates/mind-body/src/queue.rs @@ -17,7 +17,7 @@ use eureka_state::{ClientError, HuginnClient}; use huginn_mind::{ Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineDocumentSummary, PipelineFacts, PipelineProvenance, - PipelineSelectionPage, PipelineStatusSummary, RepoActivity, SemanticQuery, + PipelineSelectionPage, PipelineStatusSummary, RepoActivity, RootTouched, SemanticQuery, }; /// The last `CONSECUTIVE` ended runs over an item, all empty, trip its breaker. @@ -46,6 +46,11 @@ pub trait MindPort { fn stewarded_activity(&self, _repos: &[OrgRepo], _after: Option) -> Result { bail!("this mind port has no forge to read") } + /// Every root her mind holds a document under, with its newest admission and a campaign's repos in force. A port that does not + /// read a mind refuses. + fn roots_touched(&self) -> Result> { + bail!("this mind port has no roots to read") + } /// Admits `documents` as one batch, attributed to `agent` in `session`. fn admit(&self, agent: &str, session: &str, documents: Vec) -> Result; } @@ -104,6 +109,15 @@ impl MindPort for HuginnMind<'_> { } } + fn roots_touched(&self) -> Result> { + let request = HuginnMindRequest::RootsTouched { instance: self.instance().clone() }; + match (self.call)(request).map_err(|error| anyhow!("the mind could not be asked: {}", client_error_code(&error)))? { + HuginnMindResponse::RootsTouched(roots) => Ok(roots), + HuginnMindResponse::Refused(_) => bail!("the mind refused the roots read"), + _ => bail!("the mind answered a roots read with something else"), + } + } + fn admit(&self, agent: &str, session: &str, documents: Vec) -> Result { let batch = PipelineAdmissionBatch { instance: self.instance().clone(), @@ -575,6 +589,38 @@ mod tests { assert!(format!("{:#}", port.stewarded_activity(&repos, None).unwrap_err()).contains("something else")); } + /// The roots ask goes out as the one `RootsTouched` request naming her instance, a real mind's answer comes back whole through + /// `HuginnMind` itself, and a refusal, a client error or any other answer is an error that carries nothing of the refusal. + #[test] + fn a_roots_read_goes_through_the_port_as_one_request() { + let mind = TestMind::seeded(); + let seen = std::cell::RefCell::new(Vec::new()); + let broken = std::cell::Cell::new(0u8); + let port = HuginnMind::over(Slug(INSTANCE.into()), Box::new(|request| { + seen.borrow_mut().push(request.clone()); + match broken.get() { + 0 => Ok(mind.answer(request)), + 1 => Ok(HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { detail: "canary-roots-detail".into() })), + 2 => Ok(HuginnMindResponse::View(None)), + _ => Err(ClientError::Unencodable { detail: "canary-detail".into() }), + } + })); + let roots = port.roots_touched().unwrap(); + assert_eq!(seen.borrow()[0], HuginnMindRequest::RootsTouched { instance: Slug(INSTANCE.into()) }); + let campaign = roots.iter().find(|root| root.root.0 == CAMPAIGN).expect("the seeded campaign is a root"); + assert_eq!(campaign.repos, [OrgRepo::from(REPO)], "the campaign's repos in force come back through the port"); + assert!(roots.iter().any(|root| root.root.0 == INSTANCE && root.repos.is_empty()), "{roots:?}"); + + broken.set(1); + let refused = port.roots_touched().unwrap_err(); + assert!(format!("{refused:#}").contains("refused the roots read") && !format!("{refused:#}").contains("canary"), "{refused:#}"); + broken.set(2); + assert!(format!("{:#}", port.roots_touched().unwrap_err()).contains("something else")); + broken.set(3); + let down = port.roots_touched().unwrap_err(); + assert!(format!("{down:#}").contains("could not be asked") && !format!("{down:#}").contains("canary"), "{down:#}"); + } + #[test] fn runs_of_another_item_are_not_this_items_runs() { diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index e5a3f11..6d39aa6 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -157,6 +157,10 @@ impl TestMind { Err(refusal) => HuginnMindResponse::Refused(refusal), }, HuginnMindRequest::Admit(batch) => HuginnMindResponse::Admit(self.mind.borrow_mut().admit(batch, self.now.get())), + HuginnMindRequest::RootsTouched { .. } => match self.mind.borrow().roots_touched() { + Ok(roots) => HuginnMindResponse::RootsTouched(roots), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, other => panic!("the tests ask nothing else: {other:?}"), } } @@ -207,6 +211,11 @@ impl MindPort for TestMind { Ok(huginn_mind::RepoActivity { head: after.unwrap_or(0), events: vec![], capped: vec![], open_prs: vec![], unseen: vec![], moved: vec![] }) } + /// The roots of this mind as it holds them: the daemon's answer from the same mind, never a stub. + fn roots_touched(&self) -> Result> { + self.mind.borrow().roots_touched().map_err(|_| anyhow!("refused")) + } + fn admit(&self, agent: &str, session: &str, documents: Vec) -> Result { self.now.set(self.now.get() + Duration::seconds(1)); let batch = PipelineAdmissionBatch { diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index acb6bc8..7570af6 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -210,7 +210,7 @@ mind_body::queue: impl HuginnMind < 'static > mind_body::queue: impl HuginnMind < 'static >: fn new (client : HuginnClient) -> Self mind_body::queue: impl MindPort for HuginnMind < '_ > mind_body::queue: impl< 'a > HuginnMind < 'a > -mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn query_near (& self , _selection : & Selection , _text : & str , _top_k : u32) -> Result < PipelineSelectionPage > { bail ! ("this mind port has no semantic index") } fn stewarded_activity (& self , _repos : & [OrgRepo] , _after : Option < u64 >) -> Result < RepoActivity > { bail ! ("this mind port has no forge to read") } fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; } +mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn query_near (& self , _selection : & Selection , _text : & str , _top_k : u32) -> Result < PipelineSelectionPage > { bail ! ("this mind port has no semantic index") } fn stewarded_activity (& self , _repos : & [OrgRepo] , _after : Option < u64 >) -> Result < RepoActivity > { bail ! ("this mind port has no forge to read") } fn roots_touched (& self) -> Result < Vec < RootTouched > > { bail ! ("this mind port has no roots to read") } fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; } mind_body::queue: struct Held # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[] mind_body::queue: struct HuginnMind< 'a > pub-fields[] mind_body::recall: impl Lane diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index f7a69cf..2c6a291 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -193,6 +193,27 @@ "required": [ "StewardedActivity" ] + }, + { + "description": "Every root the mind holds a document under, with its newest admission and a campaign's repos in force: the mind's own\nrecords, one pass at the head.", + "type": "object", + "properties": { + "RootsTouched": { + "type": "object", + "properties": { + "instance": { + "$ref": "#/$defs/Slug" + } + }, + "required": [ + "instance" + ] + } + }, + "additionalProperties": false, + "required": [ + "RootsTouched" + ] } ], "$defs": { diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 45cace8..b16ac9d 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -125,6 +125,21 @@ "Activity" ] }, + { + "type": "object", + "properties": { + "RootsTouched": { + "type": "array", + "items": { + "$ref": "#/$defs/RootTouched" + } + } + }, + "additionalProperties": false, + "required": [ + "RootsTouched" + ] + }, { "description": "The answer did not fit one send and is being held for the client to\nfetch. Nothing in a mind produces this: the daemon's serve module does,\nin place of an answer, and the answer it stands for is the named\nMessagePack of the `HuginnMindResponse` the mind gave.", "type": "object", @@ -4652,6 +4667,35 @@ } ] }, + "RootTouched": { + "description": "A root of the mind and the newest admission under it. `repos` is the repo set in force of a root that is an in-force campaign,\nand empty for any other root.", + "type": "object", + "properties": { + "admitted_at": { + "type": "string" + }, + "ordinal": { + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/OrgRepo" + } + }, + "root": { + "$ref": "#/$defs/Slug" + } + }, + "required": [ + "root", + "admitted_at", + "ordinal", + "repos" + ] + }, "RulingAuthority": { "type": "string", "enum": [