use crate::{HookType, InjectionKind};
use std::path::Path;
use std::time::Duration;
pub const HOOK_EVENT_METHOD: &str = "hook_fired";
const HOOK_EMIT_TIMEOUT: Duration = Duration::from_millis(1500);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HookEventPayload {
#[serde(default)]
pub palace_id: Option<String>,
#[serde(default)]
pub palace_name: Option<String>,
pub hook_type: HookType,
pub injection_kind: InjectionKind,
#[serde(default)]
pub injection_length: u64,
#[serde(default)]
pub trigger_prompt_excerpt: String,
#[serde(default)]
pub duration_ms: u64,
}
pub async fn post_hook_event(payload: HookEventPayload) {
let params = match serde_json::to_value(&payload) {
Ok(v) => v,
Err(e) => {
tracing::warn!("hook_emit: encode payload failed: {e:#}");
return;
}
};
if let Err(e) =
crate::client::call_with_timeout(HOOK_EVENT_METHOD, params, HOOK_EMIT_TIMEOUT).await
{
tracing::warn!("hook_emit: {HOOK_EVENT_METHOD} failed: {e:#}");
}
}
pub async fn post_hook_event_at(socket: &Path, payload: HookEventPayload) {
let params = match serde_json::to_value(&payload) {
Ok(v) => v,
Err(e) => {
tracing::warn!("hook_emit: encode payload failed: {e:#}");
return;
}
};
if let Err(e) =
crate::client::call_at(socket, HOOK_EVENT_METHOD, params, HOOK_EMIT_TIMEOUT).await
{
tracing::warn!("hook_emit: {HOOK_EVENT_METHOD} failed: {e:#}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_payload() -> HookEventPayload {
HookEventPayload {
palace_id: Some("alpha".to_string()),
palace_name: Some("alpha".to_string()),
hook_type: HookType::UserPromptSubmit,
injection_kind: InjectionKind::PromptContext,
injection_length: 256,
trigger_prompt_excerpt: "test prompt".to_string(),
duration_ms: 12,
}
}
#[tokio::test]
async fn post_hook_event_no_daemon_is_noop() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let mut payload = sample_payload();
payload.palace_id = None;
payload.palace_name = None;
post_hook_event(payload).await;
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn hook_fired_activity_emit_smoke() {
let daemon = crate::test_daemon::TestDaemon::start().await;
post_hook_event_at(daemon.socket(), sample_payload()).await;
daemon.state().flush_activity_writes().await;
let page = crate::client::call_at(
daemon.socket(),
"memory.activity",
serde_json::json!({ "source": "hook", "limit": 10 }),
Duration::from_secs(10),
)
.await
.expect("memory.activity answers");
let entries = page["entries"].as_array().expect("entries array");
assert!(
!entries.is_empty(),
"expected at least one hook activity row, got {page}"
);
let first = &entries[0];
assert_eq!(first["source"], "hook");
assert_eq!(first["event_type"], "hook_fired");
assert_eq!(first["palace_id"], "alpha");
assert_eq!(first["payload"]["hook_type"], "UserPromptSubmit");
assert_eq!(first["payload"]["injection_kind"], "prompt-context");
}
#[tokio::test]
async fn hook_emit_failure_isolated() {
use std::sync::Arc;
use trusty_common::uds::server::{
serve_until, RpcError, RpcFallback, RpcRouter, RpcServeOptions,
};
struct AlwaysRefuses;
#[async_trait::async_trait]
impl RpcFallback for AlwaysRefuses {
async fn call(
&self,
method: &str,
_params: serde_json::Value,
) -> Result<serde_json::Value, RpcError> {
Err(RpcError::internal(format!("{method} is refused")))
}
}
let dir = tempfile::tempdir().expect("tempdir");
let socket = dir.path().join("refusing.sock");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
let (stop, shutdown) = tokio::sync::oneshot::channel::<()>();
let router = Arc::new(RpcRouter::new().fallback(AlwaysRefuses));
tokio::spawn(async move {
serve_until(&listener, router, RpcServeOptions::default(), async {
let _ = shutdown.await;
})
.await;
});
post_hook_event_at(&socket, sample_payload()).await;
let _ = stop.send(());
}
}