diff --git a/Cargo.lock b/Cargo.lock index 685b066..45d9d79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,6 @@ source = "git+https://eureka.gamecult.org/forge/GameCult/ModelConnector.git?rev= dependencies = [ "aes-gcm", "base64", - "cultcache-rs 0.3.1", "cultnet-rs 0.2.0", "rand_core 0.6.4", "rmp-serde", diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index 4a5d159..635d76c 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -22,13 +22,17 @@ mod render; mod schema; mod trouble; +use std::cell::{Cell, RefCell}; use std::io::Write; use std::process::ExitCode; use std::sync::OnceLock; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use cultnet_rs::{PROJECTION_DOCUMENT, Selection}; -use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key}; +use eureka_state::hold; +use huginn_mind::eureka_pipeline::{ + Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key, +}; use huginn_mind::{ BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery, @@ -182,7 +186,15 @@ fn run(settings: &Settings, command: Command) -> Result { let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) }); query(settings, selection, semantic, None, documents) } - Command::Sources { text, repo, top_k, json } => sources(settings, text, repo, top_k, json), + Command::Sources { + text, + repo, + top_k, + json, + } => sources(settings, text, repo, top_k, json), + Command::Recipe(Recipe::Blocked { spec_id, page }) => { + blocked(settings, &spec_id, page.cursor, page.documents) + } Command::Recipe(recipe) => { let ask = recipe.ask()?; query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents) @@ -264,9 +276,81 @@ fn query( } } -fn sources(settings: &Settings, text: String, repo: Option, top_k: Option, json: bool) -> Result { - let query = SourceQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K), repo: repo.map(Short) }; - match settings.ask(|instance| HuginnMindRequest::SearchSources { instance: instance.clone(), query })? { +/// The open questions that hold a spec, and the answered-`retire` ones whose +/// claims are not yet resolved: `eureka_state::hold`'s answer, the one the +/// heartbeat's gate reads, printed as a selection of those questions. +fn blocked( + settings: &Settings, + spec_id: &str, + cursor: Option, + documents: bool, +) -> Result { + let spec = PipelineRef { + kind: door::kind_of(spec_id)?, + id: Short(spec_id.to_string()), + }; + let failure: RefCell> = RefCell::new(None); + let as_of = Cell::new(0); + let ask = |selection: &Selection| -> anyhow::Result { + let selection = selection.clone(); + match settings.ask(|instance| HuginnMindRequest::Query { + instance: instance.clone(), + selection, + semantic: None, + }) { + Ok(HuginnMindResponse::Query(page)) => { + as_of.set(page.as_of); + Ok(page) + } + Ok(_) => Err(anyhow::anyhow!( + "the mind answered a query with something else" + )), + Err(trouble) => { + *failure.borrow_mut() = Some(trouble); + Err(anyhow::anyhow!("the mind could not be asked")) + } + } + }; + let holding = match hold::holds(&ask) { + Ok(holds) => holds, + Err(error) => { + return Err(failure + .take() + .unwrap_or_else(|| Trouble::Internal(error.to_string()))); + } + }; + let keys: Vec = holding + .iter() + .filter(|held| held.items.contains(&spec)) + .map(|held| held.question.id.id.0.clone()) + .collect(); + if keys.is_empty() { + say(format_args!("matched 0 as_of {} next -", as_of.get())); + return Ok(0); + } + let selection = Selection { + keys: Some(keys), + ..Selection::default() + }; + query(settings, selection, None, cursor, documents) +} + +fn sources( + settings: &Settings, + text: String, + repo: Option, + top_k: Option, + json: bool, +) -> Result { + let query = SourceQuery { + text: Line(text), + top_k: top_k.unwrap_or(DEFAULT_TOP_K), + repo: repo.map(Short), + }; + match settings.ask(|instance| HuginnMindRequest::SearchSources { + instance: instance.clone(), + query, + })? { HuginnMindResponse::Sources(found) => { if json { say(serde_json::to_string_pretty(&found).map_err(|error| Trouble::Internal(error.to_string()))?); @@ -424,6 +508,8 @@ fn unexpected(answer: &HuginnMindResponse) -> Trouble { HuginnMindResponse::Persona(_) => "a persona", 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/eureka-state/src/bin/huginn/recipes.rs b/crates/eureka-state/src/bin/huginn/recipes.rs index 78bf092..7d1c3b2 100644 --- a/crates/eureka-state/src/bin/huginn/recipes.rs +++ b/crates/eureka-state/src/bin/huginn/recipes.rs @@ -1,8 +1,11 @@ //! The campaign progress view as named subcommands. Each recipe is exactly the //! selection `references/campaign-state.md` gives (Eureka 278b881, "Recipes"), //! built per call and never cached. Each one's `--help` prints its selection. +//! The one exception is `blocked`, which asks what a question holds and so +//! reads through `eureka_state::hold`, the decider the heartbeat's gate reads. use clap::{Args, Subcommand}; +use eureka_state::hold::BLOCKING; use huginn_mind::SemanticQuery; use huginn_mind::eureka_pipeline::{Line, PipelineKind}; use serde_json::{Value, json}; @@ -14,8 +17,6 @@ use crate::trouble::Trouble; const GAP_HOME: &str = "eureka-substrate"; /// How many neighbours `precedent` asks the index for. const PRECEDENT_TOP_K: u32 = 10; -/// Severities that block a cut. -const BLOCKING: [&str; 2] = ["Blocker", "High"]; #[derive(Args, Clone, Debug, Default)] pub struct Paging { @@ -66,7 +67,7 @@ pub enum Recipe { #[command(flatten)] page: Paging, }, - /// Open questions raised in a spec: the spec waits on the operator. + /// Questions that hold a spec, answered-retire ones included: the spec waits on the operator. Blocked { spec_id: String, #[command(flatten)] @@ -125,7 +126,12 @@ impl Recipe { Self::Open { campaigns, page } => (open(&campaigns), None, page), Self::Findings { campaign, blocking, page } => (findings(&campaign, blocking), None, page), Self::Unreported { campaign, page } => (unreported(&campaign), None, page), - Self::Blocked { spec_id, page } => (blocked(&spec_id), None, page), + // Several selections, not one: `main` answers it through `eureka_state::hold`. + Self::Blocked { .. } => { + return Err(Trouble::Internal( + "blocked is answered by the hold, not by one selection".into(), + )); + } Self::Unjudged { campaign, page } => (unjudged(&campaign), None, page), Self::History { id, page } => (history(door::kind_of(&id)?.type_id(), &id), None, page), Self::Cut { campaign, label, page } => (cut(&campaign, &label), None, page), @@ -198,14 +204,6 @@ fn unreported(campaign: &str) -> Value { }) } -fn blocked(spec_id: &str) -> Value { - json!({ - "schemas": [schema(PipelineKind::Question)], - "fields": [in_force()], - "cites": { "target": { "schemaId": schema(PipelineKind::CutSpec), "recordKey": spec_id }, "role": "raised_in" }, - }) -} - fn unjudged(campaign: &str) -> Value { json!({ "schemas": [schema(PipelineKind::CutReport)], @@ -257,6 +255,18 @@ fn gaps() -> Value { }) } +/// `blocked` is not one selection: it reads the in-force questions and, for each, +/// what it holds, by `eureka_state::hold`, the decider the heartbeat's gate reads. +fn blocked_help() -> String { + let questions = json!({ "schemas": [schema(PipelineKind::Question)], "fields": [in_force()] }); + format!( + "selection (first of several): +{} +A question holds the item it is raised in, or every claim of the run it is raised in; one answered `retire` keeps holding the claims still in force. The recipe prints the questions that hold .", + serde_json::to_string_pretty(&questions).unwrap_or_default() + ) +} + /// Each recipe's `--help` addendum: its selection with placeholders in the /// arguments' places. The same builders as execution, so help cannot drift /// from what runs. @@ -276,7 +286,7 @@ pub fn help() -> Vec<(&'static str, String)> { ("open", selection(open(&[c()]))), ("findings", findings_help), ("unreported", selection(unreported(&c()))), - ("blocked", selection(blocked(""))), + ("blocked", blocked_help()), ("unjudged", selection(unjudged(&c()))), ("history", selection(history("epiphany.pipeline..v2", ""))), ("cut", selection(cut(&c(), "