1use std::sync::Arc;
5use chrono::Utc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde_json::json;
9
10use crate::backend::rpc::engine::WshRpcEngine;
11use crate::backend::rpc_types::{
12 COMMAND_LIST_AGENTS, COMMAND_CREATE_AGENT, COMMAND_UPDATE_AGENT,
13 COMMAND_DELETE_AGENT, COMMAND_GET_AGENT_CONTENT, COMMAND_SET_AGENT_CONTENT,
14 COMMAND_GET_ALL_AGENT_CONTENT,
15 COMMAND_LIST_AGENT_SKILLS, COMMAND_CREATE_AGENT_SKILL, COMMAND_UPDATE_AGENT_SKILL,
16 COMMAND_DELETE_AGENT_SKILL,
17 COMMAND_APPEND_AGENT_HISTORY, COMMAND_LIST_AGENT_HISTORY, COMMAND_SEARCH_AGENT_HISTORY,
18 COMMAND_IMPORT_AGENT_FROM_CLAW, COMMAND_IMPORT_AGENTS, COMMAND_EXPORT_AGENTS,
19 COMMAND_RESEED_AGENTS,
20 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
22 CommandAgentDefCreateFromTemplateData, AgentDefCreateFromTemplateResult,
23 CommandListAgentDefinitionsData,
24 COMMAND_AGENT_DEF_HIDE, COMMAND_AGENT_DEF_UNHIDE,
27 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
28 CommandAgentDefHideData, AgentDefHideResult,
29 CommandCreateAgentDefinitionData, CommandUpdateAgentDefinitionData, CommandDeleteAgentDefinitionData,
30 CommandGetAgentContentData, CommandSetAgentContentData, CommandGetAllAgentContentData,
31 CommandListAgentSkillsData, CommandCreateAgentSkillData, CommandUpdateAgentSkillData,
32 CommandDeleteAgentSkillData,
33 CommandAppendAgentHistoryData, CommandListAgentHistoryData, CommandSearchAgentHistoryData,
34 CommandImportAgentFromClawData,
35 CommandImportAgentDefinitionsData, ImportAgentDefinitionsResult,
36 ExportAgentDefinitionsResult, AgentDefinitionExport, AgentSkillExport,
37 COMMAND_LIST_IDENTITY_ACCOUNTS, COMMAND_GET_IDENTITY_ACCOUNT,
39 COMMAND_UPSERT_IDENTITY_ACCOUNT, COMMAND_DELETE_IDENTITY_ACCOUNT,
40 COMMAND_LINK_AGENT_IDENTITY, COMMAND_UNLINK_AGENT_IDENTITY,
41 COMMAND_LIST_AGENT_IDENTITIES,
42 COMMAND_LIST_AGENT_INSTANCES, COMMAND_GET_AGENT_INSTANCE,
43 COMMAND_CREATE_AGENT_INSTANCE, COMMAND_UPDATE_AGENT_INSTANCE,
44 COMMAND_DELETE_AGENT_INSTANCE,
45 COMMAND_LIST_NAMED_AGENTS, COMMAND_HIDE_NAMED_AGENT,
46 CommandListNamedAgentsData, CommandHideNamedAgentData,
47 NamedAgentRow,
48 COMMAND_LIST_RECENT_SESSIONS, CommandListRecentSessionsData,
49 RecentSessionRow,
50 COMMAND_AGENT_SESSION_READ, COMMAND_AGENT_SESSION_WRITE_STATE,
52 COMMAND_AGENT_SESSION_APPEND_OUTPUT, COMMAND_AGENT_SESSION_ARCHIVE,
53 COMMAND_AGENT_SESSION_LIST_ARCHIVES,
54 CommandAgentSessionReadData, AgentSessionReadResult,
55 CommandAgentSessionWriteStateData, AgentSessionWriteStateResult,
56 CommandAgentSessionAppendOutputData, AgentSessionAppendOutputResult,
57 CommandAgentSessionArchiveData, AgentSessionArchiveResult,
58 CommandAgentSessionListArchivesData, AgentArchiveRow,
59 COMMAND_FORK_AGENT_DEFINITION,
60 CommandListIdentityAccountsData, CommandGetIdentityAccountData,
61 CommandDeleteIdentityAccountData,
62 CommandLinkAgentIdentityData, CommandUnlinkAgentIdentityData,
63 CommandListAgentIdentitiesData,
64 CommandListAgentInstancesData, CommandGetAgentInstanceData,
65 CommandCreateAgentInstanceData, CommandUpdateAgentInstanceData,
66 CommandDeleteAgentInstanceData,
67 CommandForkAgentDefinitionData,
68 COMMAND_LIST_IDENTITY_BUNDLES, COMMAND_GET_IDENTITY_BUNDLE,
70 COMMAND_UPSERT_IDENTITY_BUNDLE, COMMAND_DELETE_IDENTITY_BUNDLE,
71 COMMAND_BIND_IDENTITY_ACCOUNT, COMMAND_UNBIND_IDENTITY_ACCOUNT,
72 COMMAND_LIST_IDENTITY_BINDINGS,
73 COMMAND_LIST_MEMORIES, COMMAND_GET_MEMORY,
74 COMMAND_UPSERT_MEMORY, COMMAND_DELETE_MEMORY,
75 CommandGetIdentityBundleData, CommandDeleteIdentityBundleData,
76 CommandBindIdentityAccountData, CommandUnbindIdentityAccountData,
77 CommandListIdentityBindingsData,
78 CommandGetMemoryData, CommandDeleteMemoryData,
79};
80use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
81use crate::backend::storage::wstore::{
82 AgentInstance, Identity, IdentityAccount, InstanceStatus, Memory,
83};
84
85use super::AppState;
86
87pub fn register_agent_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
88 let wstore_lfa = state.wstore.clone();
103 engine.register_handler(
104 COMMAND_LIST_AGENTS,
105 Box::new(move |data, _ctx| {
106 let wstore = wstore_lfa.clone();
107 Box::pin(async move {
108 let cmd: CommandListAgentDefinitionsData =
114 serde_json::from_value(data).unwrap_or_default();
115 let agents = wstore.agent_def_list().map_err(|e| format!("listagents: {e}"))?;
116 let is_seeded_filter = cmd.is_seeded;
117 let include_hidden = cmd.include_hidden;
118 let filtered: Vec<_> = agents
119 .into_iter()
120 .filter(|a| match is_seeded_filter {
121 Some(flag) => a.is_seeded == flag,
122 None => true,
123 })
124 .filter(|a| {
130 include_hidden || a.is_seeded != 1 || a.user_hidden == 0
131 })
132 .collect();
133 Ok(Some(serde_json::to_value(&filtered).unwrap_or_default()))
134 })
135 }),
136 );
137
138 let wstore_cfa = state.wstore.clone();
140 let broker_cfa = state.broker.clone();
141 engine.register_handler(
142 COMMAND_CREATE_AGENT,
143 Box::new(move |data, _ctx| {
144 let wstore = wstore_cfa.clone();
145 let broker = broker_cfa.clone();
146 Box::pin(async move {
147 let cmd: CommandCreateAgentDefinitionData = serde_json::from_value(data)
148 .map_err(|e| format!("createagent: {e}"))?;
149 let now = SystemTime::now()
150 .duration_since(UNIX_EPOCH)
151 .unwrap_or_default()
152 .as_millis() as i64;
153 let mut agent = AgentDefinition {
158 id: uuid::Uuid::new_v4().to_string(),
159 slug: String::new(),
160 name: cmd.name,
161 icon: cmd.icon,
162 provider: cmd.provider,
163 description: cmd.description,
164 working_directory: cmd.working_directory,
165 shell: cmd.shell,
166 provider_flags: cmd.provider_flags,
167 auto_start: cmd.auto_start,
168 restart_on_crash: cmd.restart_on_crash,
169 idle_timeout_minutes: cmd.idle_timeout_minutes,
170 created_at: now,
171 agent_type: cmd.agent_type,
172 environment: cmd.environment,
173 agent_bus_id: cmd.agent_bus_id,
174 is_seeded: 0,
175 accounts: String::new(),
176 parent_id: String::new(),
177 branch_label: String::new(),
178 updated_at: now,
179 user_hidden: 0,
180 };
181 wstore.agent_def_insert(&mut agent).map_err(|e| format!("createagent: {e}"))?;
182 broker.publish(crate::backend::wps::WaveEvent {
183 event: "agents:changed".to_string(),
184 scopes: vec![],
185 sender: String::new(),
186 persist: 0,
187 data: None,
188 });
189 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
190 })
191 }),
192 );
193
194 let wstore_ufa = state.wstore.clone();
196 let broker_ufa = state.broker.clone();
197 engine.register_handler(
198 COMMAND_UPDATE_AGENT,
199 Box::new(move |data, _ctx| {
200 let wstore = wstore_ufa.clone();
201 let broker = broker_ufa.clone();
202 Box::pin(async move {
203 let cmd: CommandUpdateAgentDefinitionData = serde_json::from_value(data)
204 .map_err(|e| format!("updateagent: {e}"))?;
205 let existing = wstore.agent_def_list().map_err(|e| format!("updateagent: {e}"))?;
207 let old = existing.iter().find(|a| a.id == cmd.id)
208 .ok_or_else(|| format!("updateagent: agent {} not found", cmd.id))?;
209 let mut agent = AgentDefinition {
213 id: cmd.id,
214 slug: old.slug.clone(),
215 name: cmd.name,
216 icon: cmd.icon,
217 provider: cmd.provider,
218 description: cmd.description,
219 working_directory: cmd.working_directory,
220 shell: cmd.shell,
221 provider_flags: cmd.provider_flags,
222 auto_start: cmd.auto_start,
223 restart_on_crash: cmd.restart_on_crash,
224 idle_timeout_minutes: cmd.idle_timeout_minutes,
225 created_at: old.created_at,
226 agent_type: cmd.agent_type,
227 environment: cmd.environment,
228 agent_bus_id: cmd.agent_bus_id,
229 is_seeded: old.is_seeded,
230 accounts: if cmd.accounts.is_empty() { old.accounts.clone() } else { cmd.accounts },
236 parent_id: old.parent_id.clone(),
240 branch_label: old.branch_label.clone(),
241 updated_at: old.updated_at,
245 user_hidden: old.user_hidden,
251 };
252 let found = wstore.agent_def_update(&mut agent).map_err(|e| format!("updateagent: {e}"))?;
253 if !found {
254 return Err(format!("updateagent: agent {} not found", agent.id));
255 }
256 broker.publish(crate::backend::wps::WaveEvent {
257 event: "agents:changed".to_string(),
258 scopes: vec![],
259 sender: String::new(),
260 persist: 0,
261 data: None,
262 });
263 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
264 })
265 }),
266 );
267
268 let wstore_dfa = state.wstore.clone();
270 let broker_dfa = state.broker.clone();
271 engine.register_handler(
272 COMMAND_DELETE_AGENT,
273 Box::new(move |data, _ctx| {
274 let wstore = wstore_dfa.clone();
275 let broker = broker_dfa.clone();
276 Box::pin(async move {
277 let cmd: CommandDeleteAgentDefinitionData = serde_json::from_value(data)
278 .map_err(|e| format!("deleteagent: {e}"))?;
279 wstore.agent_def_delete(&cmd.id).map_err(|e| format!("deleteagent: {e}"))?;
280 broker.publish(crate::backend::wps::WaveEvent {
281 event: "agents:changed".to_string(),
282 scopes: vec![],
283 sender: String::new(),
284 persist: 0,
285 data: None,
286 });
287 Ok(None)
288 })
289 }),
290 );
291
292 let wstore_act = state.wstore.clone();
306 let broker_act = state.broker.clone();
307 engine.register_handler(
308 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
309 Box::new(move |data, _ctx| {
310 let wstore = wstore_act.clone();
311 let broker = broker_act.clone();
312 Box::pin(async move {
313 let cmd: CommandAgentDefCreateFromTemplateData = serde_json::from_value(data)
314 .map_err(|e| format!("agentdefcreatefromtemplate: {e}"))?;
315 let name = cmd.name.trim().to_string();
316 if name.is_empty() {
317 return Err("agentdefcreatefromtemplate: name must be non-empty".into());
318 }
319 if name.chars().count() > 200 {
320 return Err(
321 "agentdefcreatefromtemplate: name must be ≤200 characters".into(),
322 );
323 }
324
325 let all = wstore
326 .agent_def_list()
327 .map_err(|e| format!("agentdefcreatefromtemplate: list: {e}"))?;
328 let template = all
329 .iter()
330 .find(|a| a.id == cmd.template_id)
331 .ok_or_else(|| {
332 format!(
333 "agentdefcreatefromtemplate: template {} not found",
334 cmd.template_id
335 )
336 })?;
337 if template.is_seeded != 1 {
338 return Err(format!(
339 "agentdefcreatefromtemplate: {} is not a seeded template (is_seeded={})",
340 cmd.template_id, template.is_seeded
341 ));
342 }
343 if all
344 .iter()
345 .any(|a| a.is_seeded == 0 && a.name.eq_ignore_ascii_case(&name))
346 {
347 return Err(format!(
348 "agentdefcreatefromtemplate: an agent named {:?} already exists",
349 name
350 ));
351 }
352
353 let now = SystemTime::now()
354 .duration_since(UNIX_EPOCH)
355 .map(|d| d.as_millis() as i64)
356 .unwrap_or(0);
357 let mut new_def = AgentDefinition {
358 id: uuid::Uuid::new_v4().to_string(),
359 slug: String::new(),
362 name: name.clone(),
363 icon: template.icon.clone(),
364 provider: template.provider.clone(),
365 description: template.description.clone(),
366 working_directory: String::new(),
370 shell: template.shell.clone(),
371 provider_flags: template.provider_flags.clone(),
372 auto_start: 0,
375 restart_on_crash: template.restart_on_crash,
376 idle_timeout_minutes: template.idle_timeout_minutes,
377 created_at: now,
378 agent_type: template.agent_type.clone(),
379 environment: template.environment.clone(),
380 agent_bus_id: String::new(),
381 is_seeded: 0,
382 accounts: String::new(),
383 parent_id: template.id.clone(),
384 branch_label: String::new(),
385 updated_at: now,
386 user_hidden: 0,
390 };
391 wstore
392 .agent_def_insert(&mut new_def)
393 .map_err(|e| format!("agentdefcreatefromtemplate: insert: {e}"))?;
394
395 broker.publish(crate::backend::wps::WaveEvent {
396 event: "agents:changed".to_string(),
397 scopes: vec![],
398 sender: String::new(),
399 persist: 0,
400 data: None,
401 });
402
403 let resp = AgentDefCreateFromTemplateResult {
404 definition_id: new_def.id.clone(),
405 identity_id: cmd.identity_id,
406 memory_id: cmd.memory_id,
407 };
408 tracing::info!(
409 template_id = %cmd.template_id,
410 new_definition_id = %new_def.id,
411 new_name = %new_def.name,
412 "agentdefcreatefromtemplate: cloned template into user agent"
413 );
414 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
415 })
416 }),
417 );
418
419 let wstore_hide = state.wstore.clone();
432 let broker_hide = state.broker.clone();
433 engine.register_handler(
434 COMMAND_AGENT_DEF_HIDE,
435 Box::new(move |data, _ctx| {
436 let wstore = wstore_hide.clone();
437 let broker = broker_hide.clone();
438 Box::pin(async move {
439 let cmd: CommandAgentDefHideData = serde_json::from_value(data)
440 .map_err(|e| format!("agentdefhide: {e}"))?;
441 let ok = wstore
442 .agent_def_set_hidden(&cmd.definition_id, true)
443 .map_err(|e| format!("agentdefhide: {e}"))?;
444 if ok {
445 broker.publish(crate::backend::wps::WaveEvent {
446 event: "agents:changed".to_string(),
447 scopes: vec![],
448 sender: String::new(),
449 persist: 0,
450 data: None,
451 });
452 tracing::info!(
453 definition_id = %cmd.definition_id,
454 "agentdefhide: hid template"
455 );
456 }
457 let resp = AgentDefHideResult { ok };
458 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
459 })
460 }),
461 );
462
463 let wstore_unhide = state.wstore.clone();
467 let broker_unhide = state.broker.clone();
468 engine.register_handler(
469 COMMAND_AGENT_DEF_UNHIDE,
470 Box::new(move |data, _ctx| {
471 let wstore = wstore_unhide.clone();
472 let broker = broker_unhide.clone();
473 Box::pin(async move {
474 let cmd: CommandAgentDefHideData = serde_json::from_value(data)
475 .map_err(|e| format!("agentdefunhide: {e}"))?;
476 let ok = wstore
477 .agent_def_set_hidden(&cmd.definition_id, false)
478 .map_err(|e| format!("agentdefunhide: {e}"))?;
479 if ok {
480 broker.publish(crate::backend::wps::WaveEvent {
481 event: "agents:changed".to_string(),
482 scopes: vec![],
483 sender: String::new(),
484 persist: 0,
485 data: None,
486 });
487 tracing::info!(
488 definition_id = %cmd.definition_id,
489 "agentdefunhide: unhid template"
490 );
491 }
492 let resp = AgentDefHideResult { ok };
493 Ok(Some(serde_json::to_value(&resp).unwrap_or_default()))
494 })
495 }),
496 );
497
498 let wstore_lh = state.wstore.clone();
503 engine.register_handler(
504 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
505 Box::new(move |_data, _ctx| {
506 let wstore = wstore_lh.clone();
507 Box::pin(async move {
508 let agents = wstore
509 .agent_def_list()
510 .map_err(|e| format!("agentdeflisthiddentemplates: {e}"))?;
511 let hidden: Vec<_> = agents
512 .into_iter()
513 .filter(|a| a.is_seeded == 1 && a.user_hidden == 1)
514 .collect();
515 Ok(Some(serde_json::to_value(&hidden).unwrap_or_default()))
516 })
517 }),
518 );
519
520 let wstore_gfc = state.wstore.clone();
522 engine.register_handler(
523 COMMAND_GET_AGENT_CONTENT,
524 Box::new(move |data, _ctx| {
525 let wstore = wstore_gfc.clone();
526 Box::pin(async move {
527 let cmd: CommandGetAgentContentData = serde_json::from_value(data)
528 .map_err(|e| format!("getagentcontent: {e}"))?;
529 let content = wstore.agent_content_get(&cmd.agent_id, &cmd.content_type)
530 .map_err(|e| format!("getagentcontent: {e}"))?;
531 Ok(content.map(|c| serde_json::to_value(&c).unwrap_or_default()))
532 })
533 }),
534 );
535
536 let wstore_sfc = state.wstore.clone();
538 let broker_sfc = state.broker.clone();
539 engine.register_handler(
540 COMMAND_SET_AGENT_CONTENT,
541 Box::new(move |data, _ctx| {
542 let wstore = wstore_sfc.clone();
543 let broker = broker_sfc.clone();
544 Box::pin(async move {
545 let cmd: CommandSetAgentContentData = serde_json::from_value(data)
546 .map_err(|e| format!("setagentcontent: {e}"))?;
547 let now = SystemTime::now()
548 .duration_since(UNIX_EPOCH)
549 .unwrap_or_default()
550 .as_millis() as i64;
551 let content = AgentContent {
552 agent_id: cmd.agent_id,
553 content_type: cmd.content_type,
554 content: cmd.content,
555 updated_at: now,
556 };
557 wstore.agent_content_set(&content).map_err(|e| format!("setagentcontent: {e}"))?;
558 broker.publish(crate::backend::wps::WaveEvent {
559 event: "agentcontent:changed".to_string(),
560 scopes: vec![],
561 sender: String::new(),
562 persist: 0,
563 data: None,
564 });
565 Ok(Some(serde_json::to_value(&content).unwrap_or_default()))
566 })
567 }),
568 );
569
570 let wstore_gafc = state.wstore.clone();
572 engine.register_handler(
573 COMMAND_GET_ALL_AGENT_CONTENT,
574 Box::new(move |data, _ctx| {
575 let wstore = wstore_gafc.clone();
576 Box::pin(async move {
577 let cmd: CommandGetAllAgentContentData = serde_json::from_value(data)
578 .map_err(|e| format!("getallagentcontent: {e}"))?;
579 let contents = wstore.agent_content_get_all(&cmd.agent_id)
580 .map_err(|e| format!("getallagentcontent: {e}"))?;
581 Ok(Some(serde_json::to_value(&contents).unwrap_or_default()))
582 })
583 }),
584 );
585
586 let wstore_lfs = state.wstore.clone();
590 engine.register_handler(
591 COMMAND_LIST_AGENT_SKILLS,
592 Box::new(move |data, _ctx| {
593 let wstore = wstore_lfs.clone();
594 Box::pin(async move {
595 let cmd: CommandListAgentSkillsData = serde_json::from_value(data)
596 .map_err(|e| format!("listagentskills: {e}"))?;
597 let skills = wstore.agent_skill_list(&cmd.agent_id)
598 .map_err(|e| format!("listagentskills: {e}"))?;
599 Ok(Some(serde_json::to_value(&skills).unwrap_or_default()))
600 })
601 }),
602 );
603
604 let wstore_cfs = state.wstore.clone();
606 let broker_cfs = state.broker.clone();
607 engine.register_handler(
608 COMMAND_CREATE_AGENT_SKILL,
609 Box::new(move |data, _ctx| {
610 let wstore = wstore_cfs.clone();
611 let broker = broker_cfs.clone();
612 Box::pin(async move {
613 let cmd: CommandCreateAgentSkillData = serde_json::from_value(data)
614 .map_err(|e| format!("createagentskill: {e}"))?;
615 let now = SystemTime::now()
616 .duration_since(UNIX_EPOCH)
617 .unwrap_or_default()
618 .as_millis() as i64;
619 let skill = AgentSkill {
620 id: uuid::Uuid::new_v4().to_string(),
621 agent_id: cmd.agent_id,
622 name: cmd.name,
623 trigger: cmd.trigger,
624 skill_type: cmd.skill_type,
625 description: cmd.description,
626 content: cmd.content,
627 created_at: now,
628 };
629 wstore.agent_skill_insert(&skill).map_err(|e| format!("createagentskill: {e}"))?;
630 broker.publish(crate::backend::wps::WaveEvent {
631 event: "agentskills:changed".to_string(),
632 scopes: vec![],
633 sender: String::new(),
634 persist: 0,
635 data: None,
636 });
637 Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
638 })
639 }),
640 );
641
642 let wstore_ufs = state.wstore.clone();
644 let broker_ufs = state.broker.clone();
645 engine.register_handler(
646 COMMAND_UPDATE_AGENT_SKILL,
647 Box::new(move |data, _ctx| {
648 let wstore = wstore_ufs.clone();
649 let broker = broker_ufs.clone();
650 Box::pin(async move {
651 let cmd: CommandUpdateAgentSkillData = serde_json::from_value(data)
652 .map_err(|e| format!("updateagentskill: {e}"))?;
653 let existing = wstore.agent_skill_get(&cmd.id)
654 .map_err(|e| format!("updateagentskill: {e}"))?
655 .ok_or_else(|| format!("updateagentskill: skill {} not found", cmd.id))?;
656 let skill = AgentSkill {
657 id: cmd.id,
658 agent_id: existing.agent_id,
659 name: cmd.name,
660 trigger: cmd.trigger,
661 skill_type: cmd.skill_type,
662 description: cmd.description,
663 content: cmd.content,
664 created_at: existing.created_at,
665 };
666 let found = wstore.agent_skill_update(&skill).map_err(|e| format!("updateagentskill: {e}"))?;
667 if !found {
668 return Err(format!("updateagentskill: skill {} not found", skill.id));
669 }
670 broker.publish(crate::backend::wps::WaveEvent {
671 event: "agentskills:changed".to_string(),
672 scopes: vec![],
673 sender: String::new(),
674 persist: 0,
675 data: None,
676 });
677 Ok(Some(serde_json::to_value(&skill).unwrap_or_default()))
678 })
679 }),
680 );
681
682 let wstore_dfs = state.wstore.clone();
684 let broker_dfs = state.broker.clone();
685 engine.register_handler(
686 COMMAND_DELETE_AGENT_SKILL,
687 Box::new(move |data, _ctx| {
688 let wstore = wstore_dfs.clone();
689 let broker = broker_dfs.clone();
690 Box::pin(async move {
691 let cmd: CommandDeleteAgentSkillData = serde_json::from_value(data)
692 .map_err(|e| format!("deleteagentskill: {e}"))?;
693 wstore.agent_skill_delete(&cmd.id).map_err(|e| format!("deleteagentskill: {e}"))?;
694 broker.publish(crate::backend::wps::WaveEvent {
695 event: "agentskills:changed".to_string(),
696 scopes: vec![],
697 sender: String::new(),
698 persist: 0,
699 data: None,
700 });
701 Ok(None)
702 })
703 }),
704 );
705
706 let wstore_afh = state.wstore.clone();
710 let broker_afh = state.broker.clone();
711 engine.register_handler(
712 COMMAND_APPEND_AGENT_HISTORY,
713 Box::new(move |data, _ctx| {
714 let wstore = wstore_afh.clone();
715 let broker = broker_afh.clone();
716 Box::pin(async move {
717 let cmd: CommandAppendAgentHistoryData = serde_json::from_value(data)
718 .map_err(|e| format!("appendagenthistory: {e}"))?;
719 let entry = wstore.agent_history_append(&cmd.agent_id, &cmd.entry)
720 .map_err(|e| format!("appendagenthistory: {e}"))?;
721 broker.publish(crate::backend::wps::WaveEvent {
722 event: "agenthistory:changed".to_string(),
723 scopes: vec![],
724 sender: String::new(),
725 persist: 0,
726 data: None,
727 });
728 Ok(Some(serde_json::to_value(&entry).unwrap_or_default()))
729 })
730 }),
731 );
732
733 let wstore_lfh = state.wstore.clone();
735 engine.register_handler(
736 COMMAND_LIST_AGENT_HISTORY,
737 Box::new(move |data, _ctx| {
738 let wstore = wstore_lfh.clone();
739 Box::pin(async move {
740 let cmd: CommandListAgentHistoryData = serde_json::from_value(data)
741 .map_err(|e| format!("listagenthistory: {e}"))?;
742 let entries = wstore.agent_history_list(
743 &cmd.agent_id,
744 cmd.session_date.as_deref(),
745 cmd.limit,
746 cmd.offset,
747 ).map_err(|e| format!("listagenthistory: {e}"))?;
748 Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
749 })
750 }),
751 );
752
753 let wstore_sfh = state.wstore.clone();
755 engine.register_handler(
756 COMMAND_SEARCH_AGENT_HISTORY,
757 Box::new(move |data, _ctx| {
758 let wstore = wstore_sfh.clone();
759 Box::pin(async move {
760 let cmd: CommandSearchAgentHistoryData = serde_json::from_value(data)
761 .map_err(|e| format!("searchagenthistory: {e}"))?;
762 let entries = wstore.agent_history_search(&cmd.agent_id, &cmd.query, cmd.limit)
763 .map_err(|e| format!("searchagenthistory: {e}"))?;
764 Ok(Some(serde_json::to_value(&entries).unwrap_or_default()))
765 })
766 }),
767 );
768
769 let wstore_ifc = state.wstore.clone();
773 let broker_ifc = state.broker.clone();
774 engine.register_handler(
775 COMMAND_IMPORT_AGENT_FROM_CLAW,
776 Box::new(move |data, _ctx| {
777 let wstore = wstore_ifc.clone();
778 let broker = broker_ifc.clone();
779 Box::pin(async move {
780 let cmd: CommandImportAgentFromClawData = serde_json::from_value(data)
781 .map_err(|e| format!("importagentfromclaw: {e}"))?;
782
783 let workspace_path = std::path::Path::new(&cmd.workspace_path);
784 if !workspace_path.exists() {
785 return Err(format!("importagentfromclaw: path does not exist: {}", cmd.workspace_path));
786 }
787
788 let now = SystemTime::now()
789 .duration_since(UNIX_EPOCH)
790 .unwrap_or_default()
791 .as_millis() as i64;
792
793 let mut provider = "claude".to_string();
795 let settings_path = workspace_path.join(".claude").join("settings.json");
796 if settings_path.exists() {
797 if let Ok(settings_str) = std::fs::read_to_string(&settings_path) {
798 if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&settings_str) {
799 if let Some(p) = settings.get("provider").and_then(|v| v.as_str()) {
800 provider = p.to_string();
801 }
802 }
803 }
804 }
805
806 let mut agent = AgentDefinition {
810 id: uuid::Uuid::new_v4().to_string(),
811 slug: String::new(),
812 name: cmd.agent_name.clone(),
813 icon: "\u{2726}".to_string(),
814 provider,
815 description: format!("Imported from {}", cmd.workspace_path),
816 working_directory: cmd.workspace_path.clone(),
817 shell: String::new(),
818 provider_flags: String::new(),
819 auto_start: 0,
820 restart_on_crash: 0,
821 idle_timeout_minutes: 0,
822 created_at: now,
823 agent_type: "standalone".to_string(),
824 environment: String::new(),
825 agent_bus_id: String::new(),
826 is_seeded: 0,
827 accounts: String::new(),
828 parent_id: String::new(),
829 branch_label: String::new(),
830 updated_at: now,
831 user_hidden: 0,
832 };
833 wstore.agent_def_insert(&mut agent).map_err(|e| format!("importagentfromclaw: {e}"))?;
834
835 let claude_md_path = workspace_path.join("CLAUDE.md");
837 if claude_md_path.exists() {
838 if let Ok(content) = std::fs::read_to_string(&claude_md_path) {
839 let fc = AgentContent {
840 agent_id: agent.id.clone(),
841 content_type: "agentmd".to_string(),
842 content,
843 updated_at: now,
844 };
845 let _ = wstore.agent_content_set(&fc);
846 }
847 }
848
849 let mcp_path = workspace_path.join(".mcp.json");
851 if mcp_path.exists() {
852 if let Ok(content) = std::fs::read_to_string(&mcp_path) {
853 let fc = AgentContent {
854 agent_id: agent.id.clone(),
855 content_type: "mcp".to_string(),
856 content,
857 updated_at: now,
858 };
859 let _ = wstore.agent_content_set(&fc);
860 }
861 }
862
863 broker.publish(crate::backend::wps::WaveEvent {
864 event: "agents:changed".to_string(),
865 scopes: vec![],
866 sender: String::new(),
867 persist: 0,
868 data: None,
869 });
870 Ok(Some(serde_json::to_value(&agent).unwrap_or_default()))
871 })
872 }),
873 );
874
875 let wstore_rsfa = state.wstore.clone();
877 let broker_rsfa = state.broker.clone();
878 engine.register_handler(
879 COMMAND_RESEED_AGENTS,
880 Box::new(move |_data, _ctx| {
881 let wstore = wstore_rsfa.clone();
882 let broker = broker_rsfa.clone();
883 Box::pin(async move {
884 let deleted = wstore.agent_def_delete_seeded()
886 .map_err(|e| format!("reseedagents: delete seeded: {e}"))?;
887
888 let report = crate::backend::agent_seed::seed_agents(&wstore)
890 .map_err(|e| format!("reseedagents: seed: {e}"))?;
891
892 broker.publish(crate::backend::wps::WaveEvent {
893 event: "agents:changed".to_string(),
894 scopes: vec![],
895 sender: String::new(),
896 persist: 0,
897 data: None,
898 });
899 Ok(Some(json!({
900 "deleted": deleted,
901 "created": report.created,
902 "skipped": report.skipped,
903 })))
904 })
905 }),
906 );
907
908 let wstore_ifa = state.wstore.clone();
910 let broker_ifa = state.broker.clone();
911 engine.register_handler(
912 COMMAND_IMPORT_AGENTS,
913 Box::new(move |data, _ctx| {
914 let wstore = wstore_ifa.clone();
915 let broker = broker_ifa.clone();
916 Box::pin(async move {
917 let cmd: CommandImportAgentDefinitionsData = serde_json::from_value(data)
918 .map_err(|e| format!("importagents: {e}"))?;
919
920 let now = SystemTime::now()
921 .duration_since(UNIX_EPOCH)
922 .unwrap_or_default()
923 .as_millis() as i64;
924
925 let mut imported: Vec<String> = Vec::new();
926 let mut skipped: Vec<String> = Vec::new();
927 let mut failed: Vec<String> = Vec::new();
928
929 for agent_import in cmd.agents {
930 let existing = wstore.agent_def_list()
932 .unwrap_or_default()
933 .into_iter()
934 .any(|a| a.slug == agent_import.id);
935
936 if existing {
937 skipped.push(agent_import.name.clone());
938 continue;
939 }
940
941 let mut agent = AgentDefinition {
942 id: uuid::Uuid::new_v4().to_string(),
943 slug: agent_import.id.clone(),
944 name: agent_import.name.clone(),
945 icon: agent_import.icon.clone(),
946 provider: agent_import.provider.clone(),
947 description: agent_import.description.clone(),
948 working_directory: agent_import.working_directory.clone(),
949 shell: agent_import.shell.clone(),
950 provider_flags: String::new(),
951 auto_start: 0,
952 restart_on_crash: if agent_import.restart_on_crash { 1 } else { 0 },
953 idle_timeout_minutes: 0,
954 created_at: now,
955 agent_type: agent_import.agent_type.clone(),
956 environment: agent_import.environment.clone(),
957 agent_bus_id: agent_import.agent_bus_id.clone(),
958 is_seeded: 0,
959 accounts: String::new(),
960 parent_id: String::new(),
961 branch_label: String::new(),
962 updated_at: now,
963 user_hidden: 0,
964 };
965
966 if let Err(e) = wstore.agent_def_insert(&mut agent) {
967 failed.push(format!("{}: {e}", agent_import.name));
968 continue;
969 }
970
971 let mut content_ok = true;
973 for (content_type, content) in &agent_import.content {
974 let fc = AgentContent {
975 agent_id: agent.id.clone(),
976 content_type: content_type.clone(),
977 content: content.clone(),
978 updated_at: now,
979 };
980 if let Err(e) = wstore.agent_content_set(&fc) {
981 tracing::warn!("import: failed to set content for agent {}: {e}", agent.id);
982 content_ok = false;
983 }
984 }
985
986 let mut skills_ok = true;
988 for skill_import in &agent_import.skills {
989 let skill = AgentSkill {
990 id: uuid::Uuid::new_v4().to_string(),
991 agent_id: agent.id.clone(),
992 name: skill_import.name.clone(),
993 trigger: skill_import.trigger.clone(),
994 skill_type: skill_import.skill_type.clone(),
995 description: skill_import.description.clone(),
996 content: skill_import.content.clone(),
997 created_at: now,
998 };
999 if let Err(e) = wstore.agent_skill_insert(&skill) {
1000 tracing::warn!("import: failed to insert skill '{}' for agent {}: {e}", skill.name, agent.id);
1001 skills_ok = false;
1002 }
1003 }
1004
1005 if content_ok && skills_ok {
1006 imported.push(agent_import.name.clone());
1007 } else {
1008 failed.push(agent_import.name.clone());
1009 }
1010 }
1011
1012 broker.publish(crate::backend::wps::WaveEvent {
1013 event: "agents:changed".to_string(),
1014 scopes: vec![],
1015 sender: String::new(),
1016 persist: 0,
1017 data: None,
1018 });
1019
1020 let result = ImportAgentDefinitionsResult { imported, skipped, failed };
1021 Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1022 })
1023 }),
1024 );
1025
1026 let wstore_efa = state.wstore.clone();
1028 engine.register_handler(
1029 COMMAND_EXPORT_AGENTS,
1030 Box::new(move |_data, _ctx| {
1031 let wstore = wstore_efa.clone();
1032 Box::pin(async move {
1033 let agents = wstore.agent_def_list()
1034 .map_err(|e| format!("exportagents: list: {e}"))?;
1035
1036 let mut agent_exports: Vec<AgentDefinitionExport> = Vec::new();
1037
1038 for agent in agents {
1039 let content_map = wstore.agent_content_get_all(&agent.id)
1040 .unwrap_or_default()
1041 .into_iter()
1042 .map(|fc| (fc.content_type, fc.content))
1043 .collect::<std::collections::HashMap<String, String>>();
1044
1045 let skills = wstore.agent_skill_list(&agent.id)
1046 .unwrap_or_default()
1047 .into_iter()
1048 .map(|s| AgentSkillExport {
1049 name: s.name,
1050 trigger: s.trigger,
1051 skill_type: s.skill_type,
1052 description: s.description,
1053 content: s.content,
1054 })
1055 .collect::<Vec<_>>();
1056
1057 agent_exports.push(AgentDefinitionExport {
1058 id: agent.slug.clone(),
1059 name: agent.name,
1060 icon: agent.icon,
1061 description: agent.description,
1062 provider: agent.provider,
1063 shell: agent.shell,
1064 working_directory: agent.working_directory,
1065 agent_bus_id: agent.agent_bus_id,
1066 agent_type: agent.agent_type,
1067 environment: agent.environment,
1068 restart_on_crash: agent.restart_on_crash != 0,
1069 content: content_map,
1070 skills,
1071 });
1072 }
1073
1074 let exported_at = Utc::now().to_rfc3339();
1075
1076 let result = ExportAgentDefinitionsResult {
1077 version: 4,
1078 exported_at,
1079 source: "agentmux-export".to_string(),
1080 agents: agent_exports,
1081 };
1082 Ok(Some(serde_json::to_value(&result).unwrap_or_default()))
1083 })
1084 }),
1085 );
1086
1087 register_v6_handlers(engine, state);
1088}
1089
1090fn register_v6_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
1093 let wstore = state.wstore.clone();
1096 engine.register_handler(
1097 COMMAND_LIST_IDENTITY_ACCOUNTS,
1098 Box::new(move |data, _ctx| {
1099 let wstore = wstore.clone();
1100 Box::pin(async move {
1101 let cmd: CommandListIdentityAccountsData =
1102 serde_json::from_value(data).unwrap_or_default();
1103 let accounts = wstore
1104 .identity_list(cmd.provider.as_deref())
1105 .map_err(|e| format!("listidentityaccounts: {e}"))?;
1106 Ok(Some(serde_json::to_value(&accounts).unwrap_or_default()))
1107 })
1108 }),
1109 );
1110
1111 let wstore = state.wstore.clone();
1112 engine.register_handler(
1113 COMMAND_GET_IDENTITY_ACCOUNT,
1114 Box::new(move |data, _ctx| {
1115 let wstore = wstore.clone();
1116 Box::pin(async move {
1117 let cmd: CommandGetIdentityAccountData =
1118 serde_json::from_value(data).map_err(|e| format!("getidentityaccount: {e}"))?;
1119 match wstore
1120 .identity_get(&cmd.id)
1121 .map_err(|e| format!("getidentityaccount: {e}"))?
1122 {
1123 Some(a) => Ok(Some(serde_json::to_value(&a).unwrap_or_default())),
1124 None => Err(format!("getidentityaccount: not found id={}", cmd.id)),
1125 }
1126 })
1127 }),
1128 );
1129
1130 let wstore = state.wstore.clone();
1131 let broker = state.broker.clone();
1132 engine.register_handler(
1133 COMMAND_UPSERT_IDENTITY_ACCOUNT,
1134 Box::new(move |data, _ctx| {
1135 let wstore = wstore.clone();
1136 let broker = broker.clone();
1137 Box::pin(async move {
1138 let mut account: IdentityAccount = serde_json::from_value(data)
1142 .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1143 if account.id.is_empty() {
1144 account.id = uuid::Uuid::new_v4().to_string();
1145 }
1146 let now = SystemTime::now()
1147 .duration_since(UNIX_EPOCH)
1148 .map(|d| d.as_millis() as i64)
1149 .unwrap_or(0);
1150 if account.created_at == 0 {
1151 account.created_at = now;
1152 }
1153 account.updated_at = now;
1154 wstore
1155 .identity_upsert(&account)
1156 .map_err(|e| format!("upsertidentityaccount: {e}"))?;
1157 broker.publish(crate::backend::wps::WaveEvent {
1158 event: "identityaccounts:changed".to_string(),
1159 scopes: vec![],
1160 sender: String::new(),
1161 persist: 0,
1162 data: None,
1163 });
1164 Ok(Some(serde_json::to_value(&account).unwrap_or_default()))
1165 })
1166 }),
1167 );
1168
1169 let wstore = state.wstore.clone();
1170 let broker = state.broker.clone();
1171 engine.register_handler(
1172 COMMAND_DELETE_IDENTITY_ACCOUNT,
1173 Box::new(move |data, _ctx| {
1174 let wstore = wstore.clone();
1175 let broker = broker.clone();
1176 Box::pin(async move {
1177 let cmd: CommandDeleteIdentityAccountData = serde_json::from_value(data)
1178 .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1179 let deleted = wstore
1180 .identity_delete(&cmd.id)
1181 .map_err(|e| format!("deleteidentityaccount: {e}"))?;
1182 if deleted {
1183 broker.publish(crate::backend::wps::WaveEvent {
1184 event: "identityaccounts:changed".to_string(),
1185 scopes: vec![],
1186 sender: String::new(),
1187 persist: 0,
1188 data: None,
1189 });
1190 }
1191 Ok(Some(json!({ "deleted": deleted })))
1192 })
1193 }),
1194 );
1195
1196 let wstore = state.wstore.clone();
1199 let broker = state.broker.clone();
1200 engine.register_handler(
1201 COMMAND_LINK_AGENT_IDENTITY,
1202 Box::new(move |data, _ctx| {
1203 let wstore = wstore.clone();
1204 let broker = broker.clone();
1205 Box::pin(async move {
1206 let cmd: CommandLinkAgentIdentityData = serde_json::from_value(data)
1207 .map_err(|e| format!("linkagentidentity: {e}"))?;
1208 wstore
1209 .agent_identity_link(&cmd.agent_id, &cmd.account_id, &cmd.provider)
1210 .map_err(|e| format!("linkagentidentity: {e}"))?;
1211 broker.publish(crate::backend::wps::WaveEvent {
1212 event: format!("agentidentities:changed:{}", cmd.agent_id),
1213 scopes: vec![],
1214 sender: String::new(),
1215 persist: 0,
1216 data: None,
1217 });
1218 Ok(None)
1219 })
1220 }),
1221 );
1222
1223 let wstore = state.wstore.clone();
1224 let broker = state.broker.clone();
1225 engine.register_handler(
1226 COMMAND_UNLINK_AGENT_IDENTITY,
1227 Box::new(move |data, _ctx| {
1228 let wstore = wstore.clone();
1229 let broker = broker.clone();
1230 Box::pin(async move {
1231 let cmd: CommandUnlinkAgentIdentityData = serde_json::from_value(data)
1232 .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1233 let removed = wstore
1234 .agent_identity_unlink(&cmd.agent_id, &cmd.provider)
1235 .map_err(|e| format!("unlinkagentidentity: {e}"))?;
1236 if removed {
1237 broker.publish(crate::backend::wps::WaveEvent {
1238 event: format!("agentidentities:changed:{}", cmd.agent_id),
1239 scopes: vec![],
1240 sender: String::new(),
1241 persist: 0,
1242 data: None,
1243 });
1244 }
1245 Ok(Some(json!({ "unlinked": removed })))
1246 })
1247 }),
1248 );
1249
1250 let wstore = state.wstore.clone();
1251 engine.register_handler(
1252 COMMAND_LIST_AGENT_IDENTITIES,
1253 Box::new(move |data, _ctx| {
1254 let wstore = wstore.clone();
1255 Box::pin(async move {
1256 let cmd: CommandListAgentIdentitiesData = serde_json::from_value(data)
1257 .map_err(|e| format!("listagentidentities: {e}"))?;
1258 let rows = wstore
1259 .agent_identity_list_for_agent(&cmd.agent_id)
1260 .map_err(|e| format!("listagentidentities: {e}"))?;
1261 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1262 })
1263 }),
1264 );
1265
1266 let wstore = state.wstore.clone();
1269 engine.register_handler(
1270 COMMAND_LIST_AGENT_INSTANCES,
1271 Box::new(move |data, _ctx| {
1272 let wstore = wstore.clone();
1273 Box::pin(async move {
1274 let cmd: CommandListAgentInstancesData =
1275 serde_json::from_value(data).unwrap_or_default();
1276 let rows = wstore
1277 .instance_list(cmd.definition_id.as_deref(), cmd.status.as_deref())
1278 .map_err(|e| format!("listagentinstances: {e}"))?;
1279 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1280 })
1281 }),
1282 );
1283
1284 let wstore = state.wstore.clone();
1285 engine.register_handler(
1286 COMMAND_GET_AGENT_INSTANCE,
1287 Box::new(move |data, _ctx| {
1288 let wstore = wstore.clone();
1289 Box::pin(async move {
1290 let cmd: CommandGetAgentInstanceData = serde_json::from_value(data)
1291 .map_err(|e| format!("getagentinstance: {e}"))?;
1292 match wstore
1293 .instance_get(&cmd.id)
1294 .map_err(|e| format!("getagentinstance: {e}"))?
1295 {
1296 Some(i) => Ok(Some(serde_json::to_value(&i).unwrap_or_default())),
1297 None => Err(format!("getagentinstance: not found id={}", cmd.id)),
1298 }
1299 })
1300 }),
1301 );
1302
1303 let wstore = state.wstore.clone();
1304 let broker = state.broker.clone();
1305 engine.register_handler(
1306 COMMAND_CREATE_AGENT_INSTANCE,
1307 Box::new(move |data, _ctx| {
1308 let wstore = wstore.clone();
1309 let broker = broker.clone();
1310 Box::pin(async move {
1311 let cmd: CommandCreateAgentInstanceData = serde_json::from_value(data)
1312 .map_err(|e| format!("createagentinstance: {e}"))?;
1313 let now = SystemTime::now()
1314 .duration_since(UNIX_EPOCH)
1315 .map(|d| d.as_millis() as i64)
1316 .unwrap_or(0);
1317 let inst = AgentInstance {
1318 id: uuid::Uuid::new_v4().to_string(),
1319 definition_id: cmd.definition_id,
1320 parent_instance_id: cmd.parent_instance_id,
1321 block_id: cmd.block_id,
1322 session_id: String::new(),
1323 status: InstanceStatus::Running.as_str().to_string(),
1324 github_context: String::new(),
1325 started_at: now,
1326 ended_at: 0,
1327 created_at: now,
1328 identity_id: cmd.identity_id,
1333 memory_id: cmd.memory_id,
1334 instance_name: cmd.instance_name.clone(),
1340 working_directory: cmd.working_directory.clone(),
1341 display_hidden: false,
1342 };
1343 wstore
1344 .instance_create(&inst)
1345 .map_err(|e| format!("createagentinstance: {e}"))?;
1346
1347 if !inst.block_id.is_empty()
1355 && crate::backend::agent_session::is_valid_definition_id(&inst.definition_id)
1356 {
1357 let zone = crate::backend::agent_session::agent_current_zone(
1358 &inst.definition_id,
1359 );
1360 let mut meta_update = crate::backend::obj::MetaMapType::new();
1361 meta_update.insert(
1362 "agent:sessionZone".to_string(),
1363 serde_json::json!(zone),
1364 );
1365 let oref_str = format!("block:{}", inst.block_id);
1366 if let Err(e) = crate::server::service::update_object_meta(
1367 &wstore, &oref_str, &meta_update,
1368 ) {
1369 tracing::warn!(
1374 block_id = %inst.block_id,
1375 definition_id = %inst.definition_id,
1376 error = %e,
1377 "createagentinstance: failed to stamp agent:sessionZone"
1378 );
1379 }
1380 }
1381
1382 broker.publish(crate::backend::wps::WaveEvent {
1383 event: format!("agentinstances:changed:{}", inst.definition_id),
1384 scopes: vec![],
1385 sender: String::new(),
1386 persist: 0,
1387 data: None,
1388 });
1389 Ok(Some(serde_json::to_value(&inst).unwrap_or_default()))
1390 })
1391 }),
1392 );
1393
1394 let wstore = state.wstore.clone();
1395 let broker = state.broker.clone();
1396 engine.register_handler(
1397 COMMAND_UPDATE_AGENT_INSTANCE,
1398 Box::new(move |data, _ctx| {
1399 let wstore = wstore.clone();
1400 let broker = broker.clone();
1401 Box::pin(async move {
1402 let cmd: CommandUpdateAgentInstanceData = serde_json::from_value(data)
1403 .map_err(|e| format!("updateagentinstance: {e}"))?;
1404 let existing = wstore
1405 .instance_get(&cmd.id)
1406 .map_err(|e| format!("updateagentinstance: {e}"))?
1407 .ok_or_else(|| format!("updateagentinstance: not found id={}", cmd.id))?;
1408 let merged = AgentInstance {
1409 id: existing.id.clone(),
1410 definition_id: existing.definition_id.clone(),
1411 parent_instance_id: existing.parent_instance_id.clone(),
1412 block_id: cmd.block_id.unwrap_or(existing.block_id),
1413 session_id: cmd.session_id.unwrap_or(existing.session_id),
1414 status: cmd.status.unwrap_or(existing.status),
1415 github_context: cmd.github_context.unwrap_or(existing.github_context),
1416 started_at: existing.started_at,
1417 ended_at: cmd.ended_at.unwrap_or(existing.ended_at),
1418 created_at: existing.created_at,
1419 identity_id: existing.identity_id.clone(),
1426 memory_id: existing.memory_id.clone(),
1427 instance_name: existing.instance_name.clone(),
1428 working_directory: existing.working_directory.clone(),
1429 display_hidden: existing.display_hidden,
1430 };
1431 wstore
1432 .instance_update(&merged)
1433 .map_err(|e| format!("updateagentinstance: {e}"))?;
1434 broker.publish(crate::backend::wps::WaveEvent {
1435 event: format!("agentinstances:changed:{}", merged.definition_id),
1436 scopes: vec![],
1437 sender: String::new(),
1438 persist: 0,
1439 data: None,
1440 });
1441 Ok(Some(serde_json::to_value(&merged).unwrap_or_default()))
1442 })
1443 }),
1444 );
1445
1446 let wstore = state.wstore.clone();
1447 let broker = state.broker.clone();
1448 engine.register_handler(
1449 COMMAND_DELETE_AGENT_INSTANCE,
1450 Box::new(move |data, _ctx| {
1451 let wstore = wstore.clone();
1452 let broker = broker.clone();
1453 Box::pin(async move {
1454 let cmd: CommandDeleteAgentInstanceData = serde_json::from_value(data)
1455 .map_err(|e| format!("deleteagentinstance: {e}"))?;
1456 let definition_id = wstore
1458 .instance_get(&cmd.id)
1459 .map_err(|e| format!("deleteagentinstance: {e}"))?
1460 .map(|i| i.definition_id);
1461 let deleted = wstore
1462 .instance_delete(&cmd.id)
1463 .map_err(|e| format!("deleteagentinstance: {e}"))?;
1464 if let Some(def_id) = definition_id.filter(|_| deleted) {
1465 broker.publish(crate::backend::wps::WaveEvent {
1466 event: format!("agentinstances:changed:{}", def_id),
1467 scopes: vec![],
1468 sender: String::new(),
1469 persist: 0,
1470 data: None,
1471 });
1472 }
1473 Ok(Some(json!({ "deleted": deleted })))
1474 })
1475 }),
1476 );
1477
1478 let wstore = state.wstore.clone();
1484 engine.register_handler(
1485 COMMAND_LIST_NAMED_AGENTS,
1486 Box::new(move |data, _ctx| {
1487 let wstore = wstore.clone();
1488 Box::pin(async move {
1489 let cmd: CommandListNamedAgentsData =
1490 serde_json::from_value(data).unwrap_or_default();
1491 let limit = if cmd.limit == 0 {
1492 200
1493 } else {
1494 cmd.limit.min(1000)
1495 };
1496 let defs = wstore
1501 .agent_def_list()
1502 .map_err(|e| format!("listnamedagents: agent_def_list: {e}"))?;
1503 let identities = wstore
1504 .bundle_identity_list()
1505 .map_err(|e| format!("listnamedagents: bundle_identity_list: {e}"))?;
1506 let memories = wstore
1507 .bundle_memory_list()
1508 .map_err(|e| format!("listnamedagents: bundle_memory_list: {e}"))?;
1509
1510 let rows: Vec<NamedAgentRow> = match wstore.shared_agent_registry() {
1517 Some(reg) => {
1518 let agents_root = reg.agents_root().map(|p| p.to_path_buf());
1519 let mut records = reg
1520 .list_active()
1521 .map_err(|e| format!("listnamedagents: registry: {e}"))?;
1522 if let Some(def_filter) = cmd.definition_id.as_deref() {
1523 records.retain(|r| r.data.definition_id == def_filter);
1524 }
1525 records.sort_by(|a, b| {
1526 b.data
1527 .last_launched_at_ms
1528 .cmp(&a.data.last_launched_at_ms)
1529 });
1530 records.truncate(limit);
1531 let sqlite_rows: Vec<AgentInstance> = wstore
1546 .instance_list_named(
1547 records.len().max(1),
1548 cmd.definition_id.as_deref(),
1549 false,
1550 )
1551 .unwrap_or_default();
1552 let sqlite_by_id: std::collections::HashMap<&str, &AgentInstance> =
1553 sqlite_rows.iter().map(|i| (i.id.as_str(), i)).collect();
1554 records
1555 .into_iter()
1556 .map(|rec| {
1557 let d = rec.data;
1558 let def = defs.iter().find(|x| x.id == d.definition_id);
1559 let identity_id_str =
1560 d.identity_id.clone().unwrap_or_default();
1561 let memory_id_str = d.memory_id.clone().unwrap_or_default();
1562 let identity_name = if identity_id_str.is_empty() {
1563 "(ambient creds)".to_string()
1564 } else {
1565 identities
1566 .iter()
1567 .find(|i| i.id == identity_id_str)
1568 .map(|i| i.name.clone())
1569 .unwrap_or_else(|| "(missing identity)".to_string())
1570 };
1571 let memory_name = if memory_id_str.is_empty() {
1572 "(vanilla CLI)".to_string()
1573 } else {
1574 memories
1575 .iter()
1576 .find(|m| m.id == memory_id_str)
1577 .map(|m| m.name.clone())
1578 .unwrap_or_else(|| "(missing memory)".to_string())
1579 };
1580 let working_directory = match agents_root.as_ref() {
1581 Some(root) => root
1582 .join(&d.working_dir)
1583 .to_string_lossy()
1584 .to_string(),
1585 None => d.working_dir.clone(),
1586 };
1587 let (block_id_hint, status, ended_at) =
1597 match sqlite_by_id.get(d.instance_id.as_str()) {
1598 Some(inst) => (
1599 inst.block_id.clone(),
1600 inst.status.clone(),
1601 inst.ended_at,
1602 ),
1603 None => (String::new(), "available".to_string(), 0),
1604 };
1605 NamedAgentRow {
1606 instance_id: d.instance_id,
1607 instance_name: d.instance_name,
1608 definition_id: d.definition_id.clone(),
1609 definition_name: def
1610 .map(|x| x.name.clone())
1611 .unwrap_or_else(|| "(missing definition)".to_string()),
1612 provider: def
1613 .map(|x| x.provider.clone())
1614 .unwrap_or_default(),
1615 working_directory,
1616 identity_id: identity_id_str,
1617 identity_name,
1618 memory_id: memory_id_str,
1619 memory_name,
1620 started_at: d.last_launched_at_ms,
1621 ended_at,
1622 status,
1623 block_id_hint,
1624 }
1625 })
1626 .collect()
1627 }
1628 None => {
1629 let instances = wstore
1634 .instance_list_named(
1635 limit,
1636 cmd.definition_id.as_deref(),
1637 false,
1638 )
1639 .map_err(|e| format!("listnamedagents: {e}"))?;
1640 instances
1641 .into_iter()
1642 .map(|inst| {
1643 let def = defs.iter().find(|d| d.id == inst.definition_id);
1644 let identity_name = if inst.identity_id.is_empty() {
1645 "(ambient creds)".to_string()
1646 } else {
1647 identities
1648 .iter()
1649 .find(|i| i.id == inst.identity_id)
1650 .map(|i| i.name.clone())
1651 .unwrap_or_else(|| "(missing identity)".to_string())
1652 };
1653 let memory_name = if inst.memory_id.is_empty() {
1654 "(vanilla CLI)".to_string()
1655 } else {
1656 memories
1657 .iter()
1658 .find(|m| m.id == inst.memory_id)
1659 .map(|m| m.name.clone())
1660 .unwrap_or_else(|| "(missing memory)".to_string())
1661 };
1662 NamedAgentRow {
1663 instance_id: inst.id,
1664 instance_name: inst.instance_name,
1665 definition_id: inst.definition_id.clone(),
1666 definition_name: def
1667 .map(|d| d.name.clone())
1668 .unwrap_or_else(|| "(missing definition)".to_string()),
1669 provider: def
1670 .map(|d| d.provider.clone())
1671 .unwrap_or_default(),
1672 working_directory: inst.working_directory,
1673 identity_id: inst.identity_id,
1674 identity_name,
1675 memory_id: inst.memory_id,
1676 memory_name,
1677 started_at: inst.started_at,
1678 ended_at: inst.ended_at,
1679 status: inst.status,
1680 block_id_hint: inst.block_id,
1681 }
1682 })
1683 .collect()
1684 }
1685 };
1686
1687 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1688 })
1689 }),
1690 );
1691
1692 let wstore = state.wstore.clone();
1695 let broker = state.broker.clone();
1696 engine.register_handler(
1697 COMMAND_HIDE_NAMED_AGENT,
1698 Box::new(move |data, _ctx| {
1699 let wstore = wstore.clone();
1700 let broker = broker.clone();
1701 Box::pin(async move {
1702 let cmd: CommandHideNamedAgentData = serde_json::from_value(data)
1703 .map_err(|e| format!("hidenamedagent: {e}"))?;
1704 let hidden = wstore
1705 .instance_set_hidden(&cmd.id, true)
1706 .map_err(|e| format!("hidenamedagent: {e}"))?;
1707 if hidden {
1708 broker.publish(crate::backend::wps::WaveEvent {
1709 event: "namedagents:changed".to_string(),
1710 scopes: vec![],
1711 sender: String::new(),
1712 persist: 0,
1713 data: None,
1714 });
1715 }
1716 Ok(Some(json!({ "hidden": hidden })))
1717 })
1718 }),
1719 );
1720
1721 let wstore = state.wstore.clone();
1736 let filestore = state.filestore.clone();
1737 engine.register_handler(
1738 COMMAND_LIST_RECENT_SESSIONS,
1739 Box::new(move |data, _ctx| {
1740 let wstore = wstore.clone();
1741 let filestore = filestore.clone();
1742 Box::pin(async move {
1743 let cmd: CommandListRecentSessionsData =
1744 serde_json::from_value(data).unwrap_or_default();
1745 let limit = if cmd.limit == 0 {
1746 20
1747 } else {
1748 cmd.limit.min(100)
1749 };
1750 let raw_limit = (limit * 10).max(50).min(500);
1756 let instances = wstore
1757 .instance_list_named(raw_limit, None, true)
1762 .map_err(|e| format!("listrecentsessions: {e}"))?;
1763
1764 let defs = wstore
1765 .agent_def_list()
1766 .map_err(|e| format!("listrecentsessions: defs: {e}"))?;
1767 let identities = wstore
1768 .bundle_identity_list()
1769 .map_err(|e| format!("listrecentsessions: identities: {e}"))?;
1770 let memories = wstore
1771 .bundle_memory_list()
1772 .map_err(|e| format!("listrecentsessions: memories: {e}"))?;
1773
1774 let identity_filter = cmd
1775 .identity_id
1776 .as_deref()
1777 .map(|s| s.trim())
1778 .filter(|s| !s.is_empty());
1779
1780 let mut rows: Vec<RecentSessionRow> = Vec::with_capacity(instances.len());
1785 for inst in instances {
1786 if let Some(filter) = identity_filter {
1789 if inst.identity_id != filter {
1790 continue;
1791 }
1792 }
1793 let def = defs.iter().find(|d| d.id == inst.definition_id);
1794 let identity_name = if inst.identity_id.is_empty() {
1795 "(ambient creds)".to_string()
1796 } else {
1797 identities
1798 .iter()
1799 .find(|i| i.id == inst.identity_id)
1800 .map(|i| i.name.clone())
1801 .unwrap_or_else(|| "(missing identity)".to_string())
1802 };
1803 let memory_name = if inst.memory_id.is_empty() {
1804 "(vanilla CLI)".to_string()
1805 } else {
1806 memories
1807 .iter()
1808 .find(|m| m.id == inst.memory_id)
1809 .map(|m| m.name.clone())
1810 .unwrap_or_else(|| "(missing memory)".to_string())
1811 };
1812
1813 let (has_snapshot, last_active_at, preview, node_count) =
1817 if inst.block_id.is_empty() {
1818 (false, inst.started_at, String::new(), 0usize)
1819 } else {
1820 match filestore.stat(&inst.block_id, "output.state.json") {
1821 Ok(Some(file)) => {
1822 let modts = if file.modts > 0 {
1823 file.modts
1824 } else {
1825 inst.started_at
1826 };
1827 let (preview, node_count) = read_session_preview(
1828 &filestore,
1829 &inst.block_id,
1830 );
1831 (true, modts, preview, node_count)
1832 }
1833 _ => (false, inst.started_at, String::new(), 0usize),
1834 }
1835 };
1836
1837 rows.push(RecentSessionRow {
1838 instance_id: inst.id,
1839 instance_name: inst.instance_name,
1840 definition_id: inst.definition_id.clone(),
1841 definition_name: def
1842 .map(|d| d.name.clone())
1843 .unwrap_or_else(|| "(missing definition)".to_string()),
1844 provider: def.map(|d| d.provider.clone()).unwrap_or_default(),
1845 working_directory: inst.working_directory,
1846 identity_id: inst.identity_id,
1847 identity_name,
1848 memory_id: inst.memory_id,
1849 memory_name,
1850 block_id_hint: inst.block_id,
1851 session_id: inst.session_id,
1857 preview,
1858 node_count,
1859 last_active_at,
1860 has_snapshot,
1861 });
1862 }
1863
1864 rows.sort_by(|a, b| match (a.has_snapshot, b.has_snapshot) {
1869 (true, true) | (false, false) => {
1870 b.last_active_at.cmp(&a.last_active_at)
1871 }
1872 (true, false) => std::cmp::Ordering::Less,
1873 (false, true) => std::cmp::Ordering::Greater,
1874 });
1875 rows.truncate(limit);
1876
1877 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
1878 })
1879 }),
1880 );
1881
1882 let wstore = state.wstore.clone();
1885 let broker = state.broker.clone();
1886 engine.register_handler(
1887 COMMAND_FORK_AGENT_DEFINITION,
1888 Box::new(move |data, _ctx| {
1889 let wstore = wstore.clone();
1890 let broker = broker.clone();
1891 Box::pin(async move {
1892 let cmd: CommandForkAgentDefinitionData = serde_json::from_value(data)
1893 .map_err(|e| format!("forkagentdefinition: {e}"))?;
1894
1895 let source = wstore
1897 .agent_def_list()
1898 .map_err(|e| format!("forkagentdefinition: {e}"))?
1899 .into_iter()
1900 .find(|a| a.id == cmd.source_id)
1901 .ok_or_else(|| format!("forkagentdefinition: source not found: {}", cmd.source_id))?;
1902
1903 let now = SystemTime::now()
1907 .duration_since(UNIX_EPOCH)
1908 .map(|d| d.as_millis() as i64)
1909 .unwrap_or(0);
1910 let branch_slug_part = if cmd.branch_label.is_empty() {
1911 "fork".to_string()
1912 } else {
1913 crate::backend::storage::wstore::derive_slug(&cmd.branch_label)
1914 };
1915 let mut fork = AgentDefinition {
1916 id: uuid::Uuid::new_v4().to_string(),
1917 slug: format!("{}-{}", source.slug, branch_slug_part),
1919 name: if cmd.branch_label.is_empty() {
1920 format!("{} (fork)", source.name)
1921 } else {
1922 format!("{} [{}]", source.name, cmd.branch_label)
1923 },
1924 icon: source.icon.clone(),
1925 provider: source.provider.clone(),
1926 description: source.description.clone(),
1927 working_directory: String::new(), shell: source.shell.clone(),
1929 provider_flags: source.provider_flags.clone(),
1930 auto_start: 0, restart_on_crash: source.restart_on_crash,
1932 idle_timeout_minutes: source.idle_timeout_minutes,
1933 created_at: now,
1934 agent_type: source.agent_type.clone(),
1935 environment: source.environment.clone(),
1936 agent_bus_id: String::new(), is_seeded: 0,
1938 accounts: String::new(),
1939 parent_id: source.id.clone(),
1940 branch_label: cmd.branch_label.clone(),
1941 updated_at: now,
1942 user_hidden: 0,
1943 };
1944 wstore
1945 .agent_def_insert(&mut fork)
1946 .map_err(|e| format!("forkagentdefinition: {e}"))?;
1947
1948 let source_contents = wstore
1952 .agent_content_get_all(&source.id)
1953 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
1954 for c in source_contents {
1955 let new_content = AgentContent {
1956 agent_id: fork.id.clone(),
1957 content_type: c.content_type,
1958 content: c.content,
1959 updated_at: now,
1960 };
1961 wstore
1962 .agent_content_set(&new_content)
1963 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
1964 }
1965 let source_skills = wstore
1966 .agent_skill_list(&source.id)
1967 .map_err(|e| format!("forkagentdefinition skills: {e}"))?;
1968 for s in source_skills {
1969 let new_skill = AgentSkill {
1970 id: uuid::Uuid::new_v4().to_string(),
1971 agent_id: fork.id.clone(),
1972 name: s.name,
1973 trigger: s.trigger,
1974 skill_type: s.skill_type,
1975 description: s.description,
1976 content: s.content,
1977 created_at: now,
1978 };
1979 wstore
1980 .agent_skill_insert(&new_skill)
1981 .map_err(|e| format!("forkagentdefinition skill: {e}"))?;
1982 }
1983
1984 broker.publish(crate::backend::wps::WaveEvent {
1985 event: "agents:changed".to_string(),
1986 scopes: vec![],
1987 sender: String::new(),
1988 persist: 0,
1989 data: None,
1990 });
1991
1992 Ok(Some(serde_json::to_value(&fork).unwrap_or_default()))
1993 })
1994 }),
1995 );
1996
1997 register_agent_session_handlers(engine, state);
1998 register_v7_handlers(engine, state);
1999}
2000
2001fn register_agent_session_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2008 let filestore = state.filestore.clone();
2010 engine.register_handler(
2011 COMMAND_AGENT_SESSION_READ,
2012 Box::new(move |data, _ctx| {
2013 let filestore = filestore.clone();
2014 Box::pin(async move {
2015 let cmd: CommandAgentSessionReadData = serde_json::from_value(data)
2016 .map_err(|e| format!("agent:session:read: {e}"))?;
2017 let (content, modts) =
2018 crate::backend::agent_session::read_session_state(&filestore, &cmd.definition_id)
2019 .map_err(|e| format!("agent:session:read: {e}"))?;
2020 Ok(Some(
2021 serde_json::to_value(&AgentSessionReadResult { content, modts })
2022 .unwrap_or_default(),
2023 ))
2024 })
2025 }),
2026 );
2027
2028 let filestore = state.filestore.clone();
2030 engine.register_handler(
2031 COMMAND_AGENT_SESSION_WRITE_STATE,
2032 Box::new(move |data, _ctx| {
2033 let filestore = filestore.clone();
2034 Box::pin(async move {
2035 let cmd: CommandAgentSessionWriteStateData = serde_json::from_value(data)
2036 .map_err(|e| format!("agent:session:write_state: {e}"))?;
2037 let bytes = cmd.content.as_bytes();
2038 let bytes_written = bytes.len() as u64;
2039 crate::backend::agent_session::write_session_state(
2040 &filestore,
2041 &cmd.definition_id,
2042 bytes,
2043 )
2044 .map_err(|e| format!("agent:session:write_state: {e}"))?;
2045 Ok(Some(
2046 serde_json::to_value(&AgentSessionWriteStateResult { bytes_written })
2047 .unwrap_or_default(),
2048 ))
2049 })
2050 }),
2051 );
2052
2053 let filestore = state.filestore.clone();
2055 engine.register_handler(
2056 COMMAND_AGENT_SESSION_APPEND_OUTPUT,
2057 Box::new(move |data, _ctx| {
2058 let filestore = filestore.clone();
2059 Box::pin(async move {
2060 let cmd: CommandAgentSessionAppendOutputData = serde_json::from_value(data)
2061 .map_err(|e| format!("agent:session:append_output: {e}"))?;
2062 let bytes_written = crate::backend::agent_session::append_session_output(
2063 &filestore,
2064 &cmd.definition_id,
2065 &cmd.line,
2066 )
2067 .map_err(|e| format!("agent:session:append_output: {e}"))?;
2068 Ok(Some(
2069 serde_json::to_value(&AgentSessionAppendOutputResult { bytes_written })
2070 .unwrap_or_default(),
2071 ))
2072 })
2073 }),
2074 );
2075
2076 let filestore = state.filestore.clone();
2078 engine.register_handler(
2079 COMMAND_AGENT_SESSION_ARCHIVE,
2080 Box::new(move |data, _ctx| {
2081 let filestore = filestore.clone();
2082 Box::pin(async move {
2083 let cmd: CommandAgentSessionArchiveData = serde_json::from_value(data)
2084 .map_err(|e| format!("agent:session:archive: {e}"))?;
2085 let result =
2086 crate::backend::agent_session::archive_session(&filestore, &cmd.definition_id)
2087 .map_err(|e| format!("agent:session:archive: {e}"))?;
2088 let (archive_zoneid, archived_at_ms) = match result {
2089 Some((z, ts)) => (z, ts),
2090 None => (String::new(), 0),
2091 };
2092 Ok(Some(
2093 serde_json::to_value(&AgentSessionArchiveResult {
2094 archive_zoneid,
2095 archived_at_ms,
2096 })
2097 .unwrap_or_default(),
2098 ))
2099 })
2100 }),
2101 );
2102
2103 let filestore = state.filestore.clone();
2105 engine.register_handler(
2106 COMMAND_AGENT_SESSION_LIST_ARCHIVES,
2107 Box::new(move |data, _ctx| {
2108 let filestore = filestore.clone();
2109 Box::pin(async move {
2110 let cmd: CommandAgentSessionListArchivesData =
2111 serde_json::from_value(data).unwrap_or_default();
2112 let summaries = crate::backend::agent_session::list_archives(
2113 &filestore,
2114 &cmd.definition_id,
2115 cmd.limit,
2116 )
2117 .map_err(|e| format!("agent:session:list_archives: {e}"))?;
2118 let rows: Vec<AgentArchiveRow> = summaries
2119 .into_iter()
2120 .map(|s| AgentArchiveRow {
2121 archive_zoneid: s.archive_zoneid,
2122 archived_at_ms: s.archived_at_ms,
2123 preview: s.preview,
2124 node_count: s.node_count,
2125 })
2126 .collect();
2127 Ok(Some(serde_json::to_value(&rows).unwrap_or_default()))
2128 })
2129 }),
2130 );
2131}
2132
2133fn register_v7_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
2140 let wstore = state.wstore.clone();
2143 engine.register_handler(
2144 COMMAND_LIST_IDENTITY_BUNDLES,
2145 Box::new(move |_data, _ctx| {
2146 let wstore = wstore.clone();
2147 Box::pin(async move {
2148 let bundles = wstore
2149 .bundle_identity_list()
2150 .map_err(|e| format!("listidentitybundles: {e}"))?;
2151 Ok(Some(serde_json::to_value(&bundles).unwrap_or_default()))
2152 })
2153 }),
2154 );
2155
2156 let wstore = state.wstore.clone();
2157 engine.register_handler(
2158 COMMAND_GET_IDENTITY_BUNDLE,
2159 Box::new(move |data, _ctx| {
2160 let wstore = wstore.clone();
2161 Box::pin(async move {
2162 let cmd: CommandGetIdentityBundleData = serde_json::from_value(data)
2163 .map_err(|e| format!("getidentitybundle: {e}"))?;
2164 match wstore
2165 .bundle_identity_get(&cmd.id)
2166 .map_err(|e| format!("getidentitybundle: {e}"))?
2167 {
2168 Some(b) => Ok(Some(serde_json::to_value(&b).unwrap_or_default())),
2169 None => Err(format!("getidentitybundle: not found id={}", cmd.id)),
2170 }
2171 })
2172 }),
2173 );
2174
2175 let wstore = state.wstore.clone();
2176 let broker = state.broker.clone();
2177 engine.register_handler(
2178 COMMAND_UPSERT_IDENTITY_BUNDLE,
2179 Box::new(move |data, _ctx| {
2180 let wstore = wstore.clone();
2181 let broker = broker.clone();
2182 Box::pin(async move {
2183 let mut bundle: Identity = serde_json::from_value(data)
2184 .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2185 if bundle.is_blank || bundle.id == "blank" {
2191 return Err(
2192 "upsertidentitybundle: cannot mutate the blank singleton".to_string(),
2193 );
2194 }
2195 if bundle.id.is_empty() {
2196 bundle.id = uuid::Uuid::new_v4().to_string();
2197 }
2198 let now = SystemTime::now()
2199 .duration_since(UNIX_EPOCH)
2200 .map(|d| d.as_millis() as i64)
2201 .unwrap_or(0);
2202 if bundle.created_at == 0 {
2203 bundle.created_at = now;
2204 }
2205 bundle.updated_at = now;
2206 wstore
2207 .bundle_identity_upsert(&bundle)
2208 .map_err(|e| format!("upsertidentitybundle: {e}"))?;
2209 broker.publish(crate::backend::wps::WaveEvent {
2210 event: "identitybundles:changed".to_string(),
2211 scopes: vec![],
2212 sender: String::new(),
2213 persist: 0,
2214 data: None,
2215 });
2216 Ok(Some(serde_json::to_value(&bundle).unwrap_or_default()))
2217 })
2218 }),
2219 );
2220
2221 let wstore = state.wstore.clone();
2222 let broker = state.broker.clone();
2223 engine.register_handler(
2224 COMMAND_DELETE_IDENTITY_BUNDLE,
2225 Box::new(move |data, _ctx| {
2226 let wstore = wstore.clone();
2227 let broker = broker.clone();
2228 Box::pin(async move {
2229 let cmd: CommandDeleteIdentityBundleData = serde_json::from_value(data)
2230 .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2231 let deleted = wstore
2232 .bundle_identity_delete(&cmd.id)
2233 .map_err(|e| format!("deleteidentitybundle: {e}"))?;
2234 if deleted {
2235 broker.publish(crate::backend::wps::WaveEvent {
2236 event: "identitybundles:changed".to_string(),
2237 scopes: vec![],
2238 sender: String::new(),
2239 persist: 0,
2240 data: None,
2241 });
2242 }
2243 Ok(Some(json!({ "deleted": deleted })))
2244 })
2245 }),
2246 );
2247
2248 let wstore = state.wstore.clone();
2251 let broker = state.broker.clone();
2252 engine.register_handler(
2253 COMMAND_BIND_IDENTITY_ACCOUNT,
2254 Box::new(move |data, _ctx| {
2255 let wstore = wstore.clone();
2256 let broker = broker.clone();
2257 Box::pin(async move {
2258 let cmd: CommandBindIdentityAccountData = serde_json::from_value(data)
2259 .map_err(|e| format!("bindidentityaccount: {e}"))?;
2260 wstore
2261 .bundle_identity_bind(&cmd.identity_id, &cmd.provider, &cmd.account_id)
2262 .map_err(|e| format!("bindidentityaccount: {e}"))?;
2263 broker.publish(crate::backend::wps::WaveEvent {
2264 event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2265 scopes: vec![],
2266 sender: String::new(),
2267 persist: 0,
2268 data: None,
2269 });
2270 Ok(None)
2271 })
2272 }),
2273 );
2274
2275 let wstore = state.wstore.clone();
2276 let broker = state.broker.clone();
2277 engine.register_handler(
2278 COMMAND_UNBIND_IDENTITY_ACCOUNT,
2279 Box::new(move |data, _ctx| {
2280 let wstore = wstore.clone();
2281 let broker = broker.clone();
2282 Box::pin(async move {
2283 let cmd: CommandUnbindIdentityAccountData = serde_json::from_value(data)
2284 .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2285 let removed = wstore
2286 .bundle_identity_unbind(&cmd.identity_id, &cmd.provider)
2287 .map_err(|e| format!("unbindidentityaccount: {e}"))?;
2288 if removed {
2289 broker.publish(crate::backend::wps::WaveEvent {
2290 event: format!("identitybundlebindings:changed:{}", cmd.identity_id),
2291 scopes: vec![],
2292 sender: String::new(),
2293 persist: 0,
2294 data: None,
2295 });
2296 }
2297 Ok(Some(json!({ "unbound": removed })))
2298 })
2299 }),
2300 );
2301
2302 let wstore = state.wstore.clone();
2303 engine.register_handler(
2304 COMMAND_LIST_IDENTITY_BINDINGS,
2305 Box::new(move |data, _ctx| {
2306 let wstore = wstore.clone();
2307 Box::pin(async move {
2308 let cmd: CommandListIdentityBindingsData = serde_json::from_value(data)
2309 .map_err(|e| format!("listidentitybindings: {e}"))?;
2310 let bindings = wstore
2311 .bundle_identity_bindings(&cmd.identity_id)
2312 .map_err(|e| format!("listidentitybindings: {e}"))?;
2313 Ok(Some(serde_json::to_value(&bindings).unwrap_or_default()))
2314 })
2315 }),
2316 );
2317
2318 let wstore = state.wstore.clone();
2321 engine.register_handler(
2322 COMMAND_LIST_MEMORIES,
2323 Box::new(move |_data, _ctx| {
2324 let wstore = wstore.clone();
2325 Box::pin(async move {
2326 let memories = wstore
2327 .bundle_memory_list()
2328 .map_err(|e| format!("listmemories: {e}"))?;
2329 Ok(Some(serde_json::to_value(&memories).unwrap_or_default()))
2330 })
2331 }),
2332 );
2333
2334 let wstore = state.wstore.clone();
2335 engine.register_handler(
2336 COMMAND_GET_MEMORY,
2337 Box::new(move |data, _ctx| {
2338 let wstore = wstore.clone();
2339 Box::pin(async move {
2340 let cmd: CommandGetMemoryData = serde_json::from_value(data)
2341 .map_err(|e| format!("getmemory: {e}"))?;
2342 match wstore
2343 .bundle_memory_get(&cmd.id)
2344 .map_err(|e| format!("getmemory: {e}"))?
2345 {
2346 Some(m) => Ok(Some(serde_json::to_value(&m).unwrap_or_default())),
2347 None => Err(format!("getmemory: not found id={}", cmd.id)),
2348 }
2349 })
2350 }),
2351 );
2352
2353 let wstore = state.wstore.clone();
2354 let broker = state.broker.clone();
2355 engine.register_handler(
2356 COMMAND_UPSERT_MEMORY,
2357 Box::new(move |data, _ctx| {
2358 let wstore = wstore.clone();
2359 let broker = broker.clone();
2360 Box::pin(async move {
2361 let mut memory: Memory = serde_json::from_value(data)
2362 .map_err(|e| format!("upsertmemory: {e}"))?;
2363 if memory.is_blank || memory.id == "blank" {
2367 return Err("upsertmemory: cannot mutate the blank singleton".to_string());
2368 }
2369 if memory.id.is_empty() {
2370 memory.id = uuid::Uuid::new_v4().to_string();
2371 }
2372 let now = SystemTime::now()
2373 .duration_since(UNIX_EPOCH)
2374 .map(|d| d.as_millis() as i64)
2375 .unwrap_or(0);
2376 if memory.created_at == 0 {
2377 memory.created_at = now;
2378 }
2379 memory.updated_at = now;
2380 wstore
2381 .bundle_memory_upsert(&memory)
2382 .map_err(|e| format!("upsertmemory: {e}"))?;
2383 broker.publish(crate::backend::wps::WaveEvent {
2384 event: "memories:changed".to_string(),
2385 scopes: vec![],
2386 sender: String::new(),
2387 persist: 0,
2388 data: None,
2389 });
2390 Ok(Some(serde_json::to_value(&memory).unwrap_or_default()))
2391 })
2392 }),
2393 );
2394
2395 let wstore = state.wstore.clone();
2396 let broker = state.broker.clone();
2397 engine.register_handler(
2398 COMMAND_DELETE_MEMORY,
2399 Box::new(move |data, _ctx| {
2400 let wstore = wstore.clone();
2401 let broker = broker.clone();
2402 Box::pin(async move {
2403 let cmd: CommandDeleteMemoryData = serde_json::from_value(data)
2404 .map_err(|e| format!("deletememory: {e}"))?;
2405 let deleted = wstore
2406 .bundle_memory_delete(&cmd.id)
2407 .map_err(|e| format!("deletememory: {e}"))?;
2408 if deleted {
2409 broker.publish(crate::backend::wps::WaveEvent {
2410 event: "memories:changed".to_string(),
2411 scopes: vec![],
2412 sender: String::new(),
2413 persist: 0,
2414 data: None,
2415 });
2416 }
2417 Ok(Some(json!({ "deleted": deleted })))
2418 })
2419 }),
2420 );
2421}
2422
2423fn read_session_preview(
2438 filestore: &crate::backend::storage::filestore::FileStore,
2439 block_id: &str,
2440) -> (String, usize) {
2441 let bytes = match filestore.read_file(block_id, "output.state.json") {
2442 Ok(Some(b)) => b,
2443 _ => return (String::new(), 0),
2444 };
2445 if bytes.len() > 4 * 1024 * 1024 {
2450 tracing::warn!(
2451 block_id = %block_id,
2452 size = bytes.len(),
2453 "listrecentsessions: snapshot too large; skipping preview"
2454 );
2455 return (String::new(), 0);
2456 }
2457 let json: serde_json::Value = match serde_json::from_slice(&bytes) {
2458 Ok(v) => v,
2459 Err(_) => return (String::new(), 0),
2460 };
2461 let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
2462 Some(a) => a,
2463 None => return (String::new(), 0),
2464 };
2465 let node_count = nodes.len();
2466 let mut preview = String::new();
2472 for node in nodes {
2473 let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
2474 if ty != "user_message" {
2475 continue;
2476 }
2477 let msg = node
2478 .get("message")
2479 .and_then(|v| v.as_str())
2480 .unwrap_or("")
2481 .trim();
2482 if msg.is_empty() {
2483 continue;
2484 }
2485 if preview.is_empty() && msg.starts_with("# Session Context") {
2486 preview = collapse_preview(msg);
2488 continue;
2489 }
2490 preview = collapse_preview(msg);
2491 break;
2492 }
2493 (preview, node_count)
2494}
2495
2496fn collapse_preview(s: &str) -> String {
2499 const MAX_CHARS: usize = 240;
2500 let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
2501 let mut prev_space = false;
2502 for ch in s.chars() {
2503 if buf.chars().count() >= MAX_CHARS {
2504 buf.push('\u{2026}'); return buf;
2506 }
2507 if ch.is_whitespace() {
2508 if !prev_space && !buf.is_empty() {
2509 buf.push(' ');
2510 prev_space = true;
2511 }
2512 } else {
2513 buf.push(ch);
2514 prev_space = false;
2515 }
2516 }
2517 buf
2518}
2519
2520#[cfg(test)]
2521mod recent_sessions_tests {
2522 use super::*;
2523 use crate::backend::storage::filestore::FileStore;
2524
2525 fn fresh_filestore() -> std::sync::Arc<FileStore> {
2526 std::sync::Arc::new(FileStore::open_in_memory().unwrap())
2527 }
2528
2529 fn write_snapshot(fs: &FileStore, block_id: &str, body: &str) {
2530 let meta: crate::backend::storage::filestore::FileMeta =
2533 std::collections::HashMap::new();
2534 let opts = crate::backend::storage::filestore::FileOpts::default();
2535 fs.make_file(block_id, "output.state.json", meta, opts)
2536 .expect("make_file");
2537 fs.write_file(block_id, "output.state.json", body.as_bytes())
2538 .expect("write_file");
2539 }
2540
2541 #[test]
2542 fn collapse_preview_strips_newlines_and_caps_length() {
2543 let s = "hello\n\nworld\n next line";
2544 assert_eq!(collapse_preview(s), "hello world next line");
2545 let long: String = "a".repeat(500);
2546 let out = collapse_preview(&long);
2547 assert!(out.ends_with('\u{2026}'));
2549 assert!(out.chars().count() <= 241);
2550 }
2551
2552 #[test]
2553 fn read_session_preview_missing_returns_zero() {
2554 let fs = fresh_filestore();
2555 let (preview, count) = read_session_preview(&fs, "no-such-block");
2556 assert_eq!(preview, "");
2557 assert_eq!(count, 0);
2558 }
2559
2560 #[test]
2561 fn read_session_preview_extracts_first_user_message_skipping_context() {
2562 let fs = fresh_filestore();
2563 let snapshot = serde_json::json!({
2566 "schemaVersion": 1,
2567 "savedAt": "2026-05-23T08:00:00Z",
2568 "highWaterMark": 169,
2569 "historyOffset": 0,
2570 "nodes": [
2571 {
2572 "type": "user_message",
2573 "id": "u0",
2574 "timestamp": 0,
2575 "collapsed": false,
2576 "summary": "👤 User Message",
2577 "message": "# Session Context\nIdentity: Claude\n## Description\nStartup boilerplate"
2578 },
2579 { "type": "markdown", "id": "m0", "content": "ack" },
2580 {
2581 "type": "user_message",
2582 "id": "u1",
2583 "timestamp": 100,
2584 "collapsed": false,
2585 "summary": "👤 User Message",
2586 "message": "check the agentmuxai/agentmux history, get the latest code"
2587 }
2588 ]
2589 });
2590 write_snapshot(&fs, "blk-1", &snapshot.to_string());
2591 let (preview, count) = read_session_preview(&fs, "blk-1");
2592 assert_eq!(count, 3);
2593 assert!(preview.starts_with("check the agentmuxai/agentmux"));
2594 }
2595
2596 #[test]
2597 fn read_session_preview_falls_back_to_session_context_when_only_one() {
2598 let fs = fresh_filestore();
2599 let snapshot = serde_json::json!({
2600 "schemaVersion": 1,
2601 "nodes": [
2602 {
2603 "type": "user_message",
2604 "id": "u0",
2605 "message": "# Session Context\nIdentity: Claude\nStartup boilerplate"
2606 }
2607 ]
2608 });
2609 write_snapshot(&fs, "blk-2", &snapshot.to_string());
2610 let (preview, count) = read_session_preview(&fs, "blk-2");
2611 assert_eq!(count, 1);
2612 assert!(preview.starts_with("# Session Context"));
2614 }
2615
2616 #[test]
2617 fn read_session_preview_handles_malformed_json() {
2618 let fs = fresh_filestore();
2619 write_snapshot(&fs, "blk-3", "not valid json {");
2620 let (preview, count) = read_session_preview(&fs, "blk-3");
2621 assert_eq!(preview, "");
2622 assert_eq!(count, 0);
2623 }
2624
2625 #[test]
2626 fn read_session_preview_handles_no_user_messages() {
2627 let fs = fresh_filestore();
2628 let snapshot = serde_json::json!({
2629 "schemaVersion": 1,
2630 "nodes": [
2631 { "type": "markdown", "id": "m0", "content": "system note" }
2632 ]
2633 });
2634 write_snapshot(&fs, "blk-4", &snapshot.to_string());
2635 let (preview, count) = read_session_preview(&fs, "blk-4");
2636 assert_eq!(preview, "");
2637 assert_eq!(count, 1);
2638 }
2639
2640 use crate::backend::storage::wstore::{
2650 AgentDefinition, AgentInstance, Identity, InstanceStatus, Memory, WaveStore,
2651 };
2652 use crate::backend::rpc::engine::WshRpcEngine;
2653 use crate::server::AppState;
2654 use std::sync::Arc;
2655
2656 async fn call_rpc<T: serde::de::DeserializeOwned>(
2659 engine: &Arc<WshRpcEngine>,
2660 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2661 command: &str,
2662 data: serde_json::Value,
2663 ) -> T {
2664 let req_id = format!("test-{}", uuid::Uuid::new_v4());
2665 let msg = crate::backend::rpc_types::RpcMessage {
2666 command: command.to_string(),
2667 reqid: req_id.clone(),
2668 data: Some(data),
2669 ..Default::default()
2670 };
2671 engine.handle_message(msg);
2672 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2673 .await
2674 .expect("handler timed out")
2675 .expect("output channel closed");
2676 assert_eq!(resp.resid, req_id, "unexpected response id");
2677 assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
2678 let payload = resp.data.unwrap_or(serde_json::Value::Null);
2679 serde_json::from_value(payload).expect("response deserialize")
2680 }
2681
2682 fn build_state_with_seed() -> (
2683 AppState,
2684 Arc<WshRpcEngine>,
2685 tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2686 ) {
2687 let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
2688 let filestore = Arc::new(FileStore::open_in_memory().unwrap());
2689 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2690 let broker = Arc::new(crate::backend::wps::Broker::new());
2691 let reactive_handler = crate::backend::reactive::get_global_handler();
2692 let poller = Arc::new(crate::backend::reactive::Poller::new(
2693 crate::backend::reactive::PollerConfig {
2694 agentmux_url: None,
2695 agentmux_token: None,
2696 poll_interval_secs: 30,
2697 },
2698 reactive_handler,
2699 ));
2700 crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
2701 let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
2702 let process_tracker = Arc::new(
2703 crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
2704 );
2705 let state = AppState {
2706 auth_key: "test".to_string(),
2707 version: "test".to_string(),
2708 app_path: String::new(),
2709 wstore: wstore.clone(),
2710 filestore: filestore.clone(),
2711 event_bus: event_bus.clone(),
2712 broker,
2713 reactive_handler,
2714 poller,
2715 config_watcher,
2716 messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
2717 http_client: reqwest::Client::new(),
2718 local_web_url: String::new(),
2719 subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus)),
2720 history_service: Arc::new(crate::backend::history::HistoryService::new()),
2721 lan_discovery: None,
2722 process_tracker,
2723 srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
2724 srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
2725 saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2726 saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
2727 auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
2728 install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
2729 };
2730
2731 let def = AgentDefinition {
2744 id: "def-claude".to_string(),
2745 slug: "claude-code".to_string(),
2746 name: "Claude Code".to_string(),
2747 icon: String::new(),
2748 provider: "claude".to_string(),
2749 description: String::new(),
2750 working_directory: String::new(),
2751 shell: String::new(),
2752 provider_flags: String::new(),
2753 auto_start: 0,
2754 restart_on_crash: 0,
2755 idle_timeout_minutes: 0,
2756 created_at: 0,
2757 agent_type: "host".to_string(),
2758 environment: String::new(),
2759 agent_bus_id: String::new(),
2760 is_seeded: 1,
2761 accounts: String::new(),
2762 parent_id: String::new(),
2763 branch_label: String::new(),
2764 updated_at: 0,
2765 user_hidden: 0,
2766 };
2767 let mut def_mut = def.clone();
2768 wstore.agent_def_insert(&mut def_mut).unwrap();
2769 let identity = Identity {
2770 id: "id-work".to_string(),
2771 name: "Work".to_string(),
2772 description: String::new(),
2773 is_blank: false,
2774 created_at: 0,
2775 updated_at: 0,
2776 };
2777 wstore.bundle_identity_upsert(&identity).unwrap();
2778 let memory = Memory {
2779 id: "mem-notes".to_string(),
2780 name: "Notes".to_string(),
2781 description: String::new(),
2782 is_blank: false,
2783 provider: String::new(),
2784 model: String::new(),
2785 instructions: String::new(),
2786 context_files: "[]".to_string(),
2787 mcp_servers: "[]".to_string(),
2788 skills: "[]".to_string(),
2789 created_at: 0,
2790 updated_at: 0,
2791 };
2792 wstore.bundle_memory_upsert(&memory).unwrap();
2793
2794 for (id, block, started) in [
2801 ("inst-recent", "blk-recent", 1_700_000_100_000_i64),
2802 ("inst-older", "blk-older", 1_700_000_000_000_i64),
2803 ("inst-none", "blk-none", 1_700_000_050_000_i64),
2804 ] {
2805 let inst = AgentInstance {
2806 id: id.to_string(),
2807 definition_id: "def-claude".to_string(),
2808 parent_instance_id: String::new(),
2809 block_id: block.to_string(),
2810 session_id: String::new(),
2811 status: InstanceStatus::Running.as_str().to_string(),
2812 github_context: String::new(),
2813 started_at: started,
2814 ended_at: 0,
2815 created_at: started,
2816 identity_id: "id-work".to_string(),
2817 memory_id: "mem-notes".to_string(),
2818 instance_name: format!("name-{id}"),
2819 working_directory: format!("/tmp/{id}"),
2820 display_hidden: false,
2821 };
2822 wstore.instance_create(&inst).unwrap();
2823 }
2824
2825 let snap_older = serde_json::json!({
2836 "schemaVersion": 1,
2837 "nodes": [
2838 {"type": "user_message", "id": "u0",
2839 "message": "earlier conversation"}
2840 ]
2841 });
2842 write_snapshot(&filestore, "blk-older", &snap_older.to_string());
2843 let snap_recent = serde_json::json!({
2844 "schemaVersion": 1,
2845 "nodes": [
2846 {"type": "user_message", "id": "u0",
2847 "message": "# Session Context\nboilerplate"},
2848 {"type": "markdown", "id": "m0", "content": "ack"},
2849 {"type": "user_message", "id": "u1",
2850 "message": "fix the live-feed hover delay"}
2851 ]
2852 });
2853 write_snapshot(&filestore, "blk-recent", &snap_recent.to_string());
2854
2855 let (engine, rx) = WshRpcEngine::new();
2856 super::register_agent_handlers(&engine, &state);
2857 (state, engine, rx)
2858 }
2859
2860 #[tokio::test]
2861 async fn handler_returns_sessions_with_previews_sorted_by_snapshot_first() {
2862 let (_state, engine, mut rx) = build_state_with_seed();
2863 let rows: Vec<RecentSessionRow> = call_rpc(
2864 &engine,
2865 &mut rx,
2866 COMMAND_LIST_RECENT_SESSIONS,
2867 serde_json::json!({}),
2868 )
2869 .await;
2870 assert_eq!(rows.len(), 3, "all three sessions surfaced");
2871
2872 assert_eq!(rows[0].instance_id, "inst-recent");
2875 assert!(rows[0].has_snapshot);
2876 assert_eq!(rows[0].node_count, 3);
2877 assert!(
2878 rows[0].preview.starts_with("fix the live-feed"),
2879 "preview should be the post-context user message, got {:?}",
2880 rows[0].preview
2881 );
2882
2883 assert_eq!(rows[1].instance_id, "inst-older");
2884 assert!(rows[1].has_snapshot);
2885 assert_eq!(rows[1].node_count, 1);
2886 assert_eq!(rows[1].preview, "earlier conversation");
2887
2888 assert_eq!(rows[2].instance_id, "inst-none");
2889 assert!(!rows[2].has_snapshot);
2890 assert_eq!(rows[2].node_count, 0);
2891 assert_eq!(rows[2].preview, "");
2892
2893 assert_eq!(rows[0].definition_name, "Claude Code");
2895 assert_eq!(rows[0].identity_name, "Work");
2896 assert_eq!(rows[0].memory_name, "Notes");
2897 assert_eq!(rows[0].block_id_hint, "blk-recent");
2898 }
2899
2900 #[tokio::test]
2901 async fn handler_identity_filter_restricts_rows() {
2902 let (_state, engine, mut rx) = build_state_with_seed();
2903 let rows: Vec<RecentSessionRow> = call_rpc(
2905 &engine,
2906 &mut rx,
2907 COMMAND_LIST_RECENT_SESSIONS,
2908 serde_json::json!({ "identity_id": "no-such-bundle" }),
2909 )
2910 .await;
2911 assert_eq!(rows.len(), 0);
2912
2913 let rows: Vec<RecentSessionRow> = call_rpc(
2915 &engine,
2916 &mut rx,
2917 COMMAND_LIST_RECENT_SESSIONS,
2918 serde_json::json!({ "identity_id": "id-work" }),
2919 )
2920 .await;
2921 assert_eq!(rows.len(), 3);
2922
2923 let rows: Vec<RecentSessionRow> = call_rpc(
2926 &engine,
2927 &mut rx,
2928 COMMAND_LIST_RECENT_SESSIONS,
2929 serde_json::json!({ "identity_id": "" }),
2930 )
2931 .await;
2932 assert_eq!(rows.len(), 3);
2933 }
2934
2935 #[tokio::test]
2936 async fn handler_respects_limit() {
2937 let (_state, engine, mut rx) = build_state_with_seed();
2938 let rows: Vec<RecentSessionRow> = call_rpc(
2939 &engine,
2940 &mut rx,
2941 COMMAND_LIST_RECENT_SESSIONS,
2942 serde_json::json!({ "limit": 1 }),
2943 )
2944 .await;
2945 assert_eq!(rows.len(), 1);
2946 assert_eq!(rows[0].instance_id, "inst-recent");
2947 }
2948
2949 fn build_state_with_template_seed() -> (
2957 AppState,
2958 Arc<WshRpcEngine>,
2959 tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
2960 ) {
2961 let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
2962 let filestore = Arc::new(FileStore::open_in_memory().unwrap());
2963 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2964 let broker = Arc::new(crate::backend::wps::Broker::new());
2965 let reactive_handler = crate::backend::reactive::get_global_handler();
2966 let poller = Arc::new(crate::backend::reactive::Poller::new(
2967 crate::backend::reactive::PollerConfig {
2968 agentmux_url: None,
2969 agentmux_token: None,
2970 poll_interval_secs: 30,
2971 },
2972 reactive_handler,
2973 ));
2974 crate::backend::wcore::ensure_initial_data(&wstore).unwrap();
2975 let config_watcher = Arc::new(crate::backend::wconfig::ConfigWatcher::new());
2976 let process_tracker = Arc::new(
2977 crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
2978 );
2979 let state = AppState {
2980 auth_key: "test".to_string(),
2981 version: "test".to_string(),
2982 app_path: String::new(),
2983 wstore: wstore.clone(),
2984 filestore: filestore.clone(),
2985 event_bus: event_bus.clone(),
2986 broker,
2987 reactive_handler,
2988 poller,
2989 config_watcher,
2990 messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
2991 http_client: reqwest::Client::new(),
2992 local_web_url: String::new(),
2993 subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus)),
2994 history_service: Arc::new(crate::backend::history::HistoryService::new()),
2995 lan_discovery: None,
2996 process_tracker,
2997 srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
2998 srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
2999 saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3000 saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
3001 auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
3002 install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
3003 };
3004
3005 let mut tpl = AgentDefinition {
3007 id: "tpl-claude".to_string(),
3008 slug: String::new(),
3009 name: "Claude Code".to_string(),
3010 icon: String::new(),
3011 provider: "claude".to_string(),
3012 description: "Anthropic's coding agent".to_string(),
3013 working_directory: String::new(),
3014 shell: String::new(),
3015 provider_flags: "--model haiku".to_string(),
3016 auto_start: 0,
3017 restart_on_crash: 0,
3018 idle_timeout_minutes: 0,
3019 created_at: 1_700_000_000_000,
3020 agent_type: "host".to_string(),
3021 environment: String::new(),
3022 agent_bus_id: String::new(),
3023 is_seeded: 1,
3024 accounts: String::new(),
3025 parent_id: String::new(),
3026 branch_label: String::new(),
3027 updated_at: 1_700_000_000_000,
3028 user_hidden: 0,
3029 };
3030 wstore.agent_def_insert(&mut tpl).unwrap();
3031
3032 let mut user_a = AgentDefinition {
3033 id: "user-a".to_string(),
3034 slug: String::new(),
3035 name: "Maks".to_string(),
3036 icon: String::new(),
3037 provider: "claude".to_string(),
3038 description: String::new(),
3039 working_directory: String::new(),
3040 shell: String::new(),
3041 provider_flags: String::new(),
3042 auto_start: 0,
3043 restart_on_crash: 0,
3044 idle_timeout_minutes: 0,
3045 created_at: 1_700_000_001_000,
3046 agent_type: "host".to_string(),
3047 environment: String::new(),
3048 agent_bus_id: String::new(),
3049 is_seeded: 0,
3050 accounts: String::new(),
3051 parent_id: String::new(),
3052 branch_label: String::new(),
3053 updated_at: 1_700_000_001_000,
3054 user_hidden: 0,
3055 };
3056 wstore.agent_def_insert(&mut user_a).unwrap();
3057
3058 let (engine, rx) = WshRpcEngine::new();
3059 super::register_agent_handlers(&engine, &state);
3060 (state, engine, rx)
3061 }
3062
3063 #[tokio::test]
3064 async fn listagents_no_filter_returns_all() {
3065 let (_state, engine, mut rx) = build_state_with_template_seed();
3066 let agents: Vec<AgentDefinition> = call_rpc(
3067 &engine,
3068 &mut rx,
3069 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3070 serde_json::json!({}),
3071 )
3072 .await;
3073 assert!(agents.iter().any(|a| a.id == "tpl-claude"));
3074 assert!(agents.iter().any(|a| a.id == "user-a"));
3075 }
3076
3077 #[tokio::test]
3078 async fn listagents_filter_templates_only() {
3079 let (_state, engine, mut rx) = build_state_with_template_seed();
3080 let agents: Vec<AgentDefinition> = call_rpc(
3081 &engine,
3082 &mut rx,
3083 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3084 serde_json::json!({ "is_seeded": 1 }),
3085 )
3086 .await;
3087 assert!(agents.iter().all(|a| a.is_seeded == 1));
3088 assert!(agents.iter().any(|a| a.id == "tpl-claude"));
3089 assert!(!agents.iter().any(|a| a.id == "user-a"));
3090 }
3091
3092 #[tokio::test]
3093 async fn listagents_filter_user_owned_only() {
3094 let (_state, engine, mut rx) = build_state_with_template_seed();
3095 let agents: Vec<AgentDefinition> = call_rpc(
3096 &engine,
3097 &mut rx,
3098 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3099 serde_json::json!({ "is_seeded": 0 }),
3100 )
3101 .await;
3102 assert!(agents.iter().all(|a| a.is_seeded == 0));
3103 assert!(agents.iter().any(|a| a.id == "user-a"));
3104 assert!(!agents.iter().any(|a| a.id == "tpl-claude"));
3105 }
3106
3107 #[tokio::test]
3108 async fn create_from_template_happy_path_clones_and_returns_id() {
3109 let (_state, engine, mut rx) = build_state_with_template_seed();
3110 let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
3111 &engine,
3112 &mut rx,
3113 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3114 serde_json::json!({
3115 "template_id": "tpl-claude",
3116 "name": "Asaf",
3117 "identity_id": "id-work",
3118 "memory_id": "mem-notes",
3119 }),
3120 )
3121 .await;
3122 assert!(!resp.definition_id.is_empty());
3123 assert_eq!(resp.identity_id, "id-work");
3124 assert_eq!(resp.memory_id, "mem-notes");
3125
3126 let agents: Vec<AgentDefinition> = call_rpc(
3128 &engine,
3129 &mut rx,
3130 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3131 serde_json::json!({}),
3132 )
3133 .await;
3134 let new_def = agents
3135 .iter()
3136 .find(|a| a.id == resp.definition_id)
3137 .expect("new definition should appear in listagents");
3138 assert_eq!(new_def.is_seeded, 0);
3139 assert_eq!(new_def.name, "Asaf");
3140 assert_eq!(new_def.provider, "claude");
3141 assert_eq!(new_def.provider_flags, "--model haiku");
3142 assert_eq!(new_def.parent_id, "tpl-claude");
3143 }
3144
3145 async fn call_rpc_expect_error(
3146 engine: &Arc<WshRpcEngine>,
3147 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
3148 command: &str,
3149 data: serde_json::Value,
3150 ) -> String {
3151 let req_id = format!("test-{}", uuid::Uuid::new_v4());
3152 let msg = crate::backend::rpc_types::RpcMessage {
3153 command: command.to_string(),
3154 reqid: req_id.clone(),
3155 data: Some(data),
3156 ..Default::default()
3157 };
3158 engine.handle_message(msg);
3159 let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3160 .await
3161 .expect("handler timed out")
3162 .expect("output channel closed");
3163 assert_eq!(resp.resid, req_id);
3164 assert!(
3165 !resp.error.is_empty(),
3166 "expected error, got success payload: {:?}",
3167 resp.data
3168 );
3169 resp.error
3170 }
3171
3172 #[tokio::test]
3173 async fn create_from_template_rejects_non_template_id() {
3174 let (_state, engine, mut rx) = build_state_with_template_seed();
3175 let err = call_rpc_expect_error(
3177 &engine,
3178 &mut rx,
3179 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3180 serde_json::json!({
3181 "template_id": "user-a",
3182 "name": "another",
3183 }),
3184 )
3185 .await;
3186 assert!(
3187 err.contains("not a seeded template"),
3188 "wrong error: {err}"
3189 );
3190 }
3191
3192 #[tokio::test]
3193 async fn create_from_template_rejects_unknown_template_id() {
3194 let (_state, engine, mut rx) = build_state_with_template_seed();
3195 let err = call_rpc_expect_error(
3196 &engine,
3197 &mut rx,
3198 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3199 serde_json::json!({
3200 "template_id": "no-such-id",
3201 "name": "x",
3202 }),
3203 )
3204 .await;
3205 assert!(err.contains("not found"), "wrong error: {err}");
3206 }
3207
3208 #[tokio::test]
3209 async fn create_from_template_rejects_duplicate_user_name() {
3210 let (_state, engine, mut rx) = build_state_with_template_seed();
3211 let err = call_rpc_expect_error(
3213 &engine,
3214 &mut rx,
3215 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3216 serde_json::json!({
3217 "template_id": "tpl-claude",
3218 "name": "Maks",
3219 }),
3220 )
3221 .await;
3222 assert!(
3223 err.contains("already exists"),
3224 "wrong error: {err}"
3225 );
3226 }
3227
3228 #[tokio::test]
3229 async fn create_from_template_rejects_empty_name() {
3230 let (_state, engine, mut rx) = build_state_with_template_seed();
3231 let err = call_rpc_expect_error(
3232 &engine,
3233 &mut rx,
3234 crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
3235 serde_json::json!({
3236 "template_id": "tpl-claude",
3237 "name": " ",
3238 }),
3239 )
3240 .await;
3241 assert!(err.contains("non-empty"), "wrong error: {err}");
3242 }
3243
3244 #[tokio::test]
3249 async fn hide_template_then_listagents_excludes_it_by_default() {
3250 let (_state, engine, mut rx) = build_state_with_template_seed();
3251
3252 let before: Vec<AgentDefinition> = call_rpc(
3254 &engine,
3255 &mut rx,
3256 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3257 serde_json::json!({}),
3258 )
3259 .await;
3260 assert!(before.iter().any(|a| a.id == "tpl-claude"));
3261
3262 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3264 &engine,
3265 &mut rx,
3266 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3267 serde_json::json!({ "definition_id": "tpl-claude" }),
3268 )
3269 .await;
3270 assert!(resp.ok);
3271
3272 let after: Vec<AgentDefinition> = call_rpc(
3274 &engine,
3275 &mut rx,
3276 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3277 serde_json::json!({}),
3278 )
3279 .await;
3280 assert!(
3281 !after.iter().any(|a| a.id == "tpl-claude"),
3282 "hidden template should NOT appear by default",
3283 );
3284
3285 assert!(after.iter().any(|a| a.id == "user-a"));
3288
3289 let included: Vec<AgentDefinition> = call_rpc(
3291 &engine,
3292 &mut rx,
3293 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3294 serde_json::json!({ "include_hidden": true }),
3295 )
3296 .await;
3297 let tpl = included
3298 .iter()
3299 .find(|a| a.id == "tpl-claude")
3300 .expect("hidden template should appear with include_hidden=true");
3301 assert_eq!(tpl.user_hidden, 1);
3302 }
3303
3304 #[tokio::test]
3305 async fn hide_then_unhide_round_trip() {
3306 let (_state, engine, mut rx) = build_state_with_template_seed();
3307 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3308 &engine,
3309 &mut rx,
3310 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3311 serde_json::json!({ "definition_id": "tpl-claude" }),
3312 )
3313 .await;
3314 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3315 &engine,
3316 &mut rx,
3317 crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
3318 serde_json::json!({ "definition_id": "tpl-claude" }),
3319 )
3320 .await;
3321 assert!(resp.ok);
3322 let agents: Vec<AgentDefinition> = call_rpc(
3324 &engine,
3325 &mut rx,
3326 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3327 serde_json::json!({}),
3328 )
3329 .await;
3330 let tpl = agents
3331 .iter()
3332 .find(|a| a.id == "tpl-claude")
3333 .expect("unhidden template should appear");
3334 assert_eq!(tpl.user_hidden, 0);
3335 }
3336
3337 #[tokio::test]
3338 async fn hide_rejects_user_owned_definition() {
3339 let (_state, engine, mut rx) = build_state_with_template_seed();
3340 let err = call_rpc_expect_error(
3342 &engine,
3343 &mut rx,
3344 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3345 serde_json::json!({ "definition_id": "user-a" }),
3346 )
3347 .await;
3348 assert!(
3349 err.contains("not a seeded template"),
3350 "wrong error: {err}"
3351 );
3352 }
3353
3354 #[tokio::test]
3355 async fn hide_unknown_id_returns_ok_false() {
3356 let (_state, engine, mut rx) = build_state_with_template_seed();
3357 let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3358 &engine,
3359 &mut rx,
3360 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3361 serde_json::json!({ "definition_id": "no-such-id" }),
3362 )
3363 .await;
3364 assert!(!resp.ok);
3365 }
3366
3367 #[tokio::test]
3368 async fn list_hidden_templates_returns_only_hidden_templates() {
3369 let (_state, engine, mut rx) = build_state_with_template_seed();
3370 let empty: Vec<AgentDefinition> = call_rpc(
3372 &engine,
3373 &mut rx,
3374 crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
3375 serde_json::json!({}),
3376 )
3377 .await;
3378 assert!(empty.is_empty());
3379
3380 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3382 &engine,
3383 &mut rx,
3384 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3385 serde_json::json!({ "definition_id": "tpl-claude" }),
3386 )
3387 .await;
3388 let hidden: Vec<AgentDefinition> = call_rpc(
3389 &engine,
3390 &mut rx,
3391 crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
3392 serde_json::json!({}),
3393 )
3394 .await;
3395 assert_eq!(hidden.len(), 1);
3396 assert_eq!(hidden[0].id, "tpl-claude");
3397 assert_eq!(hidden[0].is_seeded, 1);
3398 assert_eq!(hidden[0].user_hidden, 1);
3399 }
3400
3401 #[tokio::test]
3402 async fn listagents_is_seeded_filter_with_include_hidden_combines() {
3403 let (_state, engine, mut rx) = build_state_with_template_seed();
3407 let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
3408 &engine,
3409 &mut rx,
3410 crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
3411 serde_json::json!({ "definition_id": "tpl-claude" }),
3412 )
3413 .await;
3414 let templates_visible: Vec<AgentDefinition> = call_rpc(
3415 &engine,
3416 &mut rx,
3417 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3418 serde_json::json!({ "is_seeded": 1 }),
3419 )
3420 .await;
3421 assert!(
3422 !templates_visible.iter().any(|a| a.id == "tpl-claude"),
3423 "hidden template should be excluded from is_seeded=1 default query",
3424 );
3425 let templates_all: Vec<AgentDefinition> = call_rpc(
3426 &engine,
3427 &mut rx,
3428 crate::backend::rpc_types::COMMAND_LIST_AGENTS,
3429 serde_json::json!({ "is_seeded": 1, "include_hidden": true }),
3430 )
3431 .await;
3432 assert!(templates_all.iter().any(|a| a.id == "tpl-claude"));
3433 }
3434}