//! The headless keystone, deterministically: drive the worker's real `run_loop` //! (which writes edits to disk through the same `FsDocument` path the daemon uses) //! and `record_trace` (which seals each edit into a signed session ledger), with a //! scripted on-device model standing in for JouleClaw's GGUF runtime. Proves a //! real coding op closes headlessly and produces an intact, signed receipt — no //! GUI, no window, no model download. use ed25519_dalek::SigningKey; use joule_coded::{record_trace, run_loop, run_loop_metered, seed_from}; use joule_core::journal::EnergyReader; use joule_core::sessions::{SessionGrant, SessionManager}; use std::cell::Cell; use std::rc::Rc; #[test] fn headless_run_edits_a_file_and_seals_a_signed_op() { // A scratch checkout with one file the agent will rewrite. The primary session // makes it accountable without needing a git worktree. let root = std::env::temp_dir().join(format!("jc-headless-{}", std::process::id())); let src = root.join("src"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap(); let key = SigningKey::from_bytes(&seed_from(&root.to_string_lossy())); let author = key.verifying_key().to_bytes(); let grant = SessionGrant { tiers: vec!["l0".into(), "l1".into(), "l2".into(), "l3".into()], joule_ceiling_uj: 1_000_000_000, }; let mut sessions = SessionManager::with_primary( root.clone(), key, root.join(".joule").join("worktrees"), grant, "test-worker", ); // The scripted "model": edit the file, then finish. Exactly the wire the // on-device runtime would emit, so this exercises the real loop + tool parsing. let turns = [ "<<>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<>>", "<<>>\n", ]; let mut step = 0usize; let trace = run_loop(&root, "make answer return 42", 8, |_prompt| { let turn = turns[step.min(turns.len() - 1)]; step += 1; Ok(turn.to_string()) }); // The loop applied exactly one edit, and it is already on disk (the daemon's // apply_edit wrote it through FsDocument). assert_eq!(trace.applied.len(), 1, "expected one applied edit"); assert_eq!(trace.applied[0].0, "src/lib.rs"); let on_disk = std::fs::read_to_string(src.join("lib.rs")).unwrap(); assert!(on_disk.contains("{ 42 }"), "edit not on disk: {on_disk:?}"); // Recording seals the edit as a content-addressed op into the primary // session's signed chain — the headless receipt. let before = sessions .primary() .unwrap() .events() .iter() .filter(|e| e.kind == "op") .count(); let recorded = record_trace(&mut sessions, None, author, "make answer return 42", 12_345, &trace); assert_eq!(recorded, 1, "expected one op recorded"); let primary = sessions.primary().unwrap(); let ops_after = primary.events().iter().filter(|e| e.kind == "op").count(); assert_eq!(ops_after, before + 1, "ledger did not grow by one op"); assert!(primary.verify_chain(), "the signed ledger chain must verify"); assert_eq!(primary.energy_uj(), 12_345, "op energy folded into the session"); std::fs::remove_dir_all(&root).ok(); } #[test] fn metered_run_records_per_step_energy() { let root = std::env::temp_dir().join(format!("jc-metered-{}", std::process::id())); let src = root.join("src"); std::fs::create_dir_all(&src).unwrap(); std::fs::write(src.join("lib.rs"), "fn answer() -> i32 { 0 }\n").unwrap(); // A fake host counter that advances 1000 µJ per read. `measure` reads it twice // per step (before + after), so each recorded step's energy is exactly 1000 µJ — // a deterministic stand-in for a real RAPL/IOReport counter. let reader: EnergyReader = Rc::new({ let cumulative = Cell::new(0u64); move || { let value = cumulative.get(); cumulative.set(value + 1000); (value, "hw_shunt") } }); // Scripted model: edit the file, then finish. Each turn is one `generate` step; // the edit is one `edit` step — all metered independently. let turns = [ "<<>>\nsrc/lib.rs\nfn answer() -> i32 { 42 }\n<<>>", "<<>>\n", ]; let mut step = 0usize; let (trace, journal) = run_loop_metered(&root, "make answer return 42", 8, reader, |_prompt| { let turn = turns[step.min(turns.len() - 1)]; step += 1; // A scripted model still reports a token count, so the journal's // joules-per-token path is exercised rather than left at zero. Ok((turn.to_string(), turn.split_whitespace().count() as u32)) }); assert_eq!(trace.applied.len(), 1, "one edit applied"); assert!(!journal.is_empty(), "journal recorded steps"); // Every step carries its own measured energy + provenance — not one lump. for record in &journal.steps { assert_eq!(record.energy_uj, 1000, "{} step energy", record.kind); assert_eq!(record.provenance, "hw_shunt"); assert!(record.ok, "{} step should succeed", record.kind); } let kinds: Vec<&str> = journal.steps.iter().map(|record| record.kind.as_str()).collect(); assert!(kinds.contains(&"generate"), "a generate step was metered: {kinds:?}"); assert!(kinds.contains(&"edit"), "the edit step was metered: {kinds:?}"); // The summary sums the per-step energy and splits it by kind. let summary = journal.summary(); assert_eq!(summary.total_energy_uj, journal.steps.len() as u64 * 1000); assert_eq!(summary.errors, 0); assert!(summary.energy_by_kind.get("edit").copied().unwrap_or(0) >= 1000); std::fs::remove_dir_all(&root).ok(); }