Merge pull request 'huginn persona get|put' (#9) from eureka-substrate/huginn-cli-json-persona into main
This commit is contained in:
commit
6c9581cb18
2 changed files with 167 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue