use anyhow::Result;
use std::{future::Future, time::Duration};
use crate::cli::daemon::{daemon_result, mati_root_for, DaemonResult};
use crate::cli::hook_decide::{log_fail_open_named, HookRunOutcome};
pub(crate) async fn run_with_deadline<F>(
hook: &str,
rel_path: &str,
deadline_ms: u64,
reason: &str,
operation: F,
) -> Result<HookRunOutcome>
where
F: Future<Output = Result<()>>,
{
match tokio::time::timeout(Duration::from_millis(deadline_ms), operation).await {
Ok(result) => result.map(|()| HookRunOutcome::Continue),
Err(_) => {
log_fail_open_named(hook, rel_path, reason);
Ok(HookRunOutcome::Terminate(0))
}
}
}
async fn hook_fire_v2(cmd: mati_core::mcp::protocol::Command) -> Result<()> {
let kind = cmd.kind();
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match super::daemon::daemon_v2(&root, cmd).await {
DaemonResult::Ok(_) => {}
_ => tracing::debug!("mati {kind}: daemon unreachable — dropping event"),
}
Ok(())
}
async fn hook_query_bool(cmd: &str, args: serde_json::Value) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match daemon_result(&root, cmd, args).await {
DaemonResult::Ok(resp) => {
if resp.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
let value = resp.get("data").and_then(|v| v.as_bool()).unwrap_or(false);
println!("{value}");
} else {
tracing::debug!("mati {cmd}: daemon error — consultation state unknown");
println!("unknown");
}
}
_ => {
tracing::debug!("mati {cmd}: daemon unreachable — false");
println!("false");
}
}
Ok(())
}
pub async fn run_get(key: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = mati_root_for(&cwd)?;
match daemon_result(&root, "get", serde_json::json!({ "key": key })).await {
DaemonResult::Ok(resp) => {
let json = match resp.get("data") {
Some(d) if d.is_null() => "null".to_string(),
Some(d) => d.to_string(),
None => "null".to_string(),
};
println!("{json}");
}
_ => {
tracing::debug!("mati get: daemon unreachable — fail-open (null)");
println!("null");
}
}
Ok(())
}
pub async fn run_log_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Miss,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_log_hit(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::ConsultationHit(p::ConsultationHitInput {
key: key.to_string(),
capture_fingerprint: true,
actor: None,
session_id: None,
agent_id: None,
decision_basis_hash: None,
source: None,
}))
.await
}
pub async fn run_log_compliance_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceMiss,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_log_compliance_hit(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::ComplianceHit,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_log_codex_shell_miss(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::CodexShellMiss,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_log_bootstrap(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::Bootstrap,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_log_prompt_nudge(key: &str) -> Result<()> {
use mati_core::mcp::protocol as p;
hook_fire_v2(p::Command::SessionLog(p::SessionLogInput {
event: p::SessionEvent::PromptNudge,
key: key.to_string(),
session_id: None,
actor: None,
decision_basis_hash: None,
}))
.await
}
pub async fn run_session_flush() -> Result<HookRunOutcome> {
const SESSION_FLUSH_DEADLINE_MS: u64 = 5000;
run_with_deadline(
"session-flush",
"<session-flush>",
SESSION_FLUSH_DEADLINE_MS,
"session flush exceeded internal deadline",
hook_fire_v2(mati_core::mcp::protocol::Command::SessionFlush),
)
.await
}
pub async fn run_session_harvest() -> Result<HookRunOutcome> {
const SESSION_END_DEADLINE_MS: u64 = 2500;
run_with_deadline(
"session-end",
"<session-harvest>",
SESSION_END_DEADLINE_MS,
"session harvest exceeded internal deadline",
hook_fire_v2(mati_core::mcp::protocol::Command::SessionHarvest),
)
.await
}
pub async fn run_subagent_harvest() -> Result<HookRunOutcome> {
use mati_core::mcp::protocol as p;
const SUBAGENT_STOP_DEADLINE_MS: u64 = 3000;
let mut buf = String::new();
if std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf).is_err() {
return Ok(HookRunOutcome::Terminate(0));
}
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&buf) else {
return Ok(HookRunOutcome::Terminate(0));
};
let field = |k: &str| payload.get(k).and_then(|v| v.as_str()).map(str::to_string);
let summary = field("last_assistant_message").unwrap_or_default();
if summary.trim().is_empty() {
return Ok(HookRunOutcome::Terminate(0));
}
run_with_deadline(
"subagent-stop",
"<subagent-harvest>",
SUBAGENT_STOP_DEADLINE_MS,
"subagent harvest exceeded internal deadline",
hook_fire_v2(p::Command::SubagentHarvest(p::SubagentHarvestInput {
summary,
session_id: field("session_id"),
agent_id: field("agent_id"),
agent_type: field("agent_type"),
transcript_path: field("agent_transcript_path"),
})),
)
.await
}
pub async fn run_subagent_record() -> Result<HookRunOutcome> {
use mati_core::mcp::protocol as p;
const SUBAGENT_START_DEADLINE_MS: u64 = 3000;
let mut buf = String::new();
if std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf).is_err() {
return Ok(HookRunOutcome::Terminate(0));
}
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&buf) else {
return Ok(HookRunOutcome::Terminate(0));
};
let field = |k: &str| payload.get(k).and_then(|v| v.as_str()).map(str::to_string);
let agent_id = field("agent_id").unwrap_or_default();
if agent_id.trim().is_empty() {
return Ok(HookRunOutcome::Terminate(0));
}
run_with_deadline(
"subagent-start",
"<subagent-record>",
SUBAGENT_START_DEADLINE_MS,
"subagent presence record exceeded internal deadline",
hook_fire_v2(p::Command::SubagentSpawned(p::SubagentSpawnedInput {
agent_id: field("agent_id"),
session_id: field("session_id"),
agent_type: field("agent_type"),
})),
)
.await
}
pub async fn run_session_clear_consults() -> Result<HookRunOutcome> {
const POST_COMPACT_DEADLINE_MS: u64 = 3000;
run_with_deadline(
"post-compact",
"<session-clear-consults>",
POST_COMPACT_DEADLINE_MS,
"post-compact receipt cleanup exceeded internal deadline",
hook_fire_v2(mati_core::mcp::protocol::Command::SessionClearConsults),
)
.await
}
pub async fn run_edit_hook(path: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let rel = std::path::Path::new(path)
.strip_prefix(&cwd)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
hook_fire_v2(mati_core::mcp::protocol::Command::FileEditHook(
mati_core::mcp::protocol::FileEditHookInput { path: rel },
))
.await
}
pub async fn run_doc_capture(path: &str) -> Result<()> {
use std::io::Read as _;
let _ = std::io::stdin().read_to_end(&mut Vec::new());
hook_fire_v2(mati_core::mcp::protocol::Command::DocCapture(
mati_core::mcp::protocol::DocCaptureInput {
path: path.to_string(),
},
))
.await
}
pub async fn run_session_check_consulted(key: &str) -> Result<()> {
hook_query_bool("session_check_consulted", serde_json::json!({ "key": key })).await
}
pub async fn run_session_check_consulted_recent(key: &str, ttl_secs: u64) -> Result<()> {
hook_query_bool(
"session_check_consulted_recent",
serde_json::json!({ "key": key, "ttl_secs": ttl_secs }),
)
.await
}
pub async fn run_prompt_context(files: &[String]) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = crate::cli::daemon::mati_root_for(&cwd)?;
let cmd = mati_core::mcp::protocol::Command::MemBootstrap(
mati_core::mcp::protocol::MemBootstrapInput {
context_files: files.to_vec(),
},
);
match crate::cli::daemon::daemon_v2(&root, cmd).await {
crate::cli::daemon::DaemonResult::Ok(resp)
if resp.get("ok") == Some(&serde_json::Value::Bool(true)) =>
{
if let Some(data) = resp.get("data") {
let text = data.as_str().unwrap_or("");
print!("{text}");
}
}
_ => {
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use mati_core::store::session::*;
use mati_core::store::*;
use tempfile::TempDir;
fn extract_confirmed(record: &Record) -> bool {
if record.category != Category::Gotcha {
return false;
}
record
.payload_as::<GotchaRecord>()
.map(|g| g.confirmed)
.unwrap_or(false)
}
async fn temp_store() -> (TempDir, Store) {
crate::cli::ensure_test_home();
let dir = TempDir::new().expect("tempdir");
let store = Store::open(dir.path()).await.expect("open store");
(dir, store)
}
#[tokio::test]
async fn extract_confirmed_returns_true_for_confirmed_gotcha() {
let mut record = Record {
key: "gotcha:test".to_string(),
value: "test".to_string(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: Some(serde_json::json!({
"rule": "test rule",
"reason": "test reason",
"severity": "normal",
"affected_files": [],
"confirmed": true
})),
};
assert!(extract_confirmed(&record));
record.category = Category::File;
assert!(!extract_confirmed(&record));
}
#[tokio::test]
async fn upsert_daily_agg_caps_keys_at_100() {
let (_dir, store) = temp_store().await;
let agg_key = today_key("analytics:test_cap_");
for i in 0..120 {
upsert_daily_agg(&store, &agg_key, &format!("key_{i}"))
.await
.unwrap();
}
let record = store.get(&agg_key).await.unwrap().unwrap();
let agg = record.payload_as::<DailyAgg>().unwrap();
assert_eq!(agg.count, 120);
assert_eq!(agg.keys.len(), MAX_AGG_KEYS);
store.close().await.unwrap();
}
#[tokio::test]
async fn promote_gotcha_candidates_confirms_above_threshold() {
let (_dir, store) = temp_store().await;
let record = Record {
key: "gotcha:promote-test".to_string(),
value: "test".to_string(),
category: Category::Gotcha,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: GOTCHA_PROMOTION_ACCESS_THRESHOLD,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: Some(serde_json::json!({
"rule": "test rule",
"reason": "test reason",
"severity": "normal",
"affected_files": [],
"confirmed": false
})),
};
store.put(&record.key, &record).await.unwrap();
let promoted = mati_core::store::session::promote_gotcha_candidates(&store)
.await
.unwrap();
assert_eq!(promoted, 1);
let updated = store.get("gotcha:promote-test").await.unwrap().unwrap();
let gotcha = updated.payload_as::<GotchaRecord>().unwrap();
assert!(gotcha.confirmed);
store.close().await.unwrap();
}
#[tokio::test]
async fn stale_review_truncates_to_max() {
let (_dir, store) = temp_store().await;
for i in 0..30 {
let key = format!("file:test_{i}.rs");
let record = Record {
key: key.clone(),
value: format!("test file {i}"),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 0,
updated_at: 0,
ref_url: None,
staleness: StalenessScore {
value: 0.5,
tier: StalenessTier::Stale,
signals: vec![],
computed_at: 0,
last_record_sha: String::new(),
},
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 0,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
};
store.put(&key, &record).await.unwrap();
}
let keys: Vec<String> = (0..30).map(|i| format!("file:test_{i}.rs")).collect();
let entries = collect_stale_entries(&store, &keys).await.unwrap();
assert!(entries.len() <= MAX_STALE_REVIEW_ENTRIES);
store.close().await.unwrap();
}
}