1use std::sync::Arc;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::Deserialize;
13
14use super::storage::wstore::{AgentDefinition, AgentContent, AgentSkill, WaveStore};
15use super::storage::StoreError;
16
17pub struct SeedReport {
19 pub created: usize,
20 pub skipped: usize,
21}
22
23#[derive(Debug, Deserialize)]
25struct SeedManifest {
26 #[allow(dead_code)]
27 version: u32,
28 agents: Vec<SeedAgent>,
29}
30
31#[derive(Debug, Deserialize)]
33struct SeedAgent {
34 id: String,
35 name: String,
36 #[serde(default = "default_icon")]
37 icon: String,
38 #[serde(default = "default_provider")]
40 provider: String,
41 #[serde(default = "default_agent_type")]
43 agent_type: String,
44 #[serde(default = "default_environment")]
46 environment: String,
47 #[serde(default)]
48 description: String,
49 #[serde(default)]
50 working_directory: String,
51 #[serde(default)]
52 shell: String,
53 #[serde(default)]
54 agent_bus_id: String,
55 #[serde(default)]
56 auto_start: bool,
57 #[serde(default)]
58 restart_on_crash: bool,
59 #[serde(default)]
60 content: SeedContent,
61 #[serde(default)]
62 skills: Vec<SeedSkill>,
63}
64
65fn default_icon() -> String {
66 "\u{2726}".to_string()
67}
68
69fn default_provider() -> String {
70 "claude".to_string()
71}
72
73fn default_agent_type() -> String {
74 "host".to_string()
75}
76
77fn default_environment() -> String {
78 std::env::consts::OS.to_string()
79}
80
81#[derive(Debug, Default, Deserialize)]
83struct SeedContent {
84 #[serde(default)]
85 agentmd: Option<String>,
86 #[serde(default)]
87 mcp: Option<String>,
88 #[serde(default)]
89 env: Option<String>,
90 #[serde(default)]
91 soul: Option<String>,
92 #[serde(default)]
93 startup: Option<String>,
94}
95
96#[derive(Debug, Deserialize)]
98struct SeedSkill {
99 name: String,
100 #[serde(default)]
101 trigger: String,
102 #[serde(default = "default_skill_type")]
103 skill_type: String,
104 #[serde(default)]
105 description: String,
106 #[serde(default)]
107 content: String,
108}
109
110fn default_skill_type() -> String {
111 "prompt".to_string()
112}
113
114const SEED_MANIFEST: &str = include_str!("../../agent-seed.json");
116
117pub fn seed_agents(wstore: &Arc<WaveStore>) -> Result<SeedReport, StoreError> {
120 let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST)
121 .map_err(|e| StoreError::Other(format!("agent seed: parse manifest: {e}")))?;
122
123 let existing = wstore.agent_def_list()?;
124 let existing_ids: std::collections::HashSet<String> =
125 existing.iter().map(|a| a.id.clone()).collect();
126
127 let now = SystemTime::now()
128 .duration_since(UNIX_EPOCH)
129 .unwrap_or_default()
130 .as_millis() as i64;
131
132 let mut created = 0usize;
133 let mut skipped = 0usize;
134
135 for agent_def in &manifest.agents {
136 if existing_ids.contains(&agent_def.id) {
137 skipped += 1;
138 continue;
139 }
140
141 let mut agent = AgentDefinition {
146 id: agent_def.id.clone(),
147 slug: agent_def.id.clone(),
148 name: agent_def.name.clone(),
149 icon: agent_def.icon.clone(),
150 provider: agent_def.provider.clone(),
151 description: agent_def.description.clone(),
152 working_directory: agent_def.working_directory.clone(),
153 shell: agent_def.shell.clone(),
154 provider_flags: String::new(),
155 auto_start: if agent_def.auto_start { 1 } else { 0 },
156 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
157 idle_timeout_minutes: 0,
158 created_at: now,
159 agent_type: agent_def.agent_type.clone(),
160 environment: agent_def.environment.clone(),
161 agent_bus_id: agent_def.agent_bus_id.clone(),
162 is_seeded: 1,
163 accounts: String::new(),
164 parent_id: String::new(),
165 branch_label: String::new(),
166 updated_at: now,
167 user_hidden: 0,
172 };
173 wstore.agent_def_insert(&mut agent)?;
174
175 let content_pairs = [
177 ("agentmd", &agent_def.content.agentmd),
178 ("mcp", &agent_def.content.mcp),
179 ("env", &agent_def.content.env),
180 ("soul", &agent_def.content.soul),
181 ("startup", &agent_def.content.startup),
182 ];
183 for (content_type, maybe_content) in &content_pairs {
184 if let Some(content) = maybe_content {
185 if !content.is_empty() {
186 wstore.agent_content_set(&AgentContent {
187 agent_id: agent_def.id.clone(),
188 content_type: content_type.to_string(),
189 content: content.clone(),
190 updated_at: now,
191 })?;
192 }
193 }
194 }
195
196 for skill_def in &agent_def.skills {
198 let skill = AgentSkill {
199 id: uuid::Uuid::new_v4().to_string(),
200 agent_id: agent_def.id.clone(),
201 name: skill_def.name.clone(),
202 trigger: skill_def.trigger.clone(),
203 skill_type: skill_def.skill_type.clone(),
204 description: skill_def.description.clone(),
205 content: skill_def.content.clone(),
206 created_at: now,
207 };
208 wstore.agent_skill_insert(&skill)?;
209 }
210
211 created += 1;
212 }
213
214 Ok(SeedReport { created, skipped })
215}
216
217pub fn auto_seed_on_startup(wstore: &Arc<WaveStore>) {
220 let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
221 Ok(m) => m,
222 Err(e) => {
223 tracing::error!("agent seed: failed to parse seed manifest: {e}");
224 return;
225 }
226 };
227
228 match wstore.agent_def_count() {
229 Ok(0) => {
230 tracing::info!("agent seed: no agents found, seeding from manifest v{}...", manifest.version);
231 match seed_agents(wstore) {
232 Ok(report) => {
233 tracing::info!(
234 "agent seed: seeded {} agents ({} skipped)",
235 report.created,
236 report.skipped
237 );
238 }
239 Err(e) => tracing::error!("agent seed: failed: {e}"),
240 }
241 }
242 Ok(count) => {
243 match reseed_if_needed(wstore, &manifest) {
245 Ok(Some(report)) => {
246 tracing::info!(
247 "agent seed: re-seeded from manifest v{}: {} created, {} updated, {} removed",
248 manifest.version, report.created, report.updated, report.removed,
249 );
250 }
251 Ok(None) => {
252 tracing::info!("agent seed: {} agents exist, manifest up to date", count);
253 }
254 Err(e) => tracing::error!("agent seed: re-seed failed: {e}"),
255 }
256 }
257 Err(e) => tracing::error!("agent seed: failed to count agents: {e}"),
258 }
259}
260
261pub struct ReseedReport {
263 pub created: usize,
264 pub updated: usize,
265 pub removed: usize,
266}
267
268fn reseed_if_needed(
271 wstore: &Arc<WaveStore>,
272 manifest: &SeedManifest,
273) -> Result<Option<ReseedReport>, StoreError> {
274 let existing = wstore.agent_def_list()?;
275
276 let manifest_ids: std::collections::HashSet<&str> =
278 manifest.agents.iter().map(|a| a.id.as_str()).collect();
279 let existing_map: std::collections::HashMap<&str, &AgentDefinition> =
280 existing.iter().map(|a| (a.id.as_str(), a)).collect();
281
282 let mut needs_reseed = false;
283
284 for agent_def in &manifest.agents {
286 match existing_map.get(agent_def.id.as_str()) {
287 None => { needs_reseed = true; break; }
288 Some(existing_agent) => {
289 if existing_agent.description != agent_def.description {
292 needs_reseed = true;
293 break;
294 }
295 }
296 }
297 }
298
299 for agent in &existing {
301 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
302 needs_reseed = true;
303 break;
304 }
305 }
306
307 if !needs_reseed {
308 return Ok(None);
309 }
310
311 let now = std::time::SystemTime::now()
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap_or_default()
314 .as_millis() as i64;
315
316 let mut created = 0usize;
317 let mut updated = 0usize;
318 let mut removed = 0usize;
319
320 for agent_def in &manifest.agents {
322 let mut agent = AgentDefinition {
323 id: agent_def.id.clone(),
324 slug: agent_def.id.clone(),
325 name: agent_def.name.clone(),
326 icon: agent_def.icon.clone(),
327 provider: agent_def.provider.clone(),
328 description: agent_def.description.clone(),
329 working_directory: agent_def.working_directory.clone(),
330 shell: agent_def.shell.clone(),
331 provider_flags: String::new(),
332 auto_start: if agent_def.auto_start { 1 } else { 0 },
333 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
334 idle_timeout_minutes: 0,
335 created_at: now,
336 agent_type: agent_def.agent_type.clone(),
337 environment: agent_def.environment.clone(),
338 agent_bus_id: agent_def.agent_bus_id.clone(),
339 is_seeded: 1,
340 accounts: String::new(),
341 parent_id: String::new(),
342 branch_label: String::new(),
343 updated_at: now,
344 user_hidden: 0,
351 };
352
353 if let Some(existing_agent) = existing_map.get(agent_def.id.as_str()) {
354 agent.provider = existing_agent.provider.clone();
358 agent.agent_type = existing_agent.agent_type.clone();
359 agent.environment = existing_agent.environment.clone();
360 agent.shell = if existing_agent.shell.is_empty() {
361 agent_def.shell.clone()
362 } else {
363 existing_agent.shell.clone()
364 };
365 agent.auto_start = existing_agent.auto_start;
366 agent.restart_on_crash = existing_agent.restart_on_crash;
367 agent.created_at = existing_agent.created_at;
368 agent.accounts = existing_agent.accounts.clone();
369 agent.user_hidden = existing_agent.user_hidden;
375 wstore.agent_def_update(&mut agent)?;
376 updated += 1;
377 } else {
378 wstore.agent_def_insert(&mut agent)?;
379 created += 1;
380 }
381 }
382
383 for agent in &existing {
385 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
386 wstore.agent_def_delete(&agent.id)?;
387 removed += 1;
388 tracing::info!("agent seed: removed seeded agent '{}'", agent.id);
389 }
390 }
391
392 Ok(Some(ReseedReport { created, updated, removed }))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::backend::storage::wstore::{AgentDefinition, WaveStore};
399
400 fn manifest_with(ids_and_descriptions: &[(&str, &str)]) -> SeedManifest {
404 SeedManifest {
405 version: 999,
406 agents: ids_and_descriptions
407 .iter()
408 .map(|(id, desc)| SeedAgent {
409 id: id.to_string(),
410 name: id.to_string(),
411 icon: default_icon(),
412 provider: default_provider(),
413 agent_type: default_agent_type(),
414 environment: default_environment(),
415 description: desc.to_string(),
416 working_directory: String::new(),
417 shell: String::new(),
418 agent_bus_id: String::new(),
419 auto_start: false,
420 restart_on_crash: false,
421 content: SeedContent::default(),
422 skills: Vec::new(),
423 })
424 .collect(),
425 }
426 }
427
428 fn insert_tpl(wstore: &Arc<WaveStore>, id: &str, name: &str, hidden: i64) {
429 let mut def = AgentDefinition {
430 id: id.to_string(),
431 slug: id.to_string(),
432 name: name.to_string(),
433 icon: "✦".to_string(),
434 provider: "claude".to_string(),
435 description: "v1 desc".to_string(),
436 working_directory: String::new(),
437 shell: String::new(),
438 provider_flags: String::new(),
439 auto_start: 0,
440 restart_on_crash: 0,
441 idle_timeout_minutes: 0,
442 created_at: 1_700_000_000_000,
443 agent_type: "host".to_string(),
444 environment: String::new(),
445 agent_bus_id: String::new(),
446 is_seeded: 1,
447 accounts: String::new(),
448 parent_id: String::new(),
449 branch_label: String::new(),
450 updated_at: 1_700_000_000_000,
451 user_hidden: hidden,
452 };
453 wstore.agent_def_insert(&mut def).unwrap();
454 }
455
456 #[test]
457 fn reseed_preserves_user_hidden_on_existing_templates() {
458 let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
462 insert_tpl(&wstore, "tpl-claude", "Claude", 1);
463 let manifest = manifest_with(&[("tpl-claude", "v2 desc")]);
466
467 let report = reseed_if_needed(&wstore, &manifest)
468 .expect("reseed succeeds")
469 .expect("reseed runs because description changed");
470 assert_eq!(report.created, 0);
471 assert_eq!(report.updated, 1);
472
473 let after = wstore.agent_def_list().unwrap();
474 let tpl = after.iter().find(|a| a.id == "tpl-claude").unwrap();
475 assert_eq!(tpl.user_hidden, 1, "hide preference must survive reseed");
476 assert_eq!(tpl.description, "v2 desc", "description must update");
477 }
478
479 #[test]
480 fn reseed_resets_user_hidden_on_newly_added_template_id() {
481 let wstore = Arc::new(WaveStore::open_in_memory().unwrap());
486 insert_tpl(&wstore, "tpl-claude", "Claude", 1);
487 let manifest = manifest_with(&[
488 ("tpl-claude", "v1 desc"), ("tpl-codex", "Codex CLI"), ]);
491
492 let report = reseed_if_needed(&wstore, &manifest)
493 .expect("reseed succeeds")
494 .expect("reseed runs because tpl-codex is new");
495 assert!(report.created >= 1, "tpl-codex should be inserted");
496
497 let after = wstore.agent_def_list().unwrap();
498 let codex = after
499 .iter()
500 .find(|a| a.id == "tpl-codex")
501 .expect("tpl-codex should now exist");
502 assert_eq!(
503 codex.user_hidden, 0,
504 "newly-added template must start visible (Phase 2 spec invariant)",
505 );
506 let claude = after.iter().find(|a| a.id == "tpl-claude").unwrap();
508 assert_eq!(claude.user_hidden, 1);
509 }
510}