Skip to main content

trusty_memory/transport/methods/
admin.rs

1//! Log tail, shutdown, and the fire-and-forget remember (#6286).
2//!
3//! Why: three operational endpoints that supported daemon administration
4//! rather than palace data, and that had no tool equivalent to fall back on.
5//! What: `memory.logs_tail`, `memory.admin_stop`, `memory.remember_async`.
6//! Test: `super::super::uds::tests` — `rpc_logs_tail_*`, `rpc_admin_stop_*`,
7//! `rpc_remember_async_*`.
8
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use crate::transport::api_error::ApiError;
13use crate::AppState;
14
15use super::NoParams;
16
17/// Default tail length: enough context for a glance, small enough to not be a
18/// payload.
19const DEFAULT_LOGS_TAIL_N: usize = 100;
20
21/// Ceiling on a tail request — the ring-buffer capacity, so a caller can never
22/// ask for more lines than the buffer holds.
23const MAX_LOGS_TAIL_N: usize = trusty_common::log_buffer::DEFAULT_LOG_CAPACITY;
24
25fn default_logs_tail_n() -> usize {
26    DEFAULT_LOGS_TAIL_N
27}
28
29/// Params for `memory.logs_tail`.
30#[derive(Debug, Deserialize)]
31pub struct LogsTailParams {
32    /// Lines to return, clamped to `[1, MAX_LOGS_TAIL_N]`.
33    #[serde(default = "default_logs_tail_n")]
34    pub n: usize,
35}
36
37/// `memory.logs_tail` — the most recent N tracing lines (#35).
38///
39/// `total` is how many lines are currently buffered, so a caller can tell
40/// whether the ring has wrapped.
41pub async fn logs_tail(state: &AppState, params: LogsTailParams) -> Result<Value, ApiError> {
42    let n = params.n.clamp(1, MAX_LOGS_TAIL_N);
43    Ok(json!({
44        "lines": state.log_buffer.tail(n),
45        "total": state.log_buffer.len(),
46    }))
47}
48
49/// `memory.admin_stop` — ask the daemon to shut down (#35).
50///
51/// The exit is deferred 200 ms so this response reaches the caller first, and
52/// is compiled out under `cfg(test)` (#1100): a detached `process::exit` in a
53/// test binary races the test's own return and kills the whole process.
54pub async fn admin_stop(_state: &AppState, _params: NoParams) -> Result<Value, ApiError> {
55    tracing::warn!("admin_stop: shutdown requested via memory.admin_stop");
56    #[cfg(not(test))]
57    tokio::spawn(async {
58        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
59        std::process::exit(0);
60    });
61    Ok(json!({ "ok": true, "message": "shutting down" }))
62}
63
64/// Params for `memory.remember_async`.
65///
66/// Why this method exists at all: a sub-agent spawned via Claude Code's Agent
67/// tool inherits no MCP connections, so `memory_remember` is unreachable to it.
68/// It can still run a shell command, which is what `trusty-memory note` is.
69#[derive(Debug, Deserialize)]
70pub struct RememberAsyncParams {
71    /// Drawer body. Required.
72    pub content: String,
73    /// Target palace; falls back to the daemon's `--palace` default.
74    #[serde(default)]
75    pub palace: Option<String>,
76    /// Optional tags, passed through verbatim.
77    #[serde(default)]
78    pub tags: Option<Vec<String>>,
79}
80
81/// Minimum word count accepted, mirroring `tools::CONTENT_GATE_MIN_WORDS`.
82///
83/// Why the check is synchronous (#466): the write runs on a detached task, so
84/// content the background worker would reject was silently dropped after the
85/// caller had already been told the memory was stored.
86const REMEMBER_MIN_WORDS: usize = 4;
87
88/// `memory.remember_async` — queue a write and answer immediately.
89///
90/// The contract is one-way: obvious rejections (empty, too short) are refused
91/// here so the caller learns of them, and everything else is dispatched from a
92/// detached task whose failures are logged rather than returned — the agent
93/// that asked has usually exited by then.
94///
95/// Test: `rpc_remember_async_rejects_short_content`,
96/// `rpc_remember_async_queues_and_persists`.
97pub async fn remember_async(
98    state: &AppState,
99    params: RememberAsyncParams,
100) -> Result<Value, ApiError> {
101    let content = params.content.trim();
102    if content.is_empty() {
103        return Err(ApiError::bad_request("content must not be empty"));
104    }
105    let word_count = content.split_whitespace().count();
106    if word_count < REMEMBER_MIN_WORDS {
107        return Err(ApiError::unprocessable(format!(
108            "content too short: {word_count} word(s); minimum is {REMEMBER_MIN_WORDS} words"
109        )));
110    }
111
112    // Built on the calling task so a bad shape fails here rather than being
113    // swallowed by the detached one. `handle_memory_remember` reads `text`.
114    let mut args = serde_json::Map::new();
115    args.insert("text".to_string(), Value::String(content.to_string()));
116    if let Some(p) = params
117        .palace
118        .clone()
119        .or_else(|| state.default_palace.clone())
120    {
121        args.insert("palace".to_string(), Value::String(p));
122    }
123    if let Some(tags) = params.tags.clone() {
124        args.insert(
125            "tags".to_string(),
126            Value::Array(tags.into_iter().map(Value::String).collect()),
127        );
128    }
129    let tool_args = Value::Object(args);
130
131    let state_for_task = state.clone();
132    tokio::spawn(async move {
133        match crate::tools::dispatch_tool(&state_for_task, "memory_remember", tool_args).await {
134            Ok(v) => {
135                tracing::debug!(target: "trusty_memory::remember_async", result = %v, "queued remember succeeded");
136            }
137            Err(e) => {
138                tracing::warn!(
139                    target: "trusty_memory::remember_async",
140                    "queued remember failed: {e:#}"
141                );
142            }
143        }
144    });
145
146    Ok(json!({ "status": "queued" }))
147}