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}