use crate::err_run;
use areev_core::error::{Hash, Result};
use areev_core::types::Grain;
use areev_run_core::{Budgets, NodeExecutor, PlanGraph, RunError};
use areev_store::Areev;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ForkBase {
pub base_run: String,
pub base_checkpoint: String,
pub base_superstep: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PinnedTool {
pub node: String,
pub tool_hash: String,
pub tool_name: String,
pub executor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_limits: Option<serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RunManifest {
pub run_id: String,
pub plan_hash: String,
pub principal: String,
pub pinned: Vec<PinnedTool>,
pub budgets: BudgetsSpec,
pub ask_ttl_sec: Option<i64>,
pub redaction: String,
pub input: serde_json::Value,
#[serde(default)]
pub llm_max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub reducers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_of: Option<ForkBase>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
pub struct BudgetsSpec {
pub max_supersteps: Option<u64>,
pub max_tokens: Option<u64>,
pub max_usd_micros: Option<u64>,
pub max_wall_ms: Option<u64>,
pub max_storage_bytes: Option<u64>,
}
impl BudgetsSpec {
pub fn to_budgets(self) -> Budgets {
Budgets {
max_supersteps: self.max_supersteps.unwrap_or(Budgets::default().max_supersteps),
max_tokens: self.max_tokens,
max_usd_micros: self.max_usd_micros,
max_wall_ms: self.max_wall_ms,
max_storage_bytes: self.max_storage_bytes,
}
}
}
impl RunManifest {
#[allow(clippy::too_many_arguments)]
pub fn resolve(
m: &mut Areev,
ns: &str,
run_id: &str,
plan_hash: &Hash,
plan: &PlanGraph,
principal: &str,
budgets: BudgetsSpec,
ask_ttl_sec: Option<i64>,
input: serde_json::Value,
llm_available: bool,
llm_max_tokens: Option<u32>,
) -> std::result::Result<RunManifest, RunError> {
let mut pinned = Vec::with_capacity(plan.nodes.len());
for (i, node) in plan.nodes.iter().enumerate() {
let resolved = match &plan.bindings[i] {
Some(hash_str) => {
let h = Hash::from_hex(hash_str).map_err(|_| RunError::UnresolvedRef {
what: format!("binding for node '{node}' is not a content address"),
})?;
let g = m.get(&h).map_err(|e| RunError::UnresolvedRef {
what: format!("binding for node '{node}': {e}"),
})?;
if g.grain_type == areev_core::types::GrainType::Workflow {
PinnedTool {
node: node.clone(),
tool_hash: h.to_hex(),
tool_name: node.clone(),
executor: "subgraph".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
}
} else {
pin_from_definition(node, &h, &g)?
}
}
None => {
match find_definition_by_name(m, ns, node) {
Some((h, g)) => pin_from_definition(node, &h, &g)?,
None if llm_available => PinnedTool {
node: node.clone(),
tool_hash: String::new(),
tool_name: node.clone(),
executor: "abstract".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
},
None => return Err(RunError::NoToolLlm { node: node.clone() }),
}
}
};
pinned.push(resolved);
}
let mut reducers: BTreeMap<String, String> = BTreeMap::new();
if let Ok(wf_grain) = m.get(plan_hash) {
if let Some(table) = wf_grain.fields.get("reducers").and_then(|v| v.as_object()) {
for (key, name) in table {
let Some(name) = name.as_str() else {
return Err(RunError::InvalidPlan {
why: format!("reducer for state key '{key}' is not a string"),
});
};
if !crate::reducers::is_builtin(name) {
return Err(RunError::InvalidPlan {
why: format!(
"unknown reducer '{name}' for state key '{key}' \
(builtins: {:?})",
crate::reducers::BUILTIN_REDUCERS
),
});
}
reducers.insert(key.clone(), name.to_string());
}
}
}
Ok(RunManifest {
run_id: run_id.to_string(),
plan_hash: plan_hash.to_hex(),
principal: principal.to_string(),
pinned,
budgets,
ask_ttl_sec,
redaction: "none".into(),
input,
llm_max_tokens,
reducers,
fork_of: None,
})
}
pub fn llm_reserve_tokens(&self) -> u64 {
u64::from(self.llm_max_tokens.unwrap_or(1024))
}
pub fn executors(&self) -> Vec<NodeExecutor> {
let offered: Vec<areev_run_core::OfferedTool> = self
.pinned
.iter()
.filter(|p| p.executor == "host")
.map(|p| areev_run_core::OfferedTool {
tool_name: p.tool_name.clone(),
tool_hash: p.tool_hash.clone(),
})
.collect();
self.pinned
.iter()
.map(|p| match p.executor.as_str() {
"client" => NodeExecutor::Client {
tool_hash: p.tool_hash.clone(),
tool_name: p.tool_name.clone(),
},
"subgraph" => NodeExecutor::Subgraph { workflow_hash: p.tool_hash.clone() },
"abstract" => NodeExecutor::Abstract { tools: offered.clone() },
_ => NodeExecutor::Host {
tool_hash: p.tool_hash.clone(),
tool_name: p.tool_name.clone(),
},
})
.collect()
}
pub fn persist(&self, m: &mut Areev) -> Result<(Hash, Hash)> {
let config = serde_json::to_value(json!({ "areev_run": self }))
.expect("manifest serializes");
let mut state = areev_core::types::State::new(config)
.namespace(areev_core::authz::HARNESS_NS)
.created_at(0);
state.common.author_did = Some(self.principal.clone());
let config_hash = m.add(&state)?;
let mut link = areev_core::types::Fact::new(
&format!("run:{}", self.run_id),
"mg:harness",
&config_hash.to_hex(),
)
.namespace(areev_core::authz::HARNESS_NS);
link.common.author_did = Some(self.principal.clone());
link.common
.extra_fields
.insert("run_id".into(), serde_json::json!(self.run_id));
let link_hash = m.add(&link)?;
Ok((config_hash, link_hash))
}
pub fn load(m: &mut Areev, run_id: &str) -> std::result::Result<RunManifest, RunError> {
let link = m
.latest(areev_core::authz::HARNESS_NS, &format!("run:{run_id}"), "mg:harness")
.map_err(err_run)?
.ok_or_else(|| RunError::ManifestMismatch {
why: format!("no manifest link for run '{run_id}'"),
})?;
let config_hash = link
.get_str("object")
.and_then(|h| Hash::from_hex(h).ok())
.ok_or_else(|| RunError::ManifestMismatch {
why: "manifest link carries no config hash".into(),
})?;
let config = m.get(&config_hash).map_err(|e| RunError::ManifestMismatch {
why: format!("manifest config unreadable: {e}"),
})?;
let manifest = config
.fields
.get("context")
.or_else(|| config.fields.get("context_data"))
.and_then(|c| c.get("areev_run"))
.cloned()
.ok_or_else(|| RunError::ManifestMismatch {
why: "config State carries no areev_run manifest".into(),
})?;
serde_json::from_value(manifest).map_err(|e| RunError::ManifestMismatch {
why: format!("manifest does not parse: {e}"),
})
}
}
fn pin_from_definition(
node: &str,
h: &Hash,
g: &areev_core::format::deserialize::DeserializedGrain,
) -> std::result::Result<PinnedTool, RunError> {
if g.get_str("kind") != Some("definition") {
return Err(RunError::UnresolvedRef {
what: format!(
"node '{node}' binds {} which is not a Tool Definition \
(kind = {:?})",
h.to_hex(),
g.get_str("kind").unwrap_or("execution")
),
});
}
let tool_name = g
.get_str("tool_name")
.unwrap_or(node)
.to_string();
if !areev_core::types::json_schema_subset::is_valid_tool_name(&tool_name) {
return Err(RunError::UnresolvedRef {
what: format!(
"node '{node}' binds {} whose tool_name {tool_name:?} is not a valid \
tool name (1-64 chars of [A-Za-z0-9_.-])",
h.to_hex()
),
});
}
let executor = match g.get_str("executor_kind") {
Some("client") => "client",
_ => "host",
};
let executor_uri = match g.get_str("executor_uri") {
None => None,
Some(uri) if executor == "client" => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' binds a client tool carrying executor_uri {uri:?} — a \
client tool is answered by a person through `run respond`, so it has \
no executor to name"
),
})
}
Some(uri) => {
let hex = crate::executor::strip_cas(uri);
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' names executor_uri {uri:?}, which this build cannot \
dispatch — the only executable form is a content address, \
cas://sha256:<64 hex>"
),
});
}
Some(format!("cas://sha256:{}", hex.to_ascii_lowercase()))
}
};
let runtime = match g.get_str("runtime") {
None | Some("native") => None,
Some(rt) if executor_uri.is_none() => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares runtime {rt:?} but names no executor_uri — a runtime routes a code blob, and there is none"
),
})
}
Some("wasm32-areev") => Some("wasm32-areev".to_string()),
Some(rt) => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares runtime {rt:?}, which this build cannot dispatch — accepted: native, wasm32-areev"
),
})
}
};
let runtime_limits = if runtime.is_some() { g.fields.get("runtime_limits").cloned() } else { None };
Ok(PinnedTool {
node: node.to_string(),
tool_hash: h.to_hex(),
tool_name,
executor: executor.into(),
executor_uri,
runtime,
runtime_limits,
})
}
fn find_definition_by_name(
m: &mut Areev,
ns: &str,
name: &str,
) -> Option<(Hash, areev_core::format::deserialize::DeserializedGrain)> {
const CATALOG_SCAN: usize = 200_000;
let hits = m
.recent(ns, Some(areev_core::types::GrainType::Tool), CATALOG_SCAN)
.ok()?;
let mut best: Option<(i64, String)> = None;
let mut candidates: BTreeMap<String, areev_core::format::deserialize::DeserializedGrain> =
BTreeMap::new();
for g in hits {
if g.get_str("kind") == Some("definition") && g.get_str("tool_name") == Some(name) {
let at = g.get_i64("created_at").unwrap_or(0);
let key = (at, g.hash.to_hex());
if best.as_ref().is_none_or(|b| key > *b) {
best = Some(key);
}
candidates.insert(g.hash.to_hex(), g);
}
}
let (_, hex) = best?;
let g = candidates.remove(&hex)?;
let h = Hash::from_hex(&hex).ok()?;
Some((h, g))
}