use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use octl_core::{
append_and_apply_unlocked, DiscussionId, NodeId, ProposalId, RunId, RunLock, RunPaths, Status,
};
use crate::error::CliError;
use crate::supervise::state::SupervisorState;
fn exists_or_io_err(path: &std::path::Path) -> Result<bool, CliError> {
path.try_exists().map_err(|e| {
CliError::system(
"io_error",
format!("cannot check existence of {}: {e}", path.display()),
)
})
}
pub fn deterministic_id(
prefix: char,
child_run_id: &str,
child_node_id: &str,
report_seq: u64,
item_kind: &str,
item_index: usize,
) -> String {
let mut h = Sha256::new();
h.update(child_run_id.as_bytes());
h.update(b":");
h.update(child_node_id.as_bytes());
h.update(b":");
h.update(report_seq.to_string().as_bytes());
h.update(b":");
h.update(item_kind.as_bytes());
h.update(b":");
h.update(item_index.to_string().as_bytes());
let digest = h.finalize();
let head: &[u8; 7] = digest[..7].try_into().expect("sha256 produces 32 bytes");
let mut out = String::with_capacity(2 + 10);
out.push(prefix);
out.push('-');
out.push_str(&base32_lower_10(head));
out
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn base32_lower_10(bytes: &[u8; 7]) -> String {
const ALPHA: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut acc: u64 = 0;
for &b in bytes {
acc = (acc << 8) | u64::from(b);
}
acc >>= 6;
let mut out = [0u8; 10];
for (i, slot) in out.iter_mut().enumerate() {
let shift = (9 - i) * 5;
let idx = ((acc >> shift) & 0x1f) as usize;
*slot = ALPHA[idx];
}
std::str::from_utf8(&out)
.expect("base32 alphabet is ASCII")
.to_owned()
}
#[cfg(test)]
thread_local! {
pub static FAULT_INJECT_AFTER_NTH: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
#[derive(Debug, Default, Clone)]
pub struct ReportConsumption {
pub emitted_discussions: Vec<String>,
pub emitted_spinoffs: Vec<String>,
pub skipped_already_present: usize,
}
#[allow(clippy::too_many_arguments)]
pub fn process_node_report(
parent_paths: &RunPaths,
parent_node_id: &str,
child_run_id: &str,
child_node_id: &str,
report_seq: u64,
report: &Value,
state: &mut SupervisorState,
) -> Result<Option<ReportConsumption>, CliError> {
let parent_nid = NodeId::parse_str(parent_node_id)
.map_err(|e| CliError::user("invalid_id", e.to_string()))?;
RunId::parse_str(child_run_id).map_err(|e| CliError::user("invalid_id", e.to_string()))?;
NodeId::parse_str(child_node_id).map_err(|e| CliError::user("invalid_id", e.to_string()))?;
if let Some(prev) = state
.last_processed_report_seq_by_child
.get(child_run_id)
.copied()
{
if report_seq <= prev {
return Ok(None);
}
}
let mut consumption = ReportConsumption::default();
let mut emitted_count: usize = 0;
let guard = RunLock::acquire(&parent_paths.lock())
.map_err(|e| CliError::system("io_error", e.to_string()))?;
let lock = guard.witness();
{
if let Some(items) = report.get("discussion_items").and_then(Value::as_array) {
for (i, item) in items.iter().enumerate() {
let id = deterministic_id(
'd',
child_run_id,
child_node_id,
report_seq,
"discussion",
i,
);
let did = DiscussionId::parse_str(&id)
.expect("deterministic_id must produce a valid DiscussionId");
if exists_or_io_err(&parent_paths.discussion(&did))? {
consumption.skipped_already_present += 1;
continue;
}
let mut data = serde_json::Map::new();
data.insert("discussion_id".into(), Value::String(id.clone()));
if let Some(topic) = item.get("topic") {
data.insert("topic".into(), topic.clone());
} else {
data.insert(
"topic".into(),
Value::String("(no topic supplied)".to_string()),
);
}
if let Some(sev) = item.get("severity") {
data.insert("severity".into(), sev.clone());
}
if let Some(opts) = item.get("options") {
data.insert("options".into(), opts.clone());
}
if let Some(ctx) = item.get("context") {
data.insert("context".into(), ctx.clone());
}
append_and_apply_unlocked(
&lock,
parent_paths,
"discussion.opened",
Some(&parent_nid),
None,
Value::Object(data),
)
.map_err(|e| CliError::system("io_error", e.to_string()))?;
consumption.emitted_discussions.push(id);
emitted_count += 1;
fault_inject_check(emitted_count);
}
}
if let Some(items) = report.get("spinoff_proposals").and_then(Value::as_array) {
for (i, item) in items.iter().enumerate() {
let id =
deterministic_id('s', child_run_id, child_node_id, report_seq, "spinoff", i);
let pid = ProposalId::parse_str(&id)
.expect("deterministic_id must produce a valid ProposalId");
if exists_or_io_err(&parent_paths.spinoff(&pid))? {
consumption.skipped_already_present += 1;
continue;
}
let mut data = serde_json::Map::new();
data.insert("proposal_id".into(), Value::String(id.clone()));
let title = item
.get("proposed_title")
.cloned()
.unwrap_or_else(|| Value::String("(no title)".into()));
data.insert("proposed_title".into(), title);
let kind = item
.get("proposed_kind")
.cloned()
.unwrap_or_else(|| Value::String("spinoff".into()));
data.insert("proposed_kind".into(), kind);
if let Some(r) = item.get("rationale") {
data.insert("rationale".into(), r.clone());
}
append_and_apply_unlocked(
&lock,
parent_paths,
"spinoff.proposed",
Some(&parent_nid),
None,
Value::Object(data),
)
.map_err(|e| CliError::system("io_error", e.to_string()))?;
consumption.emitted_spinoffs.push(id);
emitted_count += 1;
fault_inject_check(emitted_count);
}
}
append_and_apply_unlocked(
&lock,
parent_paths,
"supervisor.cursor_advanced",
Some(&parent_nid),
None,
json!({ "child_run_id": child_run_id, "report_seq": report_seq }),
)
.map_err(|e| CliError::system("io_error", e.to_string()))?;
}
drop(guard);
state
.last_processed_report_seq_by_child
.insert(child_run_id.to_string(), report_seq);
Ok(Some(consumption))
}
#[inline]
#[allow(clippy::used_underscore_binding)]
fn fault_inject_check(_emitted: usize) {
#[cfg(test)]
{
FAULT_INJECT_AFTER_NTH.with(|c| {
if let Some(n) = c.get() {
if _emitted >= n {
c.set(None);
panic!("fault_inject: forced crash after {_emitted} emit(s)");
}
}
});
}
}
#[allow(dead_code)]
pub fn child_terminal_status_from_report(report: &Value) -> Status {
let cancelled = report
.get("cancelled")
.and_then(Value::as_bool)
.unwrap_or(false);
if cancelled {
return Status::Cancelled;
}
#[allow(clippy::match_same_arms)]
match report.get("success").and_then(Value::as_bool) {
Some(true) => Status::Done,
Some(false) => Status::Failed,
None => Status::Failed,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_id_is_stable() {
let a = deterministic_id('d', "run-x", "n-0001", 7, "discussion", 2);
let b = deterministic_id('d', "run-x", "n-0001", 7, "discussion", 2);
assert_eq!(a, b);
assert!(a.starts_with("d-"));
assert_eq!(a.len(), 2 + 10);
for c in a[2..].chars() {
assert!(
c.is_ascii_lowercase() || ('2'..='7').contains(&c),
"non-base32 char {c:?} in {a}"
);
}
}
#[test]
fn deterministic_id_formula_matches_design_md_1_4() {
let got = deterministic_id('d', "run-x", "n-0001", 7, "discussion", 2);
assert_eq!(got, "d-a4ldwigubn");
}
#[test]
fn base32_lower_10_alphabet_is_rfc4648_lowercase() {
assert_eq!(base32_lower_10(&[0u8; 7]), "aaaaaaaaaa");
assert_eq!(base32_lower_10(&[0xff; 7]), "7777777777");
assert_eq!(base32_lower_10(b"foobar\0"), "mzxw6ytboi");
assert_eq!(base32_lower_10(&[0x80, 0, 0, 0, 0, 0, 0]), "qaaaaaaaaa");
assert_eq!(base32_lower_10(&[0, 0, 0, 0, 0, 0, 0x40]), "aaaaaaaaab");
}
#[test]
fn deterministic_ids_validate_against_core_id_newtypes() {
for seq in [0u64, 1, 7, 42, 1000, u64::MAX] {
for i in [0usize, 1, 5, 99] {
let d = deterministic_id(
'd',
"01jxsnap000000000000000000",
"n-0001",
seq,
"discussion",
i,
);
DiscussionId::parse_str(&d)
.unwrap_or_else(|e| panic!("deterministic discussion id {d} rejected: {e}"));
let s = deterministic_id(
's',
"01jxsnap000000000000000000",
"n-0001",
seq,
"spinoff",
i,
);
ProposalId::parse_str(&s)
.unwrap_or_else(|e| panic!("deterministic spinoff id {s} rejected: {e}"));
}
}
}
#[test]
fn deterministic_id_differs_per_axis() {
let base = deterministic_id('s', "r", "n-0001", 1, "spinoff", 0);
for diff in [
deterministic_id('s', "r", "n-0001", 1, "spinoff", 1),
deterministic_id('s', "r2", "n-0001", 1, "spinoff", 0),
deterministic_id('s', "r", "n-0002", 1, "spinoff", 0),
deterministic_id('s', "r", "n-0001", 2, "spinoff", 0),
] {
assert_ne!(base, diff);
}
}
}