use anyhow::{Context, Result};
use clap::Subcommand;
use serde_json::{Map, Value};
use std::path::Path;
use std::path::PathBuf;
use wasm4pm_cognition::breeds::{dispatch_breed, BreedId, BreedInput};
mod receipt_domains {
pub const INPUT_HASH: &str = "wasm4pm.cognition.input_hash.v2";
pub const OUTPUT_HASH: &str = "wasm4pm.cognition.output_hash.v2";
pub const RECEIPT_SELF: &str = "wasm4pm.command-receipt.v2.self";
}
fn canonical_json(value: &Value) -> String {
fn canon(value: &Value, out: &mut String) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => out.push_str(&n.to_string()),
Value::String(s) => {
out.push_str(&serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()));
}
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
canon(item, out);
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()));
out.push(':');
canon(&map[*key], out);
}
out.push('}');
}
}
}
let mut out = String::new();
canon(value, &mut out);
out
}
fn derive_hash(domain: &str, bytes: &[u8]) -> String {
blake3::Hash::from_bytes(blake3::derive_key(domain, bytes))
.to_hex()
.to_string()
}
fn derive_run_id(timestamp: &str, input_hash: &str, output_hash: &str) -> String {
let material = format!("{}|{}|{}", timestamp, input_hash, output_hash);
let full = blake3::hash(material.as_bytes());
full.to_hex()[..32].to_string()
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create receipt dir: {}", dir.display()))?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("failed to create tmp receipt: {}", tmp.to_string_lossy()))?;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("failed to persist receipt: {}", path.display()))?;
if let Some(dir) = path.parent() {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
fn init_receipt_tracing() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let filter =
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("info")
});
let _ = tracing_subscriber::fmt()
.json()
.with_writer(std::io::stderr)
.with_env_filter(filter)
.try_init();
});
}
fn receipt_body(
run_id: &str,
command: &str,
input_hash: &str,
output_hash: &str,
status: &str,
timestamp: &str,
summary: Value,
) -> Map<String, Value> {
let mut m = Map::new();
m.insert("schema".into(), Value::String("wasm4pm.command-receipt.v2".into()));
m.insert("run_id".into(), Value::String(run_id.into()));
m.insert("command".into(), Value::String(command.into()));
m.insert("phase".into(), Value::String("outcome".into()));
m.insert("input_hash".into(), Value::String(input_hash.into()));
m.insert("output_hash".into(), Value::String(output_hash.into()));
m.insert("status".into(), Value::String(status.into()));
m.insert("timestamp".into(), Value::String(timestamp.into()));
m.insert("summary".into(), summary);
m
}
fn seal_receipt(mut body: Map<String, Value>) -> (Map<String, Value>, String) {
let canonical = canonical_json(&Value::Object(body.clone()));
let receipt_hash = derive_hash(receipt_domains::RECEIPT_SELF, canonical.as_bytes());
body.insert("receipt_hash".into(), Value::String(receipt_hash.clone()));
(body, receipt_hash)
}
fn persist_receipt(receipt: &Map<String, Value>) -> Result<PathBuf> {
let run_id = receipt
.get("run_id")
.and_then(Value::as_str)
.context("receipt missing run_id")?;
let json = serde_json::to_string_pretty(&Value::Object(receipt.clone()))
.context("failed to serialize receipt")?;
let bytes = format!("{}\n", json).into_bytes();
let dir = Path::new(".wasm4pm").join("receipts");
let target = dir.join(format!("{}.json", run_id));
atomic_write(&target, &bytes)?;
atomic_write(&dir.join("latest.json"), &bytes)?;
Ok(target)
}
fn emit_receipt(
input_bytes: &[u8],
output_json: &Value,
status: &str,
breed: &str,
format: &str,
input_path: &Path,
duration_ms: u128,
) -> Result<()> {
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let input_hash = derive_hash(receipt_domains::INPUT_HASH, input_bytes);
let output_hash = derive_hash(receipt_domains::OUTPUT_HASH, canonical_json(output_json).as_bytes());
let run_id = derive_run_id(×tamp, &input_hash, &output_hash);
let mut summary = Map::new();
summary.insert("breed".into(), Value::String(breed.into()));
summary.insert("format".into(), Value::String(format.into()));
summary.insert(
"input_path".into(),
Value::String(input_path.display().to_string()),
);
summary.insert(
"duration_ms".into(),
Value::Number(serde_json::Number::from(duration_ms as u64)),
);
let body = receipt_body(
&run_id,
"cognition run",
&input_hash,
&output_hash,
status,
×tamp,
Value::Object(summary),
);
let (receipt, receipt_hash) = seal_receipt(body);
let path = persist_receipt(&receipt)?;
tracing::info!(
target: "wasm4pm::receipt",
service_name = "wpm",
command = "cognition run",
status = %status,
run_id = %run_id,
receipt_path = %path.display(),
receipt_hash = %receipt_hash,
"command receipt emitted"
);
Ok(())
}
#[derive(Subcommand, Debug)]
pub enum CognitionCommands {
List,
Run {
breed: String,
input: PathBuf,
#[clap(short, long, default_value = "json")]
format: String,
},
}
pub fn handle_cognition_command(command: &CognitionCommands) -> Result<()> {
match command {
CognitionCommands::List => run_list(),
CognitionCommands::Run {
breed,
input,
format,
} => run_breed_cmd(breed, input, format),
}
}
fn run_list() -> Result<()> {
println!(
"{} legally-admitted cognitive breeds:\n",
BreedId::ALL.len()
);
for id in BreedId::ALL {
println!(" {}", id);
}
Ok(())
}
fn run_breed_cmd(breed: &str, input_path: &PathBuf, format: &str) -> Result<()> {
init_receipt_tracing();
let span = tracing::info_span!(
"wasm4pm.command.cognition.run",
service_name = "wpm",
status = tracing::field::Empty,
);
let t0 = std::time::Instant::now();
let outcome = {
let _entered = span.enter();
run_breed_cmd_inner(breed, input_path, format)
};
let duration_ms = t0.elapsed().as_millis();
let (status, output_json, input_bytes) = match &outcome {
Ok((output_json, input_bytes)) => ("ok", output_json.clone(), input_bytes.clone()),
Err(err) => (
"error",
serde_json::json!({ "error": format!("{}", err) }),
Vec::new(),
),
};
span.record("status", status);
emit_receipt(
&input_bytes,
&output_json,
status,
breed,
format,
input_path,
duration_ms,
)?;
outcome.map(|_| ())
}
type RunOutcome = (Value, Vec<u8>);
fn run_breed_cmd_inner(breed: &str, input_path: &Path, format: &str) -> Result<RunOutcome> {
let normalized = breed.to_lowercase();
let id = BreedId::ALL
.iter()
.copied()
.find(|id| id.to_string() == normalized)
.with_context(|| {
format!(
"unknown or unadmitted breed '{}': not found in BreedId::ALL (run `wpm cognition list`)",
breed
)
})?;
let raw = std::fs::read(input_path)
.with_context(|| format!("failed to read input file: {}", input_path.display()))?;
let raw_text = String::from_utf8(raw.clone())
.with_context(|| format!("input file is not valid UTF-8: {}", input_path.display()))?;
let input: BreedInput = serde_json::from_str(&raw_text).with_context(|| {
format!(
"failed to parse {} as BreedInput JSON",
input_path.display()
)
})?;
let output = dispatch_breed(&id.to_string(), &input)
.map_err(|e| anyhow::anyhow!("breed '{}' failed: {}", id, e))?;
let output_json = match format {
"debug" => {
println!("{:#?}", output);
serde_json::to_value(&output).context("failed to convert BreedOutput to JSON")?
}
_ => {
let pretty = serde_json::to_string_pretty(&output)
.context("failed to serialize BreedOutput as JSON")?;
println!("{}", pretty);
serde_json::to_value(&output).context("failed to convert BreedOutput to JSON")?
}
};
Ok((output_json, raw))
}