agentmux_srv/server/agent_handlers/
instance.rs1use std::sync::Arc;
6
7use crate::backend::rpc::engine::WshRpcEngine;
8use crate::backend::rpc_types::{
9 COMMAND_LIST_AGENT_INSTANCES, COMMAND_GET_AGENT_INSTANCE,
10 COMMAND_CREATE_AGENT_INSTANCE, COMMAND_UPDATE_AGENT_INSTANCE,
11 COMMAND_DELETE_AGENT_INSTANCE, COMMAND_AGENT_LAST_RUNTIME,
12 AgentLastRuntime, CommandAgentLastRuntimeData,
13 CommandListAgentInstancesData, CommandGetAgentInstanceData,
14 CommandCreateAgentInstanceData, CommandUpdateAgentInstanceData,
15 CommandDeleteAgentInstanceData, DeleteAgentInstanceResult,
16};
17use crate::backend::storage::store::{
18 AgentInstance, InstanceStatus,
19};
20
21use super::super::AppState;
22
23pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
24 let mstore = state.mstore.clone();
27 engine.register_typed(
28 COMMAND_LIST_AGENT_INSTANCES,
29 move |cmd: Option<CommandListAgentInstancesData>, _ctx| {
37 let mstore = mstore.clone();
38 async move {
39 let cmd = cmd.unwrap_or_default();
40 let rows = mstore
41 .instance_list(cmd.definition_id.as_deref(), cmd.status.as_deref())
42 .map_err(|e| format!("listagentinstances: {e}"))?;
43 Ok(rows)
44 }
45 },
46 );
47
48 let mstore = state.mstore.clone();
49 engine.register_typed(
50 COMMAND_GET_AGENT_INSTANCE,
51 move |cmd: CommandGetAgentInstanceData, _ctx| {
52 let mstore = mstore.clone();
53 async move {
54 mstore
58 .instance_get(&cmd.id)
59 .map_err(|e| format!("getagentinstance: {e}"))?
60 .ok_or_else(|| format!("getagentinstance: not found id={}", cmd.id))
61 }
62 },
63 );
64
65 let mstore = state.mstore.clone();
66 let broker = state.broker.clone();
67 engine.register_typed(
68 COMMAND_CREATE_AGENT_INSTANCE,
69 move |cmd: CommandCreateAgentInstanceData, _ctx| {
70 let mstore = mstore.clone();
71 let broker = broker.clone();
72 async move {
73 let now = agentmux_common::time::now_ms();
74 let inst = AgentInstance {
75 id: uuid::Uuid::new_v4().to_string(),
76 definition_id: cmd.definition_id,
77 parent_instance_id: cmd.parent_instance_id,
78 block_id: cmd.block_id,
79 session_id: String::new(),
80 status: InstanceStatus::Running.as_str().to_string(),
81 github_context: String::new(),
82 started_at: now,
83 ended_at: 0,
84 created_at: now,
85 identity_id: cmd.identity_id,
90 memory_id: cmd.memory_id,
91 instance_name: cmd.instance_name.clone(),
97 working_directory: cmd.working_directory.clone(),
98 display_hidden: false,
99 };
100 let created = mstore
111 .instance_create(&inst)
112 .map_err(|e| format!("createagentinstance: {e}"))?;
113
114 if !inst.block_id.is_empty()
121 && crate::backend::agent_session::is_valid_definition_id(&inst.definition_id)
122 {
123 let zone = crate::backend::agent_session::agent_current_zone(
124 &inst.definition_id,
125 );
126 let mut meta_update = crate::backend::obj::MetaMapType::new();
127 meta_update.insert(
128 "agent:sessionZone".to_string(),
129 serde_json::json!(zone),
130 );
131 let oref_str = format!("block:{}", inst.block_id);
132 if let Err(e) = crate::server::service::update_object_meta(
133 &mstore, &oref_str, &meta_update,
134 ) {
135 tracing::warn!(
140 block_id = %inst.block_id,
141 definition_id = %inst.definition_id,
142 error = %e,
143 "createagentinstance: failed to stamp agent:sessionZone"
144 );
145 }
146 }
147
148 broker.publish_event_for::<crate::backend::mps_events::AgentInstancesChanged>(&inst.definition_id, &(), vec![], 0);
149 Ok(created)
150 }
151 },
152 );
153
154 let mstore = state.mstore.clone();
155 let broker = state.broker.clone();
156 engine.register_typed(
157 COMMAND_UPDATE_AGENT_INSTANCE,
158 move |cmd: CommandUpdateAgentInstanceData, _ctx| {
159 let mstore = mstore.clone();
160 let broker = broker.clone();
161 async move {
162 let upd = crate::backend::storage::InstanceUpdate {
171 block_id: cmd.block_id,
172 session_id: cmd.session_id,
173 status: cmd.status,
174 github_context: cmd.github_context,
175 ended_at: cmd.ended_at,
176 };
177 let fresh = mstore
178 .instance_update_partial(&cmd.id, &upd)
179 .map_err(|e| format!("updateagentinstance: {e}"))?
180 .ok_or_else(|| format!("updateagentinstance: not found id={}", cmd.id))?;
181 broker.publish_event_for::<crate::backend::mps_events::AgentInstancesChanged>(&fresh.definition_id, &(), vec![], 0);
182 Ok(fresh)
183 }
184 },
185 );
186
187 let mstore = state.mstore.clone();
188 engine.register_typed(
189 COMMAND_AGENT_LAST_RUNTIME,
190 move |cmd: CommandAgentLastRuntimeData, _ctx| {
191 let mstore = mstore.clone();
192 async move {
193 if let Some(raw) = &cmd.runtime {
194 let normalized = crate::backend::storage::agents::normalize_last_runtime(raw)
195 .map_err(|e| format!("agentlastruntime: {e}"))?;
196 mstore
197 .agent_last_runtime_set(&cmd.id, &normalized)
198 .map_err(|e| format!("agentlastruntime: {e}"))?;
199 }
200 let runtime = mstore
201 .agent_last_runtime_get(&cmd.id)
202 .map_err(|e| format!("agentlastruntime: {e}"))?;
203 Ok(AgentLastRuntime { runtime })
204 }
205 },
206 );
207
208 let mstore = state.mstore.clone();
209 let broker = state.broker.clone();
210 let identity_store_del = state.identity_store.clone();
211 let id_store_del = state.id_store.clone();
212 engine.register_typed(
213 COMMAND_DELETE_AGENT_INSTANCE,
214 move |cmd: CommandDeleteAgentInstanceData, _ctx| {
215 let mstore = mstore.clone();
216 let broker = broker.clone();
217 let identity_store = identity_store_del.clone();
218 let id_store = id_store_del.clone();
219 async move {
220 let definition_id = mstore
222 .instance_get(&cmd.id)
223 .map_err(|e| format!("deleteagentinstance: {e}"))?
224 .map(|i| i.definition_id);
225 let deleted = mstore
226 .instance_delete(&cmd.id)
227 .map_err(|e| format!("deleteagentinstance: {e}"))?;
228 super::purge_identity_store_rows(&identity_store, &id_store, &cmd.id, "deleteagentinstance");
238 if let Some(def_id) = definition_id.filter(|_| deleted) {
239 broker.publish_event_for::<crate::backend::mps_events::AgentInstancesChanged>(&def_id, &(), vec![], 0);
240 }
241 Ok(DeleteAgentInstanceResult { deleted })
242 }
243 },
244 );
245
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::backend::rpc::engine::WshRpcEngine;
252 use crate::server::tests::test_state;
253
254 #[tokio::test]
262 async fn register_typed_records_the_four_instance_crud_commands() {
263 let state = crate::server::tests::test_state();
264 let (engine, _rx) = WshRpcEngine::new();
265 register(&engine, &state);
266 let schema = engine.schema_json();
267 let rows = schema.as_array().unwrap();
268 let find = |cmd: &str| {
269 rows.iter()
270 .find(|r| r["command"] == cmd)
271 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
272 };
273
274 for (cmd, resp) in [
275 (crate::backend::rpc_types::COMMAND_GET_AGENT_INSTANCE, "AgentInstance"),
276 (crate::backend::rpc_types::COMMAND_CREATE_AGENT_INSTANCE, "AgentInstance"),
277 (crate::backend::rpc_types::COMMAND_UPDATE_AGENT_INSTANCE, "AgentInstance"),
278 ] {
279 assert_eq!(find(cmd)["responseName"], resp, "{cmd} response");
280 }
281 let list = find(crate::backend::rpc_types::COMMAND_LIST_AGENT_INSTANCES);
282 let list_resp = list["responseName"].as_str().unwrap();
283 assert!(
284 list_resp.starts_with("alloc::vec::Vec<") && list_resp.contains("AgentInstance"),
285 "listagentinstances should answer Vec<AgentInstance>, got {list_resp:?}",
286 );
287 }
288
289 #[tokio::test]
302 async fn listagentinstances_accepts_no_filter_but_rejects_a_malformed_one() {
303 let state = crate::server::tests::test_state();
304 let (engine, mut rx) = WshRpcEngine::new();
305 register(&engine, &state);
306
307 let call = |payload: serde_json::Value, reqid: &str| {
308 engine.handle_message(crate::backend::rpc_types::RpcMessage {
309 command: crate::backend::rpc_types::COMMAND_LIST_AGENT_INSTANCES.to_string(),
310 reqid: reqid.to_string(),
311 data: Some(payload),
312 ..Default::default()
313 });
314 };
315
316 for (i, payload) in [serde_json::json!({}), serde_json::Value::Null]
317 .into_iter()
318 .enumerate()
319 {
320 call(payload.clone(), &format!("ok-{i}"));
321 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
322 .await
323 .unwrap()
324 .unwrap();
325 assert!(
326 resp.error.is_empty(),
327 "{payload} means no filter and must be accepted, got {:?}",
328 resp.error,
329 );
330 }
331
332 call(serde_json::json!({ "status": 5 }), "bad");
333 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
334 .await
335 .unwrap()
336 .unwrap();
337 assert!(
338 !resp.error.is_empty(),
339 "a malformed filter must not be silently answered with the unfiltered list",
340 );
341 }
342
343 #[tokio::test]
345 async fn register_typed_records_deleteagentinstance() {
346 let state = test_state();
347 let (engine, _rx) = WshRpcEngine::new();
348 register(&engine, &state);
349 let schema = engine.schema_json();
350 let rows = schema.as_array().unwrap();
351 let row = rows
352 .iter()
353 .find(|r| r["command"] == COMMAND_DELETE_AGENT_INSTANCE)
354 .expect("deleteagentinstance missing from the schema");
355 assert_eq!(row["requestName"], "CommandDeleteAgentInstanceData");
356 assert_eq!(row["responseName"], "DeleteAgentInstanceResult");
357 for cmd in [
365 COMMAND_LIST_AGENT_INSTANCES,
366 COMMAND_GET_AGENT_INSTANCE,
367 COMMAND_CREATE_AGENT_INSTANCE,
368 COMMAND_UPDATE_AGENT_INSTANCE,
369 ] {
370 assert!(
371 rows.iter().any(|r| r["command"] == cmd),
372 "{cmd} is migrated and must appear in the schema",
373 );
374 }
375 }
376
377 #[tokio::test]
380 async fn agentlastruntime_reads_replaces_forgets_and_refuses_bad_values() {
381 let state = crate::server::tests::test_state();
382 let mut agent: crate::backend::storage::AgentDefinition = serde_json::from_value(serde_json::json!({
383 "id": "rt-agent",
384 "name": "rt-agent",
385 "icon": "wand-magic-sparkles",
386 "provider": "claude",
387 "description": "",
388 "created_at": 1,
389 }))
390 .unwrap();
391 state.mstore.agent_def_insert(&mut agent).unwrap();
392 let (engine, mut rx) = WshRpcEngine::new();
393 register(&engine, &state);
394
395 let mut n = 0;
396 let mut call = |payload: serde_json::Value| {
397 n += 1;
398 engine.handle_message(crate::backend::rpc_types::RpcMessage {
399 command: COMMAND_AGENT_LAST_RUNTIME.to_string(),
400 reqid: format!("r{n}"),
401 data: Some(payload),
402 ..Default::default()
403 });
404 };
405 macro_rules! next {
406 () => {
407 tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
408 .await
409 .unwrap()
410 .unwrap()
411 };
412 }
413
414 call(serde_json::json!({ "id": "rt-agent" }));
415 let r = next!();
416 assert!(r.error.is_empty(), "{:?}", r.error);
417 assert_eq!(r.data.unwrap()["runtime"], "");
418
419 call(serde_json::json!({ "id": "rt-agent", "runtime": r#"{"model":"opus","effort":"high"}"# }));
420 let r = next!();
421 assert!(r.error.is_empty(), "{:?}", r.error);
422 assert_eq!(r.data.unwrap()["runtime"], r#"{"effort":"high","model":"opus"}"#);
423
424 call(serde_json::json!({ "id": "rt-agent", "runtime": r#"{"colour":"red"}"# }));
425 let r = next!();
426 assert!(!r.error.is_empty(), "an unknown key is refused");
427
428 call(serde_json::json!({ "id": "rt-agent" }));
429 assert_eq!(next!().data.unwrap()["runtime"], r#"{"effort":"high","model":"opus"}"#, "a refused write changes nothing");
430
431 call(serde_json::json!({ "id": "rt-agent", "runtime": "" }));
432 assert_eq!(next!().data.unwrap()["runtime"], "");
433 }
434}