1use crate::backend::service::{self, WebCallType, WebReturnType};
8
9use super::super::AppState;
10
11pub(super) async fn handle_misc_service(state: &AppState, call: &WebCallType) -> WebReturnType {
12 let _store = &state.mstore;
13 let args = &call.args;
14 match (call.service.as_str(), call.method.as_str()) {
15 ("userinput", "SendUserInputResponse") => {
17 WebReturnType::success_empty()
19 }
20
21 ("block", "GetControllerStatus") => {
23 let block_id: String = match service::get_arg(args, 0) {
24 Ok(v) => v,
25 Err(e) => return WebReturnType::error(e),
26 };
27 match crate::backend::blockcontroller::get_block_controller_status(&block_id) {
28 Some(status) => WebReturnType::success(
29 serde_json::to_value(&status).unwrap_or(serde_json::Value::Null),
30 ),
31 None => {
32 let default_status = crate::backend::blockcontroller::BlockControllerRuntimeStatus {
33 blockid: block_id,
34 ..Default::default()
35 };
36 WebReturnType::success(
37 serde_json::to_value(&default_status).unwrap_or(serde_json::Value::Null),
38 )
39 }
40 }
41 }
42 ("block", "SendCommand") => WebReturnType::success_empty(),
43
44 ("block", "SaveTerminalState") => {
54 let block_id: String = match service::get_arg(args, 0) {
55 Ok(v) => v,
56 Err(e) => return WebReturnType::error(e),
57 };
58 let term_state: String = match service::get_arg(args, 1) {
59 Ok(v) => v,
60 Err(e) => return WebReturnType::error(e),
61 };
62 let _state_type: String = service::get_arg(args, 2).unwrap_or_default();
65 let pty_offset: i64 = service::get_arg(args, 3).unwrap_or(0);
66 let term_size: serde_json::Value =
67 service::get_arg(args, 4).unwrap_or(serde_json::Value::Null);
68
69 const CACHE_FILE: &str = "cache:term:full";
70 let fs = &state.filestore;
71
72 let mut meta = std::collections::HashMap::new();
73 meta.insert("ptyoffset".to_string(), serde_json::json!(pty_offset));
74 meta.insert("termsize".to_string(), term_size);
75
76 let exists = matches!(fs.stat(&block_id, CACHE_FILE), Ok(Some(_)));
77 if !exists {
78 if let Err(e) = fs.make_file(
79 &block_id,
80 CACHE_FILE,
81 meta.clone(),
82 crate::backend::storage::filestore::FileOpts::default(),
83 ) {
84 tracing::warn!(block_id = %block_id, error = %e, "SaveTerminalState: make_file failed");
85 return WebReturnType::success_empty();
86 }
87 }
88 if let Err(e) = fs.write_file(&block_id, CACHE_FILE, term_state.as_bytes()) {
89 tracing::warn!(block_id = %block_id, error = %e, "SaveTerminalState: write_file failed");
90 }
91 if let Err(e) = fs.write_meta(&block_id, CACHE_FILE, meta, false) {
92 tracing::warn!(block_id = %block_id, error = %e, "SaveTerminalState: write_meta failed");
93 }
94 WebReturnType::success_empty()
95 }
96
97 ("shell", "ListActive") => {
100 let shells = state.shell_sessions.list_active();
101 WebReturnType::success(serde_json::to_value(&shells).unwrap_or_default())
102 }
103
104 ("cron", "ListActive") => {
112 let jobs = match &state.shared_store {
113 Some(s) => s.cron_list().unwrap_or_default(),
114 None => Vec::new(),
115 };
116 let summaries: Vec<_> = jobs
117 .iter()
118 .filter_map(|job| {
119 state
120 .reactive_handler
121 .get_agent(&job.created_by)
122 .map(|agent| super::super::cron::to_summary(job, agent.block_id))
123 })
124 .collect();
125 WebReturnType::success(serde_json::to_value(&summaries).unwrap_or_default())
126 }
127
128 ("subagent", "ListActive") => {
130 let subagents = state.subagent_watcher.list_active();
131 WebReturnType::success(serde_json::to_value(&subagents).unwrap_or_default())
132 }
133 ("subagent", "ListDispatches") => {
134 let dispatches = state.subagent_watcher.list_dispatches();
135 WebReturnType::success(serde_json::to_value(&dispatches).unwrap_or_default())
136 }
137 ("subagent", "GetHistory") => {
138 let agent_id: String = match service::get_arg(args, 0) {
139 Ok(v) => v,
140 Err(e) => return WebReturnType::error(e),
141 };
142 let limit: usize = service::get_arg(args, 1).unwrap_or(100);
143 let history = state.subagent_watcher.get_history(&agent_id, limit);
144 WebReturnType::success(serde_json::to_value(&history).unwrap_or_default())
145 }
146 ("subagent", "GetInfo") => {
147 let agent_id: String = match service::get_arg(args, 0) {
148 Ok(v) => v,
149 Err(e) => return WebReturnType::error(e),
150 };
151 let info = state.subagent_watcher.get_info(&agent_id);
152 WebReturnType::success(serde_json::to_value(&info).unwrap_or(serde_json::Value::Null))
153 }
154 ("subagent", "GenerateName") => {
155 let agent_id: String = match service::get_arg(args, 0) {
156 Ok(v) => v,
157 Err(e) => return WebReturnType::error(e),
158 };
159 let result = crate::ambient::tasks::generate_subagent_name(
160 &state.mstore,
161 &state.subagent_watcher,
162 &agent_id,
163 crate::ambient::limits::Class::Interactive,
164 )
165 .await;
166 let (display_name, tokens) = match result {
167 Some(generated) => (generated.text, generated.tokens),
168 None => (None, None),
169 };
170 WebReturnType::success(serde_json::json!({ "displayName": display_name, "tokens": tokens }))
171 }
172 ("subagent", "ResolveUnnamedBacklog") => {
179 tokio::spawn(state.subagent_watcher.clone().resolve_unnamed_backlog());
180 WebReturnType::success_empty()
181 }
182 ("history", "List") => {
184 let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
185 let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
186 let offset: usize = service::get_arg(args, 2).unwrap_or(0);
187 let limit: usize = service::get_arg(args, 3).unwrap_or(50);
188 let sort_by: String = service::get_arg(args, 4).unwrap_or_else(|_| "modified_at".to_string());
189 let sort_dir: String = service::get_arg(args, 5).unwrap_or_else(|_| "desc".to_string());
190 let result = state.history_service.list(
191 provider.as_deref(),
192 project.as_deref(),
193 offset,
194 limit,
195 &sort_by,
196 &sort_dir,
197 );
198 WebReturnType::success(result)
199 }
200 ("history", "ListForAgent") => {
201 let agent_id: String = match service::get_arg(args, 0) {
202 Ok(v) => v,
203 Err(e) => return WebReturnType::error(e),
204 };
205 let offset: usize = service::get_arg(args, 1).unwrap_or(0);
206 let limit: usize = service::get_arg(args, 2).unwrap_or(50);
207 let sort_by: String = service::get_arg(args, 3).unwrap_or_else(|_| "modified_at".to_string());
208 let sort_dir: String = service::get_arg(args, 4).unwrap_or_else(|_| "desc".to_string());
209 let result = state.history_service.list_for_agent(
214 &state.identity_store,
215 &agent_id,
216 offset,
217 limit,
218 &sort_by,
219 &sort_dir,
220 );
221 WebReturnType::success(result)
222 }
223 ("history", "Get") => {
224 let session_id: String = match service::get_arg(args, 0) {
225 Ok(v) => v,
226 Err(e) => return WebReturnType::error(e),
227 };
228 let result = state.history_service.get(&session_id);
229 WebReturnType::success(result)
230 }
231 ("history", "Refresh") => {
232 let result = state.history_service.refresh();
233 WebReturnType::success(result)
234 }
235 ("history", "Delete") => {
236 let session_id: String = match service::get_arg(args, 0) {
237 Ok(v) => v,
238 Err(e) => return WebReturnType::error(e),
239 };
240 let result = state.history_service.delete(&session_id);
241 WebReturnType::success(result)
242 }
243 ("history", "Clear") => {
244 let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
245 let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
246 let result = state
247 .history_service
248 .clear(provider.as_deref(), project.as_deref());
249 WebReturnType::success(result)
250 }
251
252 ("subagent", "WatchAgent") => {
253 let agent_id: String = match service::get_arg(args, 0) {
254 Ok(v) => v,
255 Err(e) => return WebReturnType::error(e),
256 };
257 let config_dir: String = match service::get_arg(args, 1) {
258 Ok(v) => v,
259 Err(e) => return WebReturnType::error(e),
260 };
261 let block_id: String = service::get_optional_arg(args, 2)
266 .unwrap_or(None)
267 .unwrap_or_default();
268 state.subagent_watcher.watch_agent(&agent_id, &block_id, std::path::PathBuf::from(config_dir));
269 WebReturnType::success_empty()
270 }
271
272 ("agent", "define") => {
274 let data: crate::backend::rpc_types::CommandAgentDefineData =
275 match service::get_arg(args, 0) {
276 Ok(v) => v,
277 Err(e) => return WebReturnType::error(e),
278 };
279 match super::super::app_api::agent_define_core(state.mstore.clone(), state.id_store.clone(), state.broker.clone(), data).await {
280 Ok(result) => WebReturnType::success(serde_json::to_value(&result).unwrap_or_default()),
281 Err(e) => WebReturnType::error(e),
282 }
283 }
284
285 _ => WebReturnType::error(format!(
286 "unknown service method: {}.{}",
287 call.service, call.method
288 )),
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::server::tests::test_state;
296
297 fn save_terminal_state_call(block_id: &str, state_str: &str, pty_offset: i64) -> WebCallType {
298 WebCallType {
299 service: "block".to_string(),
300 method: "SaveTerminalState".to_string(),
301 uicontext: None,
302 args: vec![
303 serde_json::json!(block_id),
304 serde_json::json!(state_str),
305 serde_json::json!("full"),
306 serde_json::json!(pty_offset),
307 serde_json::json!({ "rows": 24, "cols": 80 }),
308 ],
309 }
310 }
311
312 #[tokio::test]
313 async fn save_terminal_state_persists_content_and_meta() {
314 let state = test_state();
318 let call = save_terminal_state_call("block-1", "serialized-xterm-state", 42);
319
320 let result = handle_misc_service(&state, &call).await;
321 assert!(result.success, "expected success, got {:?}", result.error);
322
323 let content = state
324 .filestore
325 .read_file("block-1", "cache:term:full")
326 .expect("read_file ok")
327 .expect("cache file should exist");
328 assert_eq!(content, b"serialized-xterm-state");
329
330 let file = state
331 .filestore
332 .stat("block-1", "cache:term:full")
333 .expect("stat ok")
334 .expect("file should exist");
335 assert_eq!(file.meta["ptyoffset"], serde_json::json!(42));
336 assert_eq!(file.meta["termsize"], serde_json::json!({ "rows": 24, "cols": 80 }));
337 }
338
339 #[tokio::test]
340 async fn save_terminal_state_overwrites_on_second_snapshot() {
341 let state = test_state();
344
345 let first = save_terminal_state_call("block-1", "first-snapshot", 10);
346 assert!(handle_misc_service(&state, &first).await.success);
347
348 let second = save_terminal_state_call("block-1", "second-snapshot-longer", 99);
349 assert!(handle_misc_service(&state, &second).await.success);
350
351 let content = state
352 .filestore
353 .read_file("block-1", "cache:term:full")
354 .unwrap()
355 .unwrap();
356 assert_eq!(content, b"second-snapshot-longer");
357
358 let file = state.filestore.stat("block-1", "cache:term:full").unwrap().unwrap();
359 assert_eq!(file.meta["ptyoffset"], serde_json::json!(99));
360 }
361
362 #[tokio::test]
363 async fn save_terminal_state_missing_block_id_errors() {
364 let state = test_state();
365 let call = WebCallType {
366 service: "block".to_string(),
367 method: "SaveTerminalState".to_string(),
368 uicontext: None,
369 args: vec![],
370 };
371 let result = handle_misc_service(&state, &call).await;
372 assert!(!result.success);
373 assert!(result.error.is_some());
374 }
375}