Merge pull request 'huginn persona get|put' (#9) from eureka-substrate/huginn-cli-json-persona into main

This commit is contained in:
Eureka 2026-11-07 10:06:28 +00:00
commit 6c9581cb18
2 changed files with 167 additions and 3 deletions

View file

@ -35,7 +35,7 @@ use huginn_mind::eureka_pipeline::{
};
use huginn_mind::{
BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome,
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery,
PersonaPutOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery,
};
use serde_json::Value;
@ -55,7 +55,7 @@ Every key the schema does not know is refused (exit 2) with its path; nothing is
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)]
#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, persona, admit, schema", arg_required_else_help = true)]
struct Cli {
#[command(subcommand)]
command: Command,
@ -119,6 +119,33 @@ enum Command {
},
/// The JSON Schema of a document kind or of a selection; no name lists them.
Schema { name: Option<String> },
/// The mind's persona document (gamecult.persona_state.v0).
Persona {
#[command(subcommand)]
command: PersonaCommand,
},
}
#[derive(Subcommand, Debug)]
enum PersonaCommand {
/// The stored document with its updatedAt and receipt; `absent` (exit 1) until the first put.
Get,
/// Replace the persona document whole.
Put {
#[arg(long, value_parser = faculty)]
faculty: Faculty,
/// The writing agent's name.
#[arg(long)]
agent: String,
/// The writing session.
#[arg(long)]
session: String,
/// The stored updatedAt this put replaces; omit for the first put.
#[arg(long)]
expected_updated_at: Option<String>,
/// The whole document file, or - for stdin.
file: String,
},
}
fn faculty(text: &str) -> Result<Faculty, String> {
@ -200,6 +227,10 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
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::Persona { command: PersonaCommand::Get } => persona_get(settings),
Command::Persona { command: PersonaCommand::Put { faculty, agent, session, expected_updated_at, file } } => {
persona_put(settings, faculty, agent, session, expected_updated_at, &file)
}
Command::Schema { name } => {
let text = schema::text(name.as_deref())?;
let _ = write!(std::io::stdout(), "{text}");
@ -499,6 +530,49 @@ fn refused(refusal: &MindRefusal) -> u8 {
1
}
fn persona_get(settings: &Settings) -> Result<u8, Trouble> {
match settings.client()?.persona_get().map_err(Trouble::from)? {
HuginnMindResponse::Persona(Some(view)) => {
say(serde_json::to_string_pretty(&view.value).map_err(|error| Trouble::Internal(error.to_string()))?);
say(format_args!("updatedAt {} receipt {}", view.updated_at, view.receipt_id));
Ok(0)
}
HuginnMindResponse::Persona(None) => {
say("absent");
Ok(1)
}
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
other => Err(unexpected(&other)),
}
}
fn persona_put(
settings: &Settings,
faculty: Faculty,
agent: String,
session: String,
expected_updated_at: Option<String>,
file: &str,
) -> Result<u8, Trouble> {
let text = door::file_or_stdin(file)?;
let state: Value = serde_json::from_str(&text)
.map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?;
if !state.is_object() {
return Err(Trouble::invalid("the persona document is one JSON object, the whole gamecult.persona_state.v0"));
}
let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) };
match settings.client()?.persona_put(provenance, state, expected_updated_at).map_err(Trouble::from)? {
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id }) => {
say(format_args!("Committed {receipt_id}"));
Ok(0)
}
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => {
Ok(refused(&refusal))
}
other => Err(unexpected(&other)),
}
}
fn unexpected(answer: &HuginnMindResponse) -> Trouble {
let name = match answer {
HuginnMindResponse::Whoami(_) => "whoami",

View file

@ -19,7 +19,7 @@ use huginn_mind::eureka_pipeline::{
};
use huginn_mind::{
AdmissionFacts, HuginnMindRequest, HuginnMindResponse, IndexStatus, MindRefusal, MindStatus, PersonaStatus, PipelineAdmissionOutcome, PipelineDocumentView,
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatus,
PersonaPutOutcome, PipelinePageItems, PipelineProvenance, PipelineSelectionPage, PipelineStatus,
};
use huginn_daemon::{Daemon, Hits, IndexSink, SearchTicket};
use huginn_mind::MindStore;
@ -1375,3 +1375,93 @@ fn sources_refuses_naming_both_models_when_the_daemon_embeds_under_another() {
assert_eq!(refused.code, 1, "{}", refused.err);
assert!(refused.out.contains("d1") && refused.out.contains("d2"), "{}", refused.out);
}
/// A scripted daemon that records each request and answers every one with `response`.
fn replying(operation: &'static str, response: HuginnMindResponse) -> (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(operation, &response)
});
(server, seen)
}
fn persona_put_args<'a>(file: &'a str, expected: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec!["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", "--session", "session-7"];
if let Some(expected) = expected {
args.extend(["--expected-updated-at", expected]);
}
args.push(file);
args
}
#[test]
fn the_persona_commands_round_trip_against_a_live_daemon() {
const T1: &str = "2026-10-10T10:00:00Z";
const T2: &str = "2026-10-10T10:00:01Z";
let (root, daemon) = mind(vec![]);
let server = serve(root, daemon);
let dir = tempfile::tempdir().unwrap();
let first = write(&dir, "first.json", &persona(INSTANCE, T1));
let second = write(&dir, "second.json", &persona(INSTANCE, T2));
let ran = at(server.addr, &["persona", "get"], None);
assert_eq!((ran.code, ran.out.as_str(), ran.err.as_str()), (1, "absent\n", ""));
let ran = at(server.addr, &persona_put_args(&first, None), None);
assert_eq!(ran.code, 0, "{}", ran.err);
let receipt = ran.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&ran.out).to_string();
let ran = at(server.addr, &["persona", "get"], None);
assert_eq!(ran.code, 0, "{}", ran.err);
assert!(ran.out.ends_with(&format!("\nupdatedAt {T1} receipt {receipt}\n")), "{}", ran.out);
let (document, _) = ran.out.rsplit_once("\nupdatedAt").unwrap();
assert_eq!(serde_json::from_str::<Value>(document).unwrap(), persona(INSTANCE, T1));
// A put that did not read the stored document is refused, names the stored updatedAt, and writes nothing.
let stale = at(server.addr, &persona_put_args(&second, None), None);
assert_eq!(stale.code, 1, "{}", stale.err);
assert!(stale.out.starts_with("Refused PersonaStale") && stale.out.contains(T1), "{}", stale.out);
let ran = at(server.addr, &["persona", "get"], None);
assert!(ran.out.contains(&format!("updatedAt {T1} receipt {receipt}")), "{}", ran.out);
// The same document read from stdin, with the stored updatedAt, commits.
let ran = at(server.addr, &persona_put_args("-", Some(T1)), Some(&persona(INSTANCE, T2).to_string()));
assert_eq!(ran.code, 0, "{} {}", ran.out, ran.err);
let ran = at(server.addr, &["persona", "get"], None);
assert!(ran.out.contains(&format!("updatedAt {T2} receipt ")), "{}", ran.out);
}
#[test]
fn persona_put_sends_the_whole_document_under_the_huginn_tool() {
let (server, seen) = replying("persona_put", HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "mind-commit-p".into() }));
let dir = tempfile::tempdir().unwrap();
let file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z"));
let ran = at(server.addr, &persona_put_args(&file, Some("2026-10-09T00:00:00Z")), None);
assert_eq!((ran.code, ran.out.as_str()), (0, "Committed mind-commit-p\n"), "{}", ran.err);
let seen = seen.lock().unwrap();
let [HuginnMindRequest::PersonaPut { instance, provenance, state, expected_updated_at }] = &seen[..] else { panic!("{seen:?}") };
assert_eq!(instance.0, INSTANCE);
assert_eq!(
(provenance.faculty, provenance.agent.0.as_str(), provenance.session.0.as_str(), provenance.tool.0.as_str()),
(huginn_mind::Faculty::SelfFaculty, "claude", "session-7", "huginn")
);
assert_eq!(state, &persona(INSTANCE, "2026-10-10T10:00:00Z"));
assert_eq!(expected_updated_at.as_deref(), Some("2026-10-09T00:00:00Z"));
}
#[test]
fn a_persona_that_is_not_one_object_is_a_usage_error_and_sends_nothing() {
let (server, seen) = replying("persona_put", HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "x".into() }));
let dir = tempfile::tempdir().unwrap();
let array = write(&dir, "array.json", &json!([persona(INSTANCE, "2026-10-10T10:00:00Z")]));
let ran = at(server.addr, &persona_put_args(&array, None), None);
assert_eq!(ran.code, 2, "{}", ran.out);
assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err);
let ran = at(server.addr, &persona_put_args("-", None), Some("not json"));
assert_eq!(ran.code, 2, "{}", ran.out);
assert!(ran.err.starts_with("InvalidInput: "), "{}", ran.err);
assert!(seen.lock().unwrap().is_empty());
}