huginn CLI over HuginnClient: whoami, view, query, recipes, admit with read-back, schema
A new bin of eureka-state, crates/eureka-state/src/bin/huginn/: the agents' shell door to the mind. Text out, typed exit codes, strict decode of agent-written JSON (unknown key paths refused, never values), the named campaign-state recipes, and a read-back after every committed admission. lib.rs, main.rs and tests/mcp.rs are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f003801138
commit
02cb3dadcd
10 changed files with 2224 additions and 2 deletions
121
Cargo.lock
generated
121
Cargo.lock
generated
|
|
@ -57,6 +57,56 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
|
|
@ -201,6 +251,52 @@ dependencies = [
|
|||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
|
|
@ -443,6 +539,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cultnet-rs",
|
||||
"huginn-daemon",
|
||||
"huginn-mind",
|
||||
|
|
@ -640,6 +737,12 @@ version = "0.17.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
|
|
@ -759,6 +862,12 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -853,6 +962,12 @@ version = "1.21.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
|
|
@ -1594,6 +1709,12 @@ version = "0.8.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ publish.workspace = true
|
|||
# the daemon cannot spell them differently; the content fetch and its
|
||||
# verification are `cultnet-rs`'s.
|
||||
#
|
||||
# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one
|
||||
# consumer: four tools over stdio, one per wire operation, holding no state.
|
||||
# The crate is the client core (`lib.rs`) and its consumers: the `huginn` CLI
|
||||
# (`src/bin/huginn/`), the shell door every agent uses, and until
|
||||
# delete-eureka-state-mcp the MCP server (`main.rs`, the `eureka-state` binary).
|
||||
# Both hold no state.
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
cultnet-rs.workspace = true
|
||||
huginn-mind = { path = "../huginn-mind" }
|
||||
rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] }
|
||||
|
|
|
|||
67
crates/eureka-state/src/bin/huginn/config.rs
Normal file
67
crates/eureka-state/src/bin/huginn/config.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! The CLI's configuration: the mind is the session's environment, never an
|
||||
//! argument. `EUREKA_INSTANCE` names the instance and `HUGINN_ENDPOINT`
|
||||
//! (`rudp://<ip>:<port>`) the daemon that holds it.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use eureka_state::HuginnClient;
|
||||
use huginn_mind::eureka_pipeline::Slug;
|
||||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse};
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The `provenance.tool` every admission from this binary carries.
|
||||
pub const TOOL: &str = "huginn";
|
||||
/// How long one call may take, connection to last byte.
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// The two environment values as given, and the client they make when both
|
||||
/// are valid.
|
||||
pub struct Settings {
|
||||
pub instance: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
client: Result<HuginnClient, String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_env() -> Self {
|
||||
let instance = variable("EUREKA_INSTANCE");
|
||||
let endpoint = variable("HUGINN_ENDPOINT");
|
||||
let shown = |value: &Result<Option<String>, String>| value.as_ref().ok().cloned().flatten();
|
||||
let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint));
|
||||
Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) }
|
||||
}
|
||||
|
||||
pub fn client(&self) -> Result<&HuginnClient, Trouble> {
|
||||
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
|
||||
}
|
||||
|
||||
/// One wire call for the configured instance. The request is built from
|
||||
/// that instance, never from anything the caller sent. No retry.
|
||||
pub fn ask(&self, request: impl FnOnce(&Slug) -> HuginnMindRequest) -> Result<HuginnMindResponse, Trouble> {
|
||||
let client = self.client()?;
|
||||
client.call(request(client.instance())).map_err(Trouble::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// An environment variable: its value, `None` when unset, or why it cannot be read.
|
||||
fn variable(name: &str) -> Result<Option<String>, String> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(instance: Result<Option<String>, String>, endpoint: Result<Option<String>, String>) -> Result<HuginnClient, String> {
|
||||
let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?;
|
||||
let slug = Slug(instance);
|
||||
slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?;
|
||||
let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?;
|
||||
let address = endpoint
|
||||
.strip_prefix("rudp://")
|
||||
.and_then(|address| address.parse::<SocketAddr>().ok())
|
||||
.ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://<ip>:<port>: {endpoint}"))?;
|
||||
Ok(HuginnClient::new(address, slug, TIMEOUT))
|
||||
}
|
||||
161
crates/eureka-state/src/bin/huginn/door.rs
Normal file
161
crates/eureka-state/src/bin/huginn/door.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! The text door: agent-written JSON becomes typed values here, strictly, and
|
||||
//! nowhere else. The store's decode stays lenient (serde ignores unknown
|
||||
//! keys), so a misspelled key would be dropped and the batch committed
|
||||
//! without it. The door refuses instead: every key path present in the input
|
||||
//! with a value and absent from the typed value's re-serialisation is named in
|
||||
//! the refusal. Paths only, never values.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use cultnet_rs::Selection;
|
||||
use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The text of an argument that is a literal, `@<file>` or `-` for stdin.
|
||||
pub fn argument(arg: &str) -> Result<String, Trouble> {
|
||||
match arg.strip_prefix('@') {
|
||||
Some(path) => file(path),
|
||||
None if arg == "-" => stdin(),
|
||||
None => Ok(arg.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a file argument, or stdin when it is `-`.
|
||||
pub fn file_or_stdin(arg: &str) -> Result<String, Trouble> {
|
||||
if arg == "-" { stdin() } else { file(arg) }
|
||||
}
|
||||
|
||||
fn file(path: &str) -> Result<String, Trouble> {
|
||||
std::fs::read_to_string(path).map_err(|error| Trouble::invalid(format!("cannot read {path}: {}", error.kind())))
|
||||
}
|
||||
|
||||
fn stdin() -> Result<String, Trouble> {
|
||||
let mut text = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| Trouble::invalid(format!("cannot read stdin: {}", error.kind())))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// A batch: a JSON array of `{kind, value}` documents, or one such object.
|
||||
pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Trouble> {
|
||||
let items = match parse(text)? {
|
||||
Value::Array(items) => items,
|
||||
object @ Value::Object(_) => vec![object],
|
||||
_ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")),
|
||||
};
|
||||
items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| strict(item, &format!("documents[{index}]")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn selection(text: &str) -> Result<Selection, Trouble> {
|
||||
selection_value(parse(text)?)
|
||||
}
|
||||
|
||||
pub fn selection_value(value: Value) -> Result<Selection, Trouble> {
|
||||
strict(value, "selection")
|
||||
}
|
||||
|
||||
/// The kind a pipeline id names in its middle segment (`<root>:<kind>:<local>`).
|
||||
pub fn kind_of(id: &str) -> Result<PipelineKind, Trouble> {
|
||||
let mut segments = id.splitn(3, ':');
|
||||
let (_, Some(name), Some(_)) = (segments.next(), segments.next(), segments.next()) else {
|
||||
return Err(Trouble::invalid("a pipeline id is <root>:<kind>:<local>"));
|
||||
};
|
||||
PipelineKind::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|kind| kind.name() == name)
|
||||
.ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)"))
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Result<Value, Trouble> {
|
||||
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {error}")))
|
||||
}
|
||||
|
||||
fn strict<T: Serialize + DeserializeOwned>(input: Value, at: &str) -> Result<T, Trouble> {
|
||||
let typed: T = serde_json::from_value(input.clone()).map_err(|error| Trouble::invalid(format!("{at} does not decode: {error}")))?;
|
||||
let kept = serde_json::to_value(&typed).map_err(|error| Trouble::Internal(format!("{at} does not re-serialise: {error}")))?;
|
||||
let mut paths = Vec::new();
|
||||
dropped(&input, &kept, at, &mut paths);
|
||||
if paths.is_empty() {
|
||||
Ok(typed)
|
||||
} else {
|
||||
Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", "))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths of `input` that carry a value and are absent from `kept`.
|
||||
fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec<String>) {
|
||||
match (input, kept) {
|
||||
(Value::Object(given), Value::Object(held)) => {
|
||||
for (key, value) in given {
|
||||
let here = format!("{path}.{key}");
|
||||
match held.get(key) {
|
||||
Some(held) => dropped(value, held, &here, out),
|
||||
None if !empty(value) => out.push(here),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(given), Value::Array(held)) => {
|
||||
for (index, (value, held)) in given.iter().zip(held).enumerate() {
|
||||
dropped(value, held, &format!("{path}[{index}]"), out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value whose loss changes nothing: the spellings of "not given".
|
||||
fn empty(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => true,
|
||||
Value::String(text) => text.is_empty(),
|
||||
Value::Array(items) => items.is_empty(),
|
||||
Value::Object(fields) => fields.is_empty(),
|
||||
Value::Bool(_) | Value::Number(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn paths(input: &Value, kept: &Value) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
dropped(input, kept, "d", &mut out);
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_key_with_a_value_is_named_by_path() {
|
||||
let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 });
|
||||
let kept = json!({ "a": { "b": [ { "c": 1 } ] } });
|
||||
assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_key_without_a_value_is_not_a_loss() {
|
||||
let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false });
|
||||
assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_come_from_the_middle_segment() {
|
||||
assert_eq!(kind_of("eureka-substrate:cut_spec:cut-x.r1").ok(), Some(PipelineKind::CutSpec));
|
||||
assert!(kind_of("eureka-substrate:nonsense:x").is_err());
|
||||
assert!(kind_of("two:segments").is_err());
|
||||
assert!(kind_of("campaign").is_err());
|
||||
}
|
||||
}
|
||||
395
crates/eureka-state/src/bin/huginn/main.rs
Normal file
395
crates/eureka-state/src/bin/huginn/main.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
//! `huginn`: the agents' door to Huginn's mind, as shell commands.
|
||||
//!
|
||||
//! A caller of `HuginnClient` and nothing else. It owns argument parsing, the
|
||||
//! strict decode of agent-written JSON into the leaf's types (`door`), the
|
||||
//! recipe selections (`recipes`), the text rendering (`render`), the exit code
|
||||
//! and the read-back after an admission. It owns no state, writes no file and
|
||||
//! reads no configuration but two environment variables: `EUREKA_INSTANCE`
|
||||
//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that
|
||||
//! holds it. Each call is a new process; there is no retry inside it, the
|
||||
//! caller's rule retries `Unavailable` once.
|
||||
//!
|
||||
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
|
||||
//! 1 the mind said no (a refusal, a conflict, an absent document). Troubles go
|
||||
//! to stderr as `<Code>: <detail>`: 2 the call is wrong, 3 Unavailable, 4 no
|
||||
//! answer and a retry cannot help, 5 the read-back after a committed admission
|
||||
//! differs from what was sent.
|
||||
|
||||
mod config;
|
||||
mod door;
|
||||
mod recipes;
|
||||
mod render;
|
||||
mod schema;
|
||||
mod trouble;
|
||||
|
||||
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 huginn_mind::{
|
||||
BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome,
|
||||
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use config::{Settings, TOOL};
|
||||
use recipes::Recipe;
|
||||
use trouble::Trouble;
|
||||
|
||||
/// The page size when a selection names none.
|
||||
const DEFAULT_LIMIT: u32 = 50;
|
||||
/// The neighbours `--near` asks for when `--top-k` is not given.
|
||||
const DEFAULT_TOP_K: u32 = 10;
|
||||
|
||||
const ADMIT_HELP: &str = "\
|
||||
The file is a JSON array of 1 to 64 documents, each {\"kind\": <kind>, \"value\": {...}}, or - for stdin; one document object alone is a batch of one. \
|
||||
`huginn schema` lists the kinds and `huginn schema <kind>` prints a value's JSON Schema. \
|
||||
Every key the schema does not know is refused (exit 2) with its path; nothing is sent. \
|
||||
After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back <n> equal`, or exit 5 with the ids and field paths that differ.";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, admit, schema", arg_required_else_help = true)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Who this process acts for and whether the daemon answers.
|
||||
Whoami,
|
||||
/// Read one document by id, with its admission facts and status.
|
||||
View {
|
||||
/// The document's id: <root>:<kind>:<local>.
|
||||
id: String,
|
||||
/// Print only the {kind, value} JSON, which `admit` accepts back.
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
},
|
||||
/// Query the mind with a CultNet selection.
|
||||
Query {
|
||||
/// The selection JSON, @<file>, or - for stdin.
|
||||
selection: String,
|
||||
/// Rank by nearness to this text instead of ordering by key.
|
||||
#[arg(long)]
|
||||
near: Option<String>,
|
||||
/// How many neighbours (default 10).
|
||||
#[arg(long, requires = "near")]
|
||||
top_k: Option<u32>,
|
||||
/// Print each item as `view` does.
|
||||
#[arg(long)]
|
||||
documents: bool,
|
||||
},
|
||||
#[command(flatten)]
|
||||
Recipe(Recipe),
|
||||
/// Admit a batch of documents, whole or not at all, and read it back.
|
||||
#[command(after_help = ADMIT_HELP)]
|
||||
Admit {
|
||||
#[arg(long, value_parser = faculty)]
|
||||
faculty: Faculty,
|
||||
/// The admitting agent's name.
|
||||
#[arg(long)]
|
||||
agent: String,
|
||||
/// The admitting session.
|
||||
#[arg(long)]
|
||||
session: String,
|
||||
/// The documents file, or - for stdin.
|
||||
file: String,
|
||||
},
|
||||
/// The JSON Schema of a document kind or of a selection; no name lists them.
|
||||
Schema { name: Option<String> },
|
||||
}
|
||||
|
||||
fn faculty(text: &str) -> Result<Faculty, String> {
|
||||
serde_json::from_value(Value::String(text.to_string())).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// The version, then the sha the binary was built from.
|
||||
fn version() -> &'static str {
|
||||
static VERSION: OnceLock<String> = OnceLock::new();
|
||||
VERSION.get_or_init(|| format!("{} ({})", env!("CARGO_PKG_VERSION"), build_sha()))
|
||||
}
|
||||
|
||||
fn build_sha() -> &'static str {
|
||||
option_env!("HUGINN_BUILD_SHA").unwrap_or("unknown")
|
||||
}
|
||||
|
||||
/// The command line, or the exit code of having answered it (help, version) or
|
||||
/// refused it (usage, as `InvalidInput`).
|
||||
fn parse() -> Result<Cli, u8> {
|
||||
let mut command = Cli::command().version(version());
|
||||
for (name, text) in recipes::help() {
|
||||
command = command.mut_subcommand(name, |recipe| recipe.after_help(text));
|
||||
}
|
||||
match command.try_get_matches() {
|
||||
Ok(matches) => Cli::from_arg_matches(&matches).map_err(|error| usage(&error)),
|
||||
Err(error) => Err(usage(&error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage(error: &clap::Error) -> u8 {
|
||||
if error.use_stderr() {
|
||||
let text = error.to_string();
|
||||
eprintln!("InvalidInput: {}", text.trim_start_matches("error: ").trim_end());
|
||||
2
|
||||
} else {
|
||||
let _ = error.print();
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = match parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(code) => return ExitCode::from(code),
|
||||
};
|
||||
match run(&Settings::from_env(), cli.command) {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(trouble) => {
|
||||
eprintln!("{trouble}");
|
||||
ExitCode::from(trouble.exit())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn say(text: impl std::fmt::Display) {
|
||||
let _ = writeln!(std::io::stdout(), "{text}");
|
||||
}
|
||||
|
||||
fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
|
||||
match command {
|
||||
Command::Whoami => whoami(settings),
|
||||
Command::View { id, raw } => view(settings, &id, raw),
|
||||
Command::Query { selection, near, top_k, documents } => {
|
||||
let selection = door::selection(&door::argument(&selection)?)?;
|
||||
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::Recipe(recipe) => {
|
||||
let ask = recipe.ask()?;
|
||||
query(settings, door::selection_value(ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents)
|
||||
}
|
||||
Command::Admit { faculty, agent, session, file } => admit(settings, faculty, agent, session, &file),
|
||||
Command::Schema { name } => {
|
||||
let text = schema::text(name.as_deref())?;
|
||||
let _ = write!(std::io::stdout(), "{text}");
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn whoami(settings: &Settings) -> Result<u8, Trouble> {
|
||||
say(format_args!("instance {}", settings.instance.as_deref().unwrap_or("unset")));
|
||||
say(format_args!("endpoint {}", settings.endpoint.as_deref().unwrap_or("unset")));
|
||||
say(format_args!("client {}", build_sha()));
|
||||
match settings.ask(|_| HuginnMindRequest::Whoami) {
|
||||
Ok(HuginnMindResponse::Whoami(status)) => {
|
||||
say("reachable yes");
|
||||
say(format_args!(
|
||||
"documents {} receipts {} index {} epoch {}",
|
||||
status.documents,
|
||||
status.receipts,
|
||||
render::index_state(&status.index),
|
||||
status.schema_epoch
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
Ok(other) => {
|
||||
say("reachable no");
|
||||
Err(unexpected(&other))
|
||||
}
|
||||
Err(trouble) => {
|
||||
say("reachable no");
|
||||
Err(trouble)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(settings: &Settings, id: &str, raw: bool) -> Result<u8, Trouble> {
|
||||
let reference = PipelineRef { kind: door::kind_of(id)?, id: Short(id.to_string()) };
|
||||
match settings.ask(|instance| HuginnMindRequest::View { instance: instance.clone(), id: reference })? {
|
||||
HuginnMindResponse::View(Some(found)) => {
|
||||
say(render::view(&found, raw));
|
||||
Ok(0)
|
||||
}
|
||||
HuginnMindResponse::View(None) => {
|
||||
say(format_args!("absent {id}"));
|
||||
Ok(1)
|
||||
}
|
||||
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn query(
|
||||
settings: &Settings,
|
||||
mut selection: Selection,
|
||||
semantic: Option<SemanticQuery>,
|
||||
cursor: Option<String>,
|
||||
documents: bool,
|
||||
) -> Result<u8, Trouble> {
|
||||
if documents {
|
||||
selection.projection = PROJECTION_DOCUMENT.to_string();
|
||||
}
|
||||
if cursor.is_some() {
|
||||
selection.cursor = cursor;
|
||||
}
|
||||
selection.limit.get_or_insert(DEFAULT_LIMIT);
|
||||
match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic })? {
|
||||
HuginnMindResponse::Query(page) => {
|
||||
show(&page);
|
||||
Ok(0)
|
||||
}
|
||||
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn show(page: &PipelineSelectionPage) {
|
||||
match &page.items {
|
||||
PipelinePageItems::Headers(headers) => {
|
||||
for header in headers {
|
||||
say(render::header(header));
|
||||
}
|
||||
}
|
||||
PipelinePageItems::Documents(views) => {
|
||||
for (index, found) in views.iter().enumerate() {
|
||||
if index > 0 {
|
||||
say("");
|
||||
}
|
||||
say(render::view(found, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
say(format_args!("matched {} as_of {} next {}", page.matched, page.as_of, page.next.as_deref().unwrap_or("-")));
|
||||
}
|
||||
|
||||
fn admit(settings: &Settings, faculty: Faculty, agent: String, session: String, file: &str) -> Result<u8, Trouble> {
|
||||
let documents = door::documents(&door::file_or_stdin(file)?)?;
|
||||
if documents.is_empty() || documents.len() > BATCH_MAX {
|
||||
return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len())));
|
||||
}
|
||||
let sent = documents.clone();
|
||||
let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) };
|
||||
let request = |instance: &Slug| {
|
||||
HuginnMindRequest::Admit(PipelineAdmissionBatch { instance: instance.clone(), provenance, documents })
|
||||
};
|
||||
match settings.ask(request)? {
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id, committed_at, writes }) => {
|
||||
say(format_args!("Committed {receipt_id} at {committed_at}"));
|
||||
for write in &writes {
|
||||
say(format_args!("wrote {}", write.id.0));
|
||||
}
|
||||
read_back(settings, &sent)
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }) => {
|
||||
say(format_args!("AlreadyAdmitted {receipt_id}"));
|
||||
read_back(settings, &sent)
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => {
|
||||
Ok(refused(&refusal))
|
||||
}
|
||||
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities }) => {
|
||||
say("Conflict");
|
||||
for identity in &identities {
|
||||
say(format_args!("conflicts {}", identity.id.0));
|
||||
}
|
||||
Ok(1)
|
||||
}
|
||||
other => Err(unexpected(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The end-to-end check that the mind kept what was sent: one `keys` query,
|
||||
/// each stored document compared with the one sent. A difference is exit 5,
|
||||
/// not silence, whatever dropped it (an older leaf, a serde default, skew).
|
||||
fn read_back(settings: &Settings, sent: &[PipelineDocument]) -> Result<u8, Trouble> {
|
||||
let keys = sent
|
||||
.iter()
|
||||
.map(|document| pipeline_key(document).map_err(|refusal| Trouble::Internal(format!("a committed document has no key: {refusal:?}"))))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let selection = Selection {
|
||||
keys: Some(keys.clone()),
|
||||
projection: PROJECTION_DOCUMENT.to_string(),
|
||||
limit: Some(u32::try_from(keys.len()).unwrap_or(u32::MAX)),
|
||||
..Selection::default()
|
||||
};
|
||||
let page = match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic: None })? {
|
||||
HuginnMindResponse::Query(page) => page,
|
||||
other => return Err(unexpected(&other)),
|
||||
};
|
||||
let PipelinePageItems::Documents(stored) = page.items else {
|
||||
return Err(Trouble::Internal("the read-back answered headers, not documents".to_string()));
|
||||
};
|
||||
let mut differing = Vec::new();
|
||||
for (key, document) in keys.iter().zip(sent) {
|
||||
let held = stored.iter().find(|found| &found.id.id.0 == key);
|
||||
match held {
|
||||
None => differing.push(format!("{key}: missing from the read-back")),
|
||||
Some(found) if found.document == *document => {}
|
||||
Some(found) => {
|
||||
let (mut paths, sent_value, held_value) = (Vec::new(), to_value(document), to_value(&found.document));
|
||||
differences(&sent_value, &held_value, "", &mut paths);
|
||||
let shown = if paths.is_empty() { "the document".to_string() } else { paths.join(", ") };
|
||||
differing.push(format!("{key}: {shown}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if differing.is_empty() {
|
||||
say(format_args!("read back {} equal", sent.len()));
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(Trouble::ReadBackDiffers(format!(
|
||||
"{}; the batch is committed, so supersede the documents that differ",
|
||||
differing.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(document: &PipelineDocument) -> Value {
|
||||
serde_json::to_value(document).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The field paths at which two documents differ, including a field only one
|
||||
/// of them carries.
|
||||
fn differences(sent: &Value, held: &Value, path: &str, out: &mut Vec<String>) {
|
||||
match (sent, held) {
|
||||
(Value::Object(sent), Value::Object(held)) => {
|
||||
for key in sent.keys().chain(held.keys().filter(|key| !sent.contains_key(*key))) {
|
||||
let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
|
||||
match (sent.get(key), held.get(key)) {
|
||||
(Some(sent), Some(held)) => differences(sent, held, &here, out),
|
||||
_ => out.push(here),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(sent), Value::Array(held)) if sent.len() == held.len() => {
|
||||
for (index, (sent, held)) in sent.iter().zip(held).enumerate() {
|
||||
differences(sent, held, &format!("{path}[{index}]"), out);
|
||||
}
|
||||
}
|
||||
_ if sent == held => {}
|
||||
_ => out.push(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal is the mind's answer: stdout, exit 1.
|
||||
fn refused(refusal: &MindRefusal) -> u8 {
|
||||
say(format_args!("Refused {refusal:?}"));
|
||||
1
|
||||
}
|
||||
|
||||
fn unexpected(answer: &HuginnMindResponse) -> Trouble {
|
||||
let name = match answer {
|
||||
HuginnMindResponse::Whoami(_) => "whoami",
|
||||
HuginnMindResponse::Admit(_) => "admit",
|
||||
HuginnMindResponse::View(_) => "view",
|
||||
HuginnMindResponse::Query(_) => "query",
|
||||
HuginnMindResponse::Refused(_) => "a refusal",
|
||||
HuginnMindResponse::Deferred(_) => "a deferral",
|
||||
};
|
||||
Trouble::Internal(format!("the daemon answered with {name}, which this call did not ask for"))
|
||||
}
|
||||
287
crates/eureka-state/src/bin/huginn/recipes.rs
Normal file
287
crates/eureka-state/src/bin/huginn/recipes.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! 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.
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use huginn_mind::SemanticQuery;
|
||||
use huginn_mind::eureka_pipeline::{Line, PipelineKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::door;
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// The campaign whose `gap-` follow-ups are the substrate's known gaps.
|
||||
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 {
|
||||
/// The `next` cursor of the previous page.
|
||||
#[arg(long)]
|
||||
pub cursor: Option<String>,
|
||||
/// Print each item as `view` does, not as a header line.
|
||||
#[arg(long)]
|
||||
pub documents: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Recipe {
|
||||
/// Every campaign.
|
||||
Campaigns(Paging),
|
||||
/// The target in force.
|
||||
Target {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Rulings in force, operator directions included.
|
||||
Rulings {
|
||||
#[arg(required = true)]
|
||||
campaigns: Vec<String>,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open questions and follow-ups.
|
||||
Open {
|
||||
#[arg(required = true)]
|
||||
campaigns: Vec<String>,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open findings admitted by Soul.
|
||||
Findings {
|
||||
campaign: String,
|
||||
/// Only Blocker and High.
|
||||
#[arg(long)]
|
||||
blocking: bool,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Specs with no report.
|
||||
Unreported {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Open questions raised in a spec: the spec waits on the operator.
|
||||
Blocked {
|
||||
spec_id: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Reports with no verdict: Soul's queue.
|
||||
Unjudged {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// A subject's resolutions, newest first.
|
||||
History {
|
||||
id: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// One cut's spec, reports, verdicts, findings and resolutions.
|
||||
Cut {
|
||||
campaign: String,
|
||||
label: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Every spec and report, whole.
|
||||
Ledger {
|
||||
campaign: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// Rulings, resolutions and findings nearest a question in plain words.
|
||||
Precedent {
|
||||
campaign: String,
|
||||
text: String,
|
||||
#[command(flatten)]
|
||||
page: Paging,
|
||||
},
|
||||
/// The substrate's known gaps.
|
||||
Gaps(Paging),
|
||||
}
|
||||
|
||||
/// What a recipe asks the mind: the selection, the semantic part when it has
|
||||
/// one, and the caller's paging flags.
|
||||
pub struct Ask {
|
||||
pub selection: Value,
|
||||
pub semantic: Option<SemanticQuery>,
|
||||
pub page: Paging,
|
||||
}
|
||||
|
||||
impl Recipe {
|
||||
pub fn ask(self) -> Result<Ask, Trouble> {
|
||||
let (selection, semantic, page) = match self {
|
||||
Self::Campaigns(page) => (campaigns(), None, page),
|
||||
Self::Target { campaign, page } => (target(&campaign), None, page),
|
||||
Self::Rulings { campaigns, page } => (rulings(&campaigns), None, page),
|
||||
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),
|
||||
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),
|
||||
Self::Ledger { campaign, page } => (ledger(&campaign), None, page),
|
||||
Self::Precedent { campaign, text, page } => {
|
||||
(precedent(&campaign), Some(SemanticQuery { text: Line(text), top_k: PRECEDENT_TOP_K }), page)
|
||||
}
|
||||
Self::Gaps(page) => (gaps(), None, page),
|
||||
};
|
||||
Ok(Ask { selection, semantic, page })
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(kind: PipelineKind) -> &'static str {
|
||||
kind.type_id()
|
||||
}
|
||||
|
||||
fn root(campaigns: &[String]) -> Value {
|
||||
json!({ "index": "root", "op": "any_of", "values": campaigns })
|
||||
}
|
||||
|
||||
fn in_force() -> Value {
|
||||
json!({ "index": "in_force", "op": "any_of", "values": ["true"] })
|
||||
}
|
||||
|
||||
fn severity_blocking() -> Value {
|
||||
json!({ "index": "severity", "op": "any_of", "values": BLOCKING })
|
||||
}
|
||||
|
||||
fn campaigns() -> Value {
|
||||
json!({ "schemas": [schema(PipelineKind::Campaign)] })
|
||||
}
|
||||
|
||||
fn target(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Target)],
|
||||
"fields": [root(&[campaign.to_string()]), in_force()],
|
||||
"projection": "document",
|
||||
})
|
||||
}
|
||||
|
||||
fn rulings(campaigns: &[String]) -> Value {
|
||||
json!({ "schemas": [schema(PipelineKind::Ruling)], "fields": [root(campaigns), in_force()] })
|
||||
}
|
||||
|
||||
fn open(campaigns: &[String]) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Question), schema(PipelineKind::FollowUp)],
|
||||
"fields": [root(campaigns), in_force()],
|
||||
})
|
||||
}
|
||||
|
||||
fn findings(campaign: &str, blocking: bool) -> Value {
|
||||
let mut fields = vec![
|
||||
root(&[campaign.to_string()]),
|
||||
in_force(),
|
||||
json!({ "index": "faculty", "op": "any_of", "values": ["Soul"] }),
|
||||
];
|
||||
if blocking {
|
||||
fields.push(severity_blocking());
|
||||
}
|
||||
json!({ "schemas": [schema(PipelineKind::Finding)], "fields": fields })
|
||||
}
|
||||
|
||||
fn unreported(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::CutSpec)],
|
||||
"fields": [root(&[campaign.to_string()]), in_force()],
|
||||
"cited": { "role": "cut_spec", "exists": false },
|
||||
})
|
||||
}
|
||||
|
||||
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)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
"cited": { "role": "cut_report", "exists": false },
|
||||
})
|
||||
}
|
||||
|
||||
fn history(schema_id: &str, id: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Resolution)],
|
||||
"cites": { "target": { "schemaId": schema_id, "recordKey": id }, "role": "subject" },
|
||||
"descending": true,
|
||||
})
|
||||
}
|
||||
|
||||
fn cut(campaign: &str, label: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [
|
||||
schema(PipelineKind::CutSpec),
|
||||
schema(PipelineKind::CutReport),
|
||||
schema(PipelineKind::Verdict),
|
||||
schema(PipelineKind::Finding),
|
||||
schema(PipelineKind::Resolution),
|
||||
],
|
||||
"fields": [root(&[campaign.to_string()]), { "index": "cut", "op": "any_of", "values": [label] }],
|
||||
})
|
||||
}
|
||||
|
||||
fn ledger(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::CutSpec), schema(PipelineKind::CutReport)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
"projection": "document",
|
||||
})
|
||||
}
|
||||
|
||||
fn precedent(campaign: &str) -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::Ruling), schema(PipelineKind::Resolution), schema(PipelineKind::Finding)],
|
||||
"fields": [root(&[campaign.to_string()])],
|
||||
})
|
||||
}
|
||||
|
||||
fn gaps() -> Value {
|
||||
json!({
|
||||
"schemas": [schema(PipelineKind::FollowUp)],
|
||||
"fields": [root(&[GAP_HOME.to_string()]), in_force()],
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn help() -> Vec<(&'static str, String)> {
|
||||
let c = || "<c>".to_string();
|
||||
let selection = |value: Value| format!("selection:\n{}", serde_json::to_string_pretty(&value).unwrap_or_default());
|
||||
let findings_help =
|
||||
format!("{}\n--blocking adds the field predicate:\n{}", selection(findings(&c(), false)), severity_blocking());
|
||||
let precedent_help = format!(
|
||||
"{}\nsemantic: {{ \"text\": \"<text>\", \"top_k\": {PRECEDENT_TOP_K} }}",
|
||||
selection(precedent(&c()))
|
||||
);
|
||||
vec![
|
||||
("campaigns", selection(campaigns())),
|
||||
("target", selection(target(&c()))),
|
||||
("rulings", selection(rulings(&[c()]))),
|
||||
("open", selection(open(&[c()]))),
|
||||
("findings", findings_help),
|
||||
("unreported", selection(unreported(&c()))),
|
||||
("blocked", selection(blocked("<id>"))),
|
||||
("unjudged", selection(unjudged(&c()))),
|
||||
("history", selection(history("epiphany.pipeline.<kind>.v2", "<id>"))),
|
||||
("cut", selection(cut(&c(), "<label>"))),
|
||||
("ledger", selection(ledger(&c()))),
|
||||
("precedent", precedent_help),
|
||||
("gaps", selection(gaps())),
|
||||
]
|
||||
}
|
||||
106
crates/eureka-state/src/bin/huginn/render.rs
Normal file
106
crates/eureka-state/src/bin/huginn/render.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Text for stdout. Display only: nothing here decides an answer.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use huginn_mind::{IndexStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A header line is cut here, so a page of them stays one screen per dozen.
|
||||
const HEADER_MAX_CHARS: usize = 240;
|
||||
|
||||
/// The variant or string a serde-tagged value is spelled as: `Hands` for a
|
||||
/// faculty, `Fixed` for an outcome.
|
||||
pub fn name_of<T: Serialize>(value: &T) -> String {
|
||||
match serde_json::to_value(value) {
|
||||
Ok(Value::String(name)) => name,
|
||||
Ok(Value::Object(fields)) => fields.keys().next().cloned().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An index status as one token and its numbers: `Behind pending=3`.
|
||||
pub fn index_state(index: &IndexStatus) -> String {
|
||||
let mut text = name_of(index);
|
||||
if let Ok(Value::Object(variant)) = serde_json::to_value(index)
|
||||
&& let Some(Value::Object(fields)) = variant.values().next()
|
||||
{
|
||||
for (key, value) in fields {
|
||||
if let Some(shown) = scalar(value) {
|
||||
let _ = write!(text, " {key}={shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// One document as `view` prints it: id and status, the admission facts, then
|
||||
/// the `{kind, value}` document as pretty JSON. `raw` is the JSON alone.
|
||||
pub fn view(view: &PipelineDocumentView, raw: bool) -> String {
|
||||
let document = serde_json::to_string_pretty(&view.document).unwrap_or_default();
|
||||
if raw {
|
||||
return document;
|
||||
}
|
||||
let id = &view.id.id.0;
|
||||
let status = match &view.status {
|
||||
PipelineStatus::InForce => "InForce".to_string(),
|
||||
PipelineStatus::Resolved { resolution, record } => format!("Resolved {} by {}", name_of(&record.outcome), resolution.id.0),
|
||||
};
|
||||
let admission = &view.admission;
|
||||
let who = &admission.provenance;
|
||||
format!(
|
||||
"{id} {status}\nadmitted {} ordinal {} {}/{} session {} tool {} receipt {}\n{document}",
|
||||
admission.admitted_at,
|
||||
admission.ordinal,
|
||||
name_of(&who.faculty),
|
||||
who.agent.0,
|
||||
who.session.0,
|
||||
who.tool.0,
|
||||
admission.receipt_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// One header as a line: `<id> <status> <faculty> <title or label> k=v...`
|
||||
/// over the header's scalar facts.
|
||||
pub fn header(item: &PipelineDocumentSummary) -> String {
|
||||
let status = match &item.status {
|
||||
PipelineStatusSummary::InForce => "InForce".to_string(),
|
||||
PipelineStatusSummary::Resolved { outcome, .. } => format!("Resolved:{}", name_of(outcome)),
|
||||
};
|
||||
let mut line = format!("{} {status} {}", item.id.id.0, name_of(&item.admission.provenance.faculty));
|
||||
if let Ok(facts) = serde_json::to_value(&item.facts)
|
||||
&& let Some(Value::Object(facts)) = facts.get("value")
|
||||
{
|
||||
if let Some(Value::String(name)) = facts.get("title").or_else(|| facts.get("label")) {
|
||||
let _ = write!(line, " {name}");
|
||||
}
|
||||
for (key, value) in facts {
|
||||
if key != "title"
|
||||
&& key != "label"
|
||||
&& let Some(shown) = scalar(value)
|
||||
{
|
||||
let _ = write!(line, " {key}={shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
line.chars().take(HEADER_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// A fact as one token: a string, a number, a flag, a reference's id, or a
|
||||
/// list of those joined by commas. Anything deeper is not a header fact.
|
||||
fn scalar(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) if !text.is_empty() => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(flag) => Some(flag.to_string()),
|
||||
Value::Array(items) => {
|
||||
let shown: Vec<String> = items.iter().filter_map(scalar).collect();
|
||||
if shown.is_empty() { None } else { Some(shown.join(",")) }
|
||||
}
|
||||
Value::Object(fields) => match fields.get("id") {
|
||||
Some(Value::String(id)) => Some(id.clone()),
|
||||
_ => None,
|
||||
},
|
||||
Value::String(_) | Value::Null => None,
|
||||
}
|
||||
}
|
||||
52
crates/eureka-state/src/bin/huginn/schema.rs
Normal file
52
crates/eureka-state/src/bin/huginn/schema.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! `huginn schema`: the published schema of every document kind and of a
|
||||
//! selection, compiled in, so the schema an agent reads is the schema of the
|
||||
//! binary it runs.
|
||||
|
||||
use crate::trouble::Trouble;
|
||||
|
||||
/// CultLib's published shape of a selection, vendored because the selection
|
||||
/// type carries no schema of its own.
|
||||
const SELECTION: &str = include_str!("../../../schemas/cultnet.selection.schema.json");
|
||||
|
||||
macro_rules! kinds {
|
||||
($($name:ident),* $(,)?) => {
|
||||
/// Each kind's file in `schemas/cultnet`, named by `PipelineKind::name`.
|
||||
const KINDS: &[(&str, &str)] = &[$((
|
||||
stringify!($name),
|
||||
include_str!(concat!("../../../../../schemas/cultnet/epiphany.pipeline.", stringify!($name), ".v2.schema.json")),
|
||||
)),*];
|
||||
};
|
||||
}
|
||||
|
||||
kinds!(
|
||||
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship,
|
||||
hand_off, run,
|
||||
);
|
||||
|
||||
/// The schema named, or the names when none is: the exit code is 2 for a name
|
||||
/// that is neither a kind nor `selection`.
|
||||
pub fn text(name: Option<&str>) -> Result<String, Trouble> {
|
||||
match name {
|
||||
None => Ok(KINDS.iter().map(|(kind, _)| *kind).chain(["selection"]).map(|kind| format!("{kind}\n")).collect()),
|
||||
Some("selection") => Ok(SELECTION.to_string()),
|
||||
Some(name) => KINDS
|
||||
.iter()
|
||||
.find(|(kind, _)| *kind == name)
|
||||
.map(|(_, schema)| (*schema).to_string())
|
||||
.ok_or_else(|| Trouble::invalid("no such schema; `huginn schema` lists the names")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use huginn_mind::eureka_pipeline::PipelineKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kind_has_exactly_its_schema() {
|
||||
let names: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
|
||||
let kinds: Vec<&str> = PipelineKind::ALL.iter().map(|kind| kind.name()).collect();
|
||||
assert_eq!(names, kinds);
|
||||
}
|
||||
}
|
||||
79
crates/eureka-state/src/bin/huginn/trouble.rs
Normal file
79
crates/eureka-state/src/bin/huginn/trouble.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//! Why a call produced no answer, and the exit code each reason owns.
|
||||
//!
|
||||
//! Exit codes: 2 the call is wrong (usage, InvalidInput, TooLarge,
|
||||
//! Misconfigured); 3 Unavailable (retry once); 4 no answer and a retry cannot
|
||||
//! help (Rejected, Unencodable, Internal); 5 ReadBackDiffers. Exit 0 and 1 are
|
||||
//! answers (the mind said yes, the mind said no) and are not troubles.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use eureka_state::ClientError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Trouble {
|
||||
Misconfigured(String),
|
||||
InvalidInput(String),
|
||||
TooLarge { bytes: usize, limit: usize },
|
||||
Unavailable(String),
|
||||
Rejected(String),
|
||||
Unencodable(String),
|
||||
Internal(String),
|
||||
ReadBackDiffers(String),
|
||||
}
|
||||
|
||||
impl Trouble {
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Misconfigured(_) => "Misconfigured",
|
||||
Self::InvalidInput(_) => "InvalidInput",
|
||||
Self::TooLarge { .. } => "TooLarge",
|
||||
Self::Unavailable(_) => "Unavailable",
|
||||
Self::Rejected(_) => "Rejected",
|
||||
Self::Unencodable(_) => "Unencodable",
|
||||
Self::Internal(_) => "Internal",
|
||||
Self::ReadBackDiffers(_) => "ReadBackDiffers",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit(&self) -> u8 {
|
||||
match self {
|
||||
Self::Misconfigured(_) | Self::InvalidInput(_) | Self::TooLarge { .. } => 2,
|
||||
Self::Unavailable(_) => 3,
|
||||
Self::Rejected(_) | Self::Unencodable(_) | Self::Internal(_) => 4,
|
||||
Self::ReadBackDiffers(_) => 5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(detail: impl Into<String>) -> Self {
|
||||
Self::InvalidInput(detail.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Trouble {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: ", self.code())?;
|
||||
match self {
|
||||
Self::Misconfigured(detail)
|
||||
| Self::InvalidInput(detail)
|
||||
| Self::Unavailable(detail)
|
||||
| Self::Rejected(detail)
|
||||
| Self::Unencodable(detail)
|
||||
| Self::Internal(detail)
|
||||
| Self::ReadBackDiffers(detail) => f.write_str(detail),
|
||||
Self::TooLarge { bytes, limit } => {
|
||||
write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientError> for Trouble {
|
||||
fn from(error: ClientError) -> Self {
|
||||
match error {
|
||||
ClientError::Unavailable { endpoint, detail } => Self::Unavailable(format!("rudp://{endpoint}: {detail}")),
|
||||
ClientError::Rejected { endpoint, code, detail } => Self::Rejected(format!("rudp://{endpoint}: {code}: {detail}")),
|
||||
ClientError::TooLarge { bytes, limit } => Self::TooLarge { bytes, limit },
|
||||
ClientError::Unencodable { detail } => Self::Unencodable(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
951
crates/eureka-state/tests/cli.rs
Normal file
951
crates/eureka-state/tests/cli.rs
Normal file
|
|
@ -0,0 +1,951 @@
|
|||
//! The `huginn` binary as an agent drives it: a real process, its stdout, its
|
||||
//! stderr and its exit code, against a real daemon in-process or a scripted
|
||||
//! server where the daemon cannot be made to misbehave.
|
||||
|
||||
use std::io::Write;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cultnet_rs::Selection;
|
||||
use huginn_mind::envelope::{OperationFailure, decode_request, encode_failure, encode_response};
|
||||
use huginn_mind::eureka_pipeline::{
|
||||
CodeLocation, CommitRange, CutVerification, Date, Evidence, EvidenceKind, MutationRecord, OrgRepo, PipelineCutReport,
|
||||
PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, PipelineRuling, Promise, ReportCommit, RulingAuthority, Short,
|
||||
StructuralDelta, pipeline_key,
|
||||
};
|
||||
use huginn_mind::{
|
||||
AdmissionFacts, HuginnMindRequest, HuginnMindResponse, IndexStatus, MindStatus, PipelineAdmissionOutcome, PipelineDocumentView,
|
||||
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatus,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
struct Run {
|
||||
code: i32,
|
||||
out: String,
|
||||
err: String,
|
||||
}
|
||||
|
||||
fn run(env: &[(&str, &str)], args: &[&str], stdin: Option<&str>) -> Run {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_huginn"));
|
||||
command
|
||||
.env_remove("EUREKA_INSTANCE")
|
||||
.env_remove("HUGINN_ENDPOINT")
|
||||
.envs(env.iter().copied())
|
||||
.args(args)
|
||||
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn().expect("the binary starts");
|
||||
if let Some(text) = stdin {
|
||||
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
let output = child.wait_with_output().unwrap();
|
||||
Run {
|
||||
code: output.status.code().expect("exited, not killed"),
|
||||
out: String::from_utf8(output.stdout).unwrap(),
|
||||
err: String::from_utf8(output.stderr).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured for the test instance against a daemon at `addr`.
|
||||
fn at(addr: SocketAddr, args: &[&str], stdin: Option<&str>) -> Run {
|
||||
run(&[("EUREKA_INSTANCE", INSTANCE), ("HUGINN_ENDPOINT", &format!("rudp://{addr}"))], args, stdin)
|
||||
}
|
||||
|
||||
/// A loopback UDP port nothing listens on.
|
||||
fn closed_port() -> SocketAddr {
|
||||
UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap()
|
||||
}
|
||||
|
||||
fn write(dir: &TempDir, name: &str, value: &Value) -> String {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, value.to_string()).unwrap();
|
||||
path.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn campaign_json() -> Value {
|
||||
serde_json::to_value(campaign()).unwrap()
|
||||
}
|
||||
|
||||
fn live() -> Server {
|
||||
let (root, daemon) = mind(vec![vec![stewardship()]]);
|
||||
serve(root, daemon)
|
||||
}
|
||||
|
||||
fn admit_args(file: &str) -> Vec<&str> {
|
||||
vec!["admit", "--faculty", "Hands", "--agent", "claude", "--session", "session-7", file]
|
||||
}
|
||||
|
||||
fn delta() -> StructuralDelta {
|
||||
StructuralDelta {
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
dependencies_added: vec![],
|
||||
dependencies_removed: vec![],
|
||||
formats_added: vec![],
|
||||
formats_removed: vec![],
|
||||
targets_added: vec![],
|
||||
targets_removed: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(cut: &str) -> Value {
|
||||
serde_json::to_value(PipelineDocument::CutSpec(PipelineCutSpec {
|
||||
campaign: slug(CAMPAIGN),
|
||||
cut: cut.into(),
|
||||
revision: 1,
|
||||
title: "A cut".into(),
|
||||
repo: OrgRepo(REPO.into()),
|
||||
branch: "codex/eureka-pipeline-state".into(),
|
||||
base: sha(),
|
||||
depends_on: vec![],
|
||||
first: vec![],
|
||||
deletes: vec![],
|
||||
keeps_moves: vec![],
|
||||
adds: vec![],
|
||||
file_changes: vec![],
|
||||
reads: vec![],
|
||||
authority_map: None,
|
||||
verification: CutVerification { builds: vec![], tests: vec![], negative: vec![], operator: vec![] },
|
||||
estimate: delta(),
|
||||
rulings: vec![],
|
||||
questions: vec![],
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn report(cut: &str) -> Value {
|
||||
serde_json::to_value(PipelineDocument::CutReport(PipelineCutReport {
|
||||
campaign: slug(CAMPAIGN),
|
||||
cut_spec: Short(format!("{CAMPAIGN}:cut_spec:cut-{cut}.r1")),
|
||||
attempt: 1,
|
||||
repo: OrgRepo(REPO.into()),
|
||||
branch: "codex/eureka-pipeline-state".into(),
|
||||
commits: vec![ReportCommit { sha: sha(), subject: "Land the cut".into(), builds: true }],
|
||||
range: CommitRange { base: sha(), head: sha() },
|
||||
verification: vec![Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() }],
|
||||
mutations: vec![MutationRecord {
|
||||
label: "M1".into(),
|
||||
rule: "the rule".into(),
|
||||
location: CodeLocation { path: Short("crates/x.rs".into()), line: 1, end_line: None, symbol: None },
|
||||
before: "before".into(),
|
||||
after: "after".into(),
|
||||
commit: sha(),
|
||||
failed_as_expected: true,
|
||||
}],
|
||||
deviations: vec![],
|
||||
forks: vec![],
|
||||
structural_delta: delta(),
|
||||
landed_names: vec![],
|
||||
undone: vec![],
|
||||
promises: vec![Promise { label: "P1".into(), text: "One derived key per document.".into() }],
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn ruling(quote: Option<&str>) -> PipelineDocument {
|
||||
PipelineDocument::Ruling(PipelineRuling {
|
||||
campaign: slug(CAMPAIGN),
|
||||
label: "R1".into(),
|
||||
title: "An instance owns its mind".into(),
|
||||
answers: None,
|
||||
choice: None,
|
||||
ruling: "An instance owns its mind.".into(),
|
||||
operator_quote: quote.map(Into::into),
|
||||
ruled_on: Date("2026-10-10".into()),
|
||||
precedents: vec![],
|
||||
authority: RulingAuthority::Standing,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_page() -> PipelineSelectionPage {
|
||||
PipelineSelectionPage { matched: 0, as_of: 1, next: None, items: PipelinePageItems::Headers(vec![]), edges: None }
|
||||
}
|
||||
|
||||
fn answer(operation: &str, response: &HuginnMindResponse) -> cultnet_rs::CultNetMessage {
|
||||
encode_response("eureka-state-call", operation, response, "scripted").unwrap()
|
||||
}
|
||||
|
||||
/// A scripted server that records every request it is sent and answers a
|
||||
/// query with an empty page.
|
||||
fn recording() -> (Server, Arc<Mutex<Vec<HuginnMindRequest>>>) {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&seen);
|
||||
let server = scripted(move |message| {
|
||||
let (_, request) = decode_request(message).expect("a mind request");
|
||||
sink.lock().unwrap().push(request);
|
||||
answer("query", &HuginnMindResponse::Query(empty_page()))
|
||||
});
|
||||
(server, seen)
|
||||
}
|
||||
|
||||
/// The selection a command sent, and its semantic part.
|
||||
fn sent(args: &[&str]) -> (Selection, Option<(String, u32)>) {
|
||||
let (server, seen) = recording();
|
||||
let ran = at(server.addr, args, None);
|
||||
assert_eq!(ran.code, 0, "{args:?}: {}", ran.err);
|
||||
assert!(ran.out.contains("matched 0 as_of 1 next -"), "{}", ran.out);
|
||||
let seen = seen.lock().unwrap();
|
||||
let [HuginnMindRequest::Query { instance, selection, semantic }] = &seen[..] else {
|
||||
panic!("one query expected: {seen:?}");
|
||||
};
|
||||
assert_eq!(instance.0, INSTANCE);
|
||||
(selection.clone(), semantic.as_ref().map(|asked| (asked.text.0.clone(), asked.top_k)))
|
||||
}
|
||||
|
||||
/// The page's literal as the selection it spells, with the CLI's default page.
|
||||
fn page_literal(literal: Value) -> Selection {
|
||||
let mut selection: Selection = serde_json::from_value(literal).unwrap();
|
||||
selection.limit = Some(50);
|
||||
selection
|
||||
}
|
||||
|
||||
fn counting(replies: HuginnMindResponse, operation: &'static str) -> (Server, Arc<Mutex<usize>>) {
|
||||
let count = Arc::new(Mutex::new(0));
|
||||
let counter = Arc::clone(&count);
|
||||
let server = scripted(move |_| {
|
||||
*counter.lock().unwrap() += 1;
|
||||
answer(operation, &replies)
|
||||
});
|
||||
(server, count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_configuration_whoami_reports_it_and_every_other_command_refuses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
|
||||
let whoami = run(&[], &["whoami"], None);
|
||||
assert_eq!(whoami.code, 2);
|
||||
assert!(whoami.out.contains("instance unset\nendpoint unset\nclient "), "{}", whoami.out);
|
||||
assert!(whoami.out.contains("reachable no"), "{}", whoami.out);
|
||||
assert!(whoami.err.starts_with("Misconfigured: EUREKA_INSTANCE is not set"), "{}", whoami.err);
|
||||
|
||||
for args in [
|
||||
vec!["view", "eureka-state:campaign:self"],
|
||||
vec!["query", "{}"],
|
||||
vec!["rulings", "eureka-state"],
|
||||
admit_args(&file),
|
||||
] {
|
||||
let ran = run(&[], &args, None);
|
||||
assert_eq!(ran.code, 2, "{args:?}");
|
||||
assert!(ran.err.starts_with("Misconfigured:"), "{args:?}: {}", ran.err);
|
||||
assert_eq!(ran.out, "", "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_environment_is_misconfigured_with_its_own_reason() {
|
||||
let cases: [(&[(&str, &str)], &str); 4] = [
|
||||
(&[("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not set"),
|
||||
(&[("EUREKA_INSTANCE", "Not A Slug"), ("HUGINN_ENDPOINT", "rudp://127.0.0.1:1")], "EUREKA_INSTANCE is not a valid"),
|
||||
(&[("EUREKA_INSTANCE", "eureka")], "HUGINN_ENDPOINT is not set"),
|
||||
(&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "127.0.0.1:1")], "HUGINN_ENDPOINT is not rudp://"),
|
||||
];
|
||||
for (env, expected) in cases {
|
||||
let ran = run(env, &["whoami"], None);
|
||||
assert_eq!(ran.code, 2, "{expected}");
|
||||
assert!(ran.err.starts_with("Misconfigured: "), "{}", ran.err);
|
||||
assert!(ran.err.contains(expected), "{expected}: {}", ran.err);
|
||||
}
|
||||
let named = run(&[("EUREKA_INSTANCE", "eureka"), ("HUGINN_ENDPOINT", "rudp://not-an-address")], &["whoami"], None);
|
||||
assert_eq!(named.code, 2);
|
||||
assert!(named.out.contains("endpoint rudp://not-an-address"), "{}", named.out);
|
||||
assert!(named.out.contains("instance eureka"), "{}", named.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whoami_reads_the_live_mind() {
|
||||
let server = live();
|
||||
let ran = at(server.addr, &["whoami"], None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
let lines: Vec<&str> = ran.out.lines().collect();
|
||||
assert_eq!(lines[0], format!("instance {INSTANCE}"));
|
||||
assert_eq!(lines[1], format!("endpoint rudp://{}", server.addr));
|
||||
assert!(lines[2].starts_with("client "), "{lines:?}");
|
||||
assert_eq!(lines[3], "reachable yes");
|
||||
assert_eq!(lines[4], "documents 2 receipts 2 index Current epoch epiphany.pipeline.epoch.v2");
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert_eq!(ran.err, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_that_is_behind_shows_its_count() {
|
||||
let status = MindStatus {
|
||||
instance: slug(INSTANCE),
|
||||
schema_epoch: "epoch".into(),
|
||||
documents: 7,
|
||||
receipts: 9,
|
||||
index: IndexStatus::Behind { pending: 3 },
|
||||
};
|
||||
let (server, _) = counting(HuginnMindResponse::Whoami(status), "whoami");
|
||||
let ran = at(server.addr, &["whoami"], None);
|
||||
assert_eq!(ran.code, 0);
|
||||
assert!(ran.out.contains("documents 7 receipts 9 index Behind pending=3 epoch epoch"), "{}", ran.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admit_round_trips_and_is_idempotent() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
|
||||
let first = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(first.code, 0, "{}", first.err);
|
||||
let lines: Vec<&str> = first.out.lines().collect();
|
||||
assert!(lines[0].starts_with("Committed mind-commit-"), "{}", first.out);
|
||||
assert!(lines[0].contains(" at "), "{}", first.out);
|
||||
assert_eq!(lines[1], format!("wrote {CAMPAIGN}:campaign:self"));
|
||||
assert_eq!(lines[2], "read back 1 equal");
|
||||
assert_eq!(lines.len(), 3, "{}", first.out);
|
||||
|
||||
let again = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(again.code, 0, "{}", again.err);
|
||||
let lines: Vec<&str> = again.out.lines().collect();
|
||||
assert!(lines[0].starts_with("AlreadyAdmitted mind-commit-"), "{}", again.out);
|
||||
assert_eq!(lines[1], "read back 1 equal");
|
||||
|
||||
let id = format!("{CAMPAIGN}:campaign:self");
|
||||
let viewed = at(server.addr, &["view", &id], None);
|
||||
assert_eq!(viewed.code, 0, "{}", viewed.err);
|
||||
let lines: Vec<&str> = viewed.out.lines().collect();
|
||||
assert_eq!(lines[0], format!("{id} InForce"));
|
||||
assert!(lines[1].starts_with("admitted "), "{}", viewed.out);
|
||||
assert!(lines[1].contains(" ordinal 3 Hands/claude session session-7 tool huginn receipt mind-commit-"), "{}", lines[1]);
|
||||
let shown: Value = serde_json::from_str(&lines[2..].join("\n")).unwrap();
|
||||
assert_eq!(shown, campaign_json());
|
||||
|
||||
let raw = at(server.addr, &["view", &id, "--raw"], None);
|
||||
assert_eq!(raw.code, 0);
|
||||
assert_eq!(serde_json::from_str::<Value>(&raw.out).unwrap(), campaign_json());
|
||||
assert!(raw.out.starts_with('{'), "{}", raw.out);
|
||||
|
||||
let fed_back = at(server.addr, &admit_args("-"), Some(&raw.out));
|
||||
assert_eq!(fed_back.code, 0, "{}", fed_back.err);
|
||||
assert!(fed_back.out.starts_with("AlreadyAdmitted "), "{}", fed_back.out);
|
||||
|
||||
let status = at(server.addr, &["whoami"], None);
|
||||
assert!(status.out.contains("documents 3 receipts 3"), "{}", status.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_refused_by_path_and_never_by_value() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut nested = campaign_json();
|
||||
nested["value"]["target_doc"]["pathh"] = json!("CANARY-NESTED");
|
||||
let mut top = campaign_json();
|
||||
top["surplus"] = json!("CANARY-TOP");
|
||||
let file = write(&dir, "bad.json", &json!([nested, top]));
|
||||
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 2);
|
||||
assert_eq!(ran.out, "");
|
||||
assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err);
|
||||
assert!(ran.err.contains("documents[0].value.target_doc.pathh"), "{}", ran.err);
|
||||
assert!(ran.err.contains("documents[1].surplus"), "{}", ran.err);
|
||||
assert!(!ran.err.contains("CANARY"), "{}", ran.err);
|
||||
|
||||
let status = at(server.addr, &["whoami"], None);
|
||||
assert!(status.out.contains("receipts 2 "), "nothing was sent: {}", status.out);
|
||||
|
||||
let selection = at(server.addr, &["query", r#"{"limt": 5}"#], None);
|
||||
assert_eq!(selection.code, 2);
|
||||
assert!(selection.err.contains("selection.limt"), "{}", selection.err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicitly_empty_unknown_key_is_not_a_loss() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut doc = campaign_json();
|
||||
doc["value"]["ghost"] = Value::Null;
|
||||
doc["value"]["ghosts"] = json!([]);
|
||||
doc["value"]["ghost_map"] = json!({});
|
||||
doc["value"]["ghost_text"] = json!("");
|
||||
let file = write(&dir, "ghosts.json", &json!([doc]));
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
assert!(ran.out.contains("read back 1 equal"), "{}", ran.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_falsy_unknown_value_is_still_a_loss() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut doc = campaign_json();
|
||||
doc["value"]["zero"] = json!(0);
|
||||
doc["value"]["no"] = json!(false);
|
||||
let file = write(&dir, "falsy.json", &json!([doc]));
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 2);
|
||||
assert!(ran.err.contains("documents[0].value.zero") && ran.err.contains("documents[0].value.no"), "{}", ran.err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_that_does_not_decode_is_invalid_input() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cases = [
|
||||
(json!([{ "kind": "nonsense", "value": {} }]), "documents[0] does not decode"),
|
||||
(json!("text"), "JSON array"),
|
||||
(json!([]), "1 to 64"),
|
||||
(json!((0..65).map(|_| campaign_json()).collect::<Vec<_>>()), "1 to 64"),
|
||||
];
|
||||
for (n, (value, expected)) in cases.into_iter().enumerate() {
|
||||
let file = write(&dir, &format!("case{n}.json"), &value);
|
||||
let ran = at(closed_port(), &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 2, "{expected}: {}", ran.err);
|
||||
assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err);
|
||||
assert!(ran.err.contains(expected), "{expected}: {}", ran.err);
|
||||
}
|
||||
let broken = at(closed_port(), &admit_args("-"), Some("{ not json"));
|
||||
assert_eq!(broken.code, 2);
|
||||
assert!(broken.err.contains("not JSON"), "{}", broken.err);
|
||||
let missing = at(closed_port(), &admit_args("/no/such/file.json"), None);
|
||||
assert_eq!(missing.code, 2);
|
||||
assert!(missing.err.contains("cannot read /no/such/file.json"), "{}", missing.err);
|
||||
}
|
||||
|
||||
fn view_of(document: &PipelineDocument) -> PipelineDocumentView {
|
||||
PipelineDocumentView {
|
||||
id: PipelineRef { kind: document.kind(), id: Short(pipeline_key(document).unwrap()) },
|
||||
document: document.clone(),
|
||||
admission: AdmissionFacts {
|
||||
receipt_id: "mind-commit-x".into(),
|
||||
admitted_at: "2026-10-10T00:00:00Z".into(),
|
||||
provenance: PipelineProvenance {
|
||||
faculty: huginn_mind::Faculty::Hands,
|
||||
agent: Short("a".into()),
|
||||
session: Short("s".into()),
|
||||
tool: Short("huginn".into()),
|
||||
},
|
||||
ordinal: 1,
|
||||
},
|
||||
status: PipelineStatus::InForce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admits `Committed` and answers the keys query with `stored`.
|
||||
fn committing(stored: Vec<PipelineDocumentView>) -> Server {
|
||||
scripted(move |message| {
|
||||
let (_, request) = decode_request(message).expect("a mind request");
|
||||
match request {
|
||||
HuginnMindRequest::Admit(_) => answer(
|
||||
"admit",
|
||||
&HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed {
|
||||
receipt_id: "mind-commit-x".into(),
|
||||
committed_at: "2026-10-10T00:00:00Z".into(),
|
||||
writes: vec![],
|
||||
}),
|
||||
),
|
||||
HuginnMindRequest::Query { selection, .. } => {
|
||||
assert_eq!(selection.projection, "document");
|
||||
assert_eq!(selection.limit, Some(u32::try_from(selection.keys.as_ref().unwrap().len()).unwrap()));
|
||||
let page = PipelineSelectionPage {
|
||||
matched: u32::try_from(stored.len()).unwrap(),
|
||||
as_of: 2,
|
||||
next: None,
|
||||
items: PipelinePageItems::Documents(stored.clone()),
|
||||
edges: None,
|
||||
};
|
||||
answer("query", &HuginnMindResponse::Query(page))
|
||||
}
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_the_mind_dropped_is_reported_with_the_receipt_printed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sent = ruling(Some("CANARY-QUOTE"));
|
||||
let file = write(&dir, "ruling.json", &json!([sent]));
|
||||
let key = pipeline_key(&sent).unwrap();
|
||||
|
||||
let server = committing(vec![view_of(&ruling(None))]);
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 5);
|
||||
assert!(ran.out.starts_with("Committed mind-commit-x at 2026-10-10T00:00:00Z"), "{}", ran.out);
|
||||
assert!(!ran.out.contains("read back"), "{}", ran.out);
|
||||
assert!(ran.err.starts_with(&format!("ReadBackDiffers: {key}: value.operator_quote;")), "{}", ran.err);
|
||||
assert!(ran.err.contains("supersede"), "{}", ran.err);
|
||||
|
||||
let missing = committing(vec![]);
|
||||
let ran = at(missing.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 5);
|
||||
assert!(ran.err.contains(&format!("{key}: missing from the read-back")), "{}", ran.err);
|
||||
|
||||
let equal = committing(vec![view_of(&sent)]);
|
||||
let ran = at(equal.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
assert!(ran.out.ends_with("read back 1 equal\n"), "{}", ran.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_difference_deep_in_a_list_names_its_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sent = serde_json::from_value::<PipelineDocument>(spec("A")).unwrap();
|
||||
let mut stored = sent.clone();
|
||||
if let PipelineDocument::CutSpec(value) = &mut stored {
|
||||
value.rulings = vec![Short("x:ruling:R1".into())];
|
||||
value.title = "Another".into();
|
||||
}
|
||||
let file = write(&dir, "spec.json", &json!([sent]));
|
||||
let server = committing(vec![view_of(&stored)]);
|
||||
let ran = at(server.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 5);
|
||||
assert!(ran.err.contains("value.rulings[0]") || ran.err.contains("value.rulings"), "{}", ran.err);
|
||||
assert!(ran.err.contains("value.title"), "{}", ran.err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_follow_the_answer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
|
||||
// Nothing listening: Unavailable, naming the endpoint, whoami included.
|
||||
let port = closed_port();
|
||||
let down = at(port, &["view", "eureka-state:campaign:self"], None);
|
||||
assert_eq!(down.code, 3);
|
||||
assert!(down.err.starts_with("Unavailable: "), "{}", down.err);
|
||||
assert!(down.err.contains(&format!("rudp://{port}")), "{}", down.err);
|
||||
let whoami = at(port, &["whoami"], None);
|
||||
assert_eq!(whoami.code, 3);
|
||||
assert!(whoami.out.contains("reachable no"), "{}", whoami.out);
|
||||
|
||||
// The daemon rejecting the envelope.
|
||||
let rejecting = scripted(|_| {
|
||||
encode_failure(
|
||||
"eureka-state-call",
|
||||
"view",
|
||||
&OperationFailure { code: "wrong-service".into(), message: "not huginn.mind".into() },
|
||||
"scripted",
|
||||
)
|
||||
});
|
||||
let rejected = at(rejecting.addr, &["view", "eureka-state:campaign:self"], None);
|
||||
assert_eq!(rejected.code, 4);
|
||||
assert!(rejected.err.starts_with("Rejected: "), "{}", rejected.err);
|
||||
assert!(rejected.err.contains("wrong-service"), "{}", rejected.err);
|
||||
|
||||
// An answer of another operation's shape.
|
||||
let status = MindStatus {
|
||||
instance: slug(INSTANCE),
|
||||
schema_epoch: "e".into(),
|
||||
documents: 0,
|
||||
receipts: 0,
|
||||
index: IndexStatus::Current,
|
||||
};
|
||||
let (odd, _) = counting(HuginnMindResponse::Whoami(status), "view");
|
||||
let internal = at(odd.addr, &["view", "eureka-state:campaign:self"], None);
|
||||
assert_eq!(internal.code, 4);
|
||||
assert!(internal.err.starts_with("Internal: "), "{}", internal.err);
|
||||
|
||||
// A batch no single send carries, refused before sending.
|
||||
let mut huge = campaign_json();
|
||||
huge["value"]["working_branch"] = json!("b".repeat(90_000));
|
||||
let big = write(&dir, "huge.json", &json!([huge]));
|
||||
let too_large = at(closed_port(), &admit_args(&big), None);
|
||||
assert_eq!(too_large.code, 2);
|
||||
assert!(too_large.err.starts_with("TooLarge: "), "{}", too_large.err);
|
||||
assert!(too_large.err.contains(&eureka_state::MAX_REQUEST_BYTES.to_string()), "{}", too_large.err);
|
||||
|
||||
// The mind saying no: a refusal is an answer on stdout.
|
||||
let live = live();
|
||||
let refused = at(live.addr, &["admit", "--faculty", "Hands", "--agent", "a", "--session", "", &file], None);
|
||||
assert_eq!(refused.code, 1);
|
||||
assert!(refused.out.starts_with("Refused "), "{}", refused.out);
|
||||
assert!(refused.out.contains("provenance.session"), "{}", refused.out);
|
||||
assert_eq!(refused.err, "");
|
||||
|
||||
// Absent: the mind answered null.
|
||||
let absent = at(live.addr, &["view", "eureka-state:cut_spec:cut-99.r1"], None);
|
||||
assert_eq!(absent.code, 1);
|
||||
assert_eq!(absent.out, "absent eureka-state:cut_spec:cut-99.r1\n");
|
||||
|
||||
// Another instance's mind: refused on the read side too.
|
||||
let foreign = run(
|
||||
&[("EUREKA_INSTANCE", "thought-cage"), ("HUGINN_ENDPOINT", &format!("rudp://{}", live.addr))],
|
||||
&["query", "{}"],
|
||||
None,
|
||||
);
|
||||
assert_eq!(foreign.code, 1);
|
||||
assert!(foreign.out.starts_with("Refused "), "{}", foreign.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_that_is_wrong_exits_two_without_sending() {
|
||||
let (server, count) = counting(HuginnMindResponse::View(None), "view");
|
||||
for args in [
|
||||
vec!["view", "eureka-state:nonsense:x"],
|
||||
vec!["view", "no-kind-segment"],
|
||||
vec!["history", "eureka-state:nonsense:x"],
|
||||
vec!["query", "{ not json"],
|
||||
vec!["query", "[]"],
|
||||
vec!["admit", "--faculty", "Nope", "--agent", "a", "--session", "s", "x.json"],
|
||||
vec!["schema", "no-such-kind"],
|
||||
vec!["bogus"],
|
||||
vec!["view"],
|
||||
vec![],
|
||||
] {
|
||||
let ran = at(server.addr, &args, None);
|
||||
assert_eq!(ran.code, 2, "{args:?}");
|
||||
assert!(ran.err.starts_with("InvalidInput: "), "{args:?}: {}", ran.err);
|
||||
assert_eq!(ran.out, "", "{args:?}");
|
||||
}
|
||||
assert_eq!(*count.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recipes_send_exactly_the_pages_selections() {
|
||||
let schema = |kind: &str| format!("epiphany.pipeline.{kind}.v2");
|
||||
let root = |values: &[&str]| json!({ "index": "root", "op": "any_of", "values": values });
|
||||
let in_force = json!({ "index": "in_force", "op": "any_of", "values": ["true"] });
|
||||
let c = "eureka-substrate";
|
||||
|
||||
let cases = [
|
||||
(vec!["campaigns"], json!({ "schemas": [schema("campaign")] })),
|
||||
(
|
||||
vec!["target", c],
|
||||
json!({ "schemas": [schema("target")], "fields": [root(&[c]), in_force], "projection": "document" }),
|
||||
),
|
||||
(
|
||||
vec!["rulings", c, "eureka-body"],
|
||||
json!({ "schemas": [schema("ruling")], "fields": [root(&[c, "eureka-body"]), in_force] }),
|
||||
),
|
||||
(
|
||||
vec!["open", c],
|
||||
json!({ "schemas": [schema("question"), schema("follow_up")], "fields": [root(&[c]), in_force] }),
|
||||
),
|
||||
(
|
||||
vec!["findings", c],
|
||||
json!({
|
||||
"schemas": [schema("finding")],
|
||||
"fields": [root(&[c]), in_force, { "index": "faculty", "op": "any_of", "values": ["Soul"] }],
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["findings", c, "--blocking"],
|
||||
json!({
|
||||
"schemas": [schema("finding")],
|
||||
"fields": [
|
||||
root(&[c]),
|
||||
in_force,
|
||||
{ "index": "faculty", "op": "any_of", "values": ["Soul"] },
|
||||
{ "index": "severity", "op": "any_of", "values": ["Blocker", "High"] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["unreported", c],
|
||||
json!({
|
||||
"schemas": [schema("cut_spec")],
|
||||
"fields": [root(&[c]), in_force],
|
||||
"cited": { "role": "cut_spec", "exists": false },
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["blocked", "eureka-substrate:cut_spec:cut-x.r1"],
|
||||
json!({
|
||||
"schemas": [schema("question")],
|
||||
"fields": [in_force],
|
||||
"cites": {
|
||||
"target": { "schemaId": schema("cut_spec"), "recordKey": "eureka-substrate:cut_spec:cut-x.r1" },
|
||||
"role": "raised_in",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["unjudged", c],
|
||||
json!({
|
||||
"schemas": [schema("cut_report")],
|
||||
"fields": [root(&[c])],
|
||||
"cited": { "role": "cut_report", "exists": false },
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["history", "eureka-substrate:ruling:R1"],
|
||||
json!({
|
||||
"schemas": [schema("resolution")],
|
||||
"cites": { "target": { "schemaId": schema("ruling"), "recordKey": "eureka-substrate:ruling:R1" }, "role": "subject" },
|
||||
"descending": true,
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["cut", c, "huginn-cli"],
|
||||
json!({
|
||||
"schemas": [schema("cut_spec"), schema("cut_report"), schema("verdict"), schema("finding"), schema("resolution")],
|
||||
"fields": [root(&[c]), { "index": "cut", "op": "any_of", "values": ["huginn-cli"] }],
|
||||
}),
|
||||
),
|
||||
(
|
||||
vec!["ledger", c],
|
||||
json!({ "schemas": [schema("cut_spec"), schema("cut_report")], "fields": [root(&[c])], "projection": "document" }),
|
||||
),
|
||||
(
|
||||
vec!["precedent", c, "the question in plain words"],
|
||||
json!({ "schemas": [schema("ruling"), schema("resolution"), schema("finding")], "fields": [root(&[c])] }),
|
||||
),
|
||||
(
|
||||
vec!["gaps"],
|
||||
json!({ "schemas": [schema("follow_up")], "fields": [root(&["eureka-substrate"]), in_force] }),
|
||||
),
|
||||
];
|
||||
for (args, literal) in cases {
|
||||
let (selection, semantic) = sent(&args);
|
||||
assert_eq!(selection, page_literal(literal), "{args:?}");
|
||||
let expected = (args[0] == "precedent").then(|| ("the question in plain words".to_string(), 10));
|
||||
assert_eq!(semantic, expected, "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paging_and_query_flags_shape_the_selection() {
|
||||
let (selection, semantic) = sent(&["gaps", "--cursor", "abc", "--documents"]);
|
||||
assert_eq!(selection.cursor.as_deref(), Some("abc"));
|
||||
assert_eq!(selection.projection, "document");
|
||||
assert_eq!(selection.limit, Some(50));
|
||||
assert_eq!(semantic, None);
|
||||
|
||||
let (selection, _) = sent(&["gaps"]);
|
||||
assert_eq!(selection.cursor, None);
|
||||
assert_eq!(selection.projection, "header");
|
||||
|
||||
let (selection, semantic) = sent(&["query", "{}", "--near", "drift", "--top-k", "7"]);
|
||||
assert_eq!(selection, page_literal(json!({})));
|
||||
assert_eq!(semantic, Some(("drift".to_string(), 7)));
|
||||
let (_, semantic) = sent(&["query", "{}", "--near", "drift"]);
|
||||
assert_eq!(semantic, Some(("drift".to_string(), 10)));
|
||||
|
||||
let (selection, _) = sent(&["query", r#"{"limit": 5, "descending": true}"#]);
|
||||
assert_eq!(selection.limit, Some(5));
|
||||
assert!(selection.descending);
|
||||
let (selection, _) = sent(&["query", r#"{"projection": "header"}"#, "--documents"]);
|
||||
assert_eq!(selection.projection, "document");
|
||||
|
||||
let ran = at(closed_port(), &["query", "{}", "--top-k", "3"], None);
|
||||
assert_eq!(ran.code, 2, "--top-k needs --near");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "selection.json", &json!({ "schemas": ["epiphany.pipeline.campaign.v2"] }));
|
||||
let (selection, _) = sent(&["query", &format!("@{file}")]);
|
||||
assert_eq!(selection.schemas, Some(vec!["epiphany.pipeline.campaign.v2".to_string()]));
|
||||
let (server, seen) = recording();
|
||||
let ran = at(server.addr, &["query", "-"], Some(r#"{"keys": ["a:campaign:self"]}"#));
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
let seen = seen.lock().unwrap();
|
||||
let [HuginnMindRequest::Query { selection, .. }] = &seen[..] else { panic!("{seen:?}") };
|
||||
assert_eq!(selection.keys, Some(vec!["a:campaign:self".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipes_list_what_the_mind_holds() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let campaign = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let ran = at(server.addr, &admit_args(&campaign), None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
let specs = write(&dir, "specs.json", &json!([spec("A"), spec("B")]));
|
||||
let ran = at(server.addr, &admit_args(&specs), None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
|
||||
let unreported = at(server.addr, &["unreported", CAMPAIGN], None);
|
||||
assert_eq!(unreported.code, 0, "{}", unreported.err);
|
||||
let mut lines: Vec<&str> = unreported.out.lines().collect();
|
||||
assert_eq!(lines.pop().unwrap().split(' ').take(2).collect::<Vec<_>>(), ["matched", "2"]);
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
let reports = write(&dir, "report.json", &json!([report("B")]));
|
||||
let ran = at(server.addr, &admit_args(&reports), None);
|
||||
assert_eq!(ran.code, 0, "{}", ran.err);
|
||||
|
||||
let unreported = at(server.addr, &["unreported", CAMPAIGN], None);
|
||||
let lines: Vec<&str> = unreported.out.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{}", unreported.out);
|
||||
let tokens: Vec<&str> = lines[0].split(' ').collect();
|
||||
let id = format!("{CAMPAIGN}:cut_spec:cut-A.r1");
|
||||
assert_eq!(tokens[..5], [id.as_str(), "InForce", "Hands", "A", "cut"]);
|
||||
for fact in ["cut=A", "repo=GameCult/Epiphany", "revision=1", "base=5f98228d", "branch=codex/eureka-pipeline-state"] {
|
||||
assert!(tokens.contains(&fact), "{fact}: {}", lines[0]);
|
||||
}
|
||||
assert!(lines[1].starts_with("matched 1 as_of "), "{}", lines[1]);
|
||||
assert!(lines[1].ends_with(" next -"), "{}", lines[1]);
|
||||
assert!(!unreported.out.contains("cut-B.r1"), "{}", unreported.out);
|
||||
|
||||
let unjudged = at(server.addr, &["unjudged", CAMPAIGN], None);
|
||||
assert!(unjudged.out.contains(&format!("{CAMPAIGN}:cut_report:cut-B.h1")), "{}", unjudged.out);
|
||||
assert!(unjudged.out.contains("matched 1 "), "{}", unjudged.out);
|
||||
|
||||
let campaigns = at(server.addr, &["campaigns"], None);
|
||||
assert!(campaigns.out.starts_with(&format!("{CAMPAIGN}:campaign:self InForce Hands Eureka pipeline state repos={REPO}\n")), "{}", campaigns.out);
|
||||
|
||||
let whole = at(server.addr, &["ledger", CAMPAIGN], None);
|
||||
assert_eq!(whole.code, 0, "{}", whole.err);
|
||||
assert_eq!(whole.out.matches("admitted ").count(), 3, "{}", whole.out);
|
||||
assert!(whole.out.contains("\n\n"), "documents are separated by a blank line: {}", whole.out);
|
||||
|
||||
let documents = at(server.addr, &["campaigns", "--documents"], None);
|
||||
assert!(documents.out.contains("\nadmitted "), "{}", documents.out);
|
||||
assert!(documents.out.contains("\"kind\": \"campaign\""), "{}", documents.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_header_is_cut() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut doc = campaign_json();
|
||||
doc["value"]["title"] = json!("t".repeat(200));
|
||||
doc["value"]["working_branch"] = json!("b");
|
||||
let file = write(&dir, "long.json", &json!([doc]));
|
||||
assert_eq!(at(server.addr, &admit_args(&file), None).code, 0);
|
||||
let ran = at(server.addr, &["campaigns"], None);
|
||||
let header = ran.out.lines().next().unwrap();
|
||||
assert_eq!(header.chars().count(), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolved_document_shows_its_closing_record() {
|
||||
let server = live();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let question = json!({
|
||||
"kind": "question",
|
||||
"value": {
|
||||
"campaign": CAMPAIGN,
|
||||
"label": "Q1",
|
||||
"title": "Who owns the state?",
|
||||
"question": "Who owns the state?",
|
||||
"options": [{ "label": "A", "text": "an instance" }, { "label": "B", "text": "a repo" }],
|
||||
"recommended": "A",
|
||||
"depends": [],
|
||||
"raised_in": null,
|
||||
"asked_on": "2026-10-10",
|
||||
},
|
||||
});
|
||||
let setup = write(&dir, "setup.json", &json!([campaign_json(), question]));
|
||||
let ran = at(server.addr, &admit_args(&setup), None);
|
||||
assert_eq!(ran.code, 0, "{}{}", ran.out, ran.err);
|
||||
let resolution = json!({
|
||||
"kind": "resolution",
|
||||
"value": {
|
||||
"subject": { "kind": "question", "id": format!("{CAMPAIGN}:question:Q1") },
|
||||
"sequence": 1,
|
||||
"outcome": { "Withdrawn": { "reason": "moot" } },
|
||||
"rationale": "Resolved.",
|
||||
"resolved_on": "2026-10-10",
|
||||
},
|
||||
});
|
||||
let closing = write(&dir, "closing.json", &json!([resolution]));
|
||||
let ran = at(server.addr, &admit_args(&closing), None);
|
||||
assert_eq!(ran.code, 0, "{}{}", ran.out, ran.err);
|
||||
|
||||
let id = format!("{CAMPAIGN}:question:Q1");
|
||||
let closer = format!("{CAMPAIGN}:resolution:question.Q1.n1");
|
||||
let viewed = at(server.addr, &["view", &id], None);
|
||||
assert_eq!(viewed.out.lines().next().unwrap(), format!("{id} Resolved Withdrawn by {closer}"));
|
||||
|
||||
let history = at(server.addr, &["history", &id], None);
|
||||
assert_eq!(history.code, 0, "{}", history.err);
|
||||
assert!(history.out.starts_with(&format!("{closer} InForce Hands ")), "{}", history.out);
|
||||
assert!(history.out.contains("sequence=1"), "{}", history.out);
|
||||
|
||||
let open = at(server.addr, &["open", CAMPAIGN], None);
|
||||
assert!(open.out.contains("matched 0 "), "a resolved question is not open: {}", open.out);
|
||||
|
||||
let header = at(server.addr, &["query", r#"{"schemas": ["epiphany.pipeline.question.v2"]}"#], None);
|
||||
assert!(header.out.starts_with(&format!("{id} Resolved:Withdrawn Hands Who owns the state? ")), "{}", header.out);
|
||||
assert!(header.out.contains("options=A,B") && header.out.contains("recommended=A"), "{}", header.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_prints_the_published_files() {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
for kind in ["cut_spec", "campaign", "hand_off", "run"] {
|
||||
let ran = run(&[], &["schema", kind], None);
|
||||
assert_eq!(ran.code, 0);
|
||||
let published = std::fs::read_to_string(manifest.join(format!("../../schemas/cultnet/epiphany.pipeline.{kind}.v2.schema.json"))).unwrap();
|
||||
assert_eq!(ran.out, published, "{kind}");
|
||||
}
|
||||
let selection = run(&[], &["schema", "selection"], None);
|
||||
assert_eq!(selection.out, std::fs::read_to_string(manifest.join("schemas/cultnet.selection.schema.json")).unwrap());
|
||||
let names = run(&[], &["schema"], None);
|
||||
assert_eq!(names.code, 0);
|
||||
let names: Vec<&str> = names.out.lines().collect();
|
||||
assert_eq!(names.len(), 15, "{names:?}");
|
||||
assert_eq!(names[0], "campaign");
|
||||
assert_eq!(names[14], "selection");
|
||||
for kind in PipelineKind::ALL {
|
||||
assert!(names.contains(&kind.name()), "{}", kind.name());
|
||||
}
|
||||
let unknown = run(&[], &["schema", "no-such-kind"], None);
|
||||
assert_eq!(unknown.code, 2);
|
||||
assert!(unknown.err.starts_with("InvalidInput: "), "{}", unknown.err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_the_schema_surface() {
|
||||
let rulings = run(&[], &["rulings", "--help"], None);
|
||||
assert_eq!(rulings.code, 0);
|
||||
let (_, printed) = rulings.out.split_once("selection:\n").expect("the selection is in the help");
|
||||
let selection: Value = serde_json::from_str(printed.trim()).unwrap();
|
||||
assert_eq!(
|
||||
selection,
|
||||
json!({
|
||||
"schemas": ["epiphany.pipeline.ruling.v2"],
|
||||
"fields": [
|
||||
{ "index": "root", "op": "any_of", "values": ["<c>"] },
|
||||
{ "index": "in_force", "op": "any_of", "values": ["true"] },
|
||||
],
|
||||
})
|
||||
);
|
||||
let history = run(&[], &["history", "--help"], None);
|
||||
assert!(history.out.contains("epiphany.pipeline.<kind>.v2"), "{}", history.out);
|
||||
let findings = run(&[], &["findings", "--help"], None);
|
||||
assert!(findings.out.contains("\"severity\"") && findings.out.contains("Blocker"), "{}", findings.out);
|
||||
let precedent = run(&[], &["precedent", "--help"], None);
|
||||
assert!(precedent.out.contains("\"top_k\": 10"), "{}", precedent.out);
|
||||
let admit = run(&[], &["admit", "--help"], None);
|
||||
let admit_text = admit.out.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(admit_text.contains("{\"kind\": <kind>, \"value\": {...}}"), "{admit_text}");
|
||||
assert!(admit_text.contains("exit 5"), "{admit_text}");
|
||||
|
||||
let version = run(&[], &["--version"], None);
|
||||
assert_eq!(version.code, 0);
|
||||
assert!(version.out.starts_with(&format!("huginn {} (", env!("CARGO_PKG_VERSION"))), "{}", version.out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_recipe_has_help() {
|
||||
for recipe in [
|
||||
"campaigns", "target", "rulings", "open", "findings", "unreported", "blocked", "unjudged", "history", "cut", "ledger",
|
||||
"precedent", "gaps",
|
||||
] {
|
||||
let ran = run(&[], &[recipe, "--help"], None);
|
||||
assert_eq!(ran.code, 0, "{recipe}");
|
||||
assert!(ran.out.contains("epiphany.pipeline."), "{recipe}: {}", ran.out);
|
||||
assert!(ran.out.contains("--cursor"), "{recipe}: {}", ran.out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflicting_batch_is_the_minds_no() {
|
||||
let conflict = scripted(|message| {
|
||||
let (_, request) = decode_request(message).expect("a mind request");
|
||||
assert!(matches!(request, HuginnMindRequest::Admit(_)));
|
||||
answer(
|
||||
"admit",
|
||||
&HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict {
|
||||
identities: vec![PipelineRef { kind: PipelineKind::Campaign, id: Short(format!("{CAMPAIGN}:campaign:self")) }],
|
||||
}),
|
||||
)
|
||||
});
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
|
||||
let ran = at(conflict.addr, &admit_args(&file), None);
|
||||
assert_eq!(ran.code, 1);
|
||||
assert_eq!(ran.out, format!("Conflict\nconflicts {CAMPAIGN}:campaign:self\n"));
|
||||
}
|
||||
Loading…
Reference in a new issue