1use std::sync::Arc;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::Deserialize;
13
14use super::storage::memory_bundles::Memory;
15use super::storage::store::{AgentDefinition, AgentContent, AgentSkill, Store};
16use super::storage::StoreError;
17
18pub struct SeedReport {
20 pub created: usize,
21 pub skipped: usize,
22}
23
24#[derive(Debug, Deserialize)]
26struct SeedManifest {
27 #[allow(dead_code)]
28 version: u32,
29 agents: Vec<SeedAgent>,
30 #[serde(default)]
31 memories: Vec<SeedMemory>,
32}
33
34#[derive(Debug, Deserialize)]
36struct SeedMemory {
37 id: String,
38 name: String,
39 #[serde(default)]
40 description: String,
41 #[serde(default)]
45 is_global: bool,
46 #[serde(default)]
47 instructions: String,
48}
49
50#[derive(Debug, Deserialize)]
52struct SeedAgent {
53 id: String,
54 name: String,
55 #[serde(default = "default_icon")]
56 icon: String,
57 #[serde(default = "default_provider")]
59 provider: String,
60 #[serde(default = "default_agent_type")]
62 agent_type: String,
63 #[serde(default = "default_environment")]
65 environment: String,
66 #[serde(default)]
67 description: String,
68 #[serde(default)]
69 working_directory: String,
70 #[serde(default)]
71 shell: String,
72 #[serde(default)]
73 agent_bus_id: String,
74 #[serde(default)]
75 auto_start: bool,
76 #[serde(default)]
77 restart_on_crash: bool,
78 #[serde(default)]
81 container_image: String,
82 #[serde(default)]
83 content: SeedContent,
84 #[serde(default)]
85 skills: Vec<SeedSkill>,
86}
87
88fn default_icon() -> String {
89 "\u{2726}".to_string()
90}
91
92fn default_provider() -> String {
93 "claude".to_string()
94}
95
96fn default_agent_type() -> String {
97 "host".to_string()
98}
99
100fn default_environment() -> String {
101 std::env::consts::OS.to_string()
102}
103
104#[derive(Debug, Default, Deserialize)]
106struct SeedContent {
107 #[serde(default)]
108 agentmd: Option<String>,
109 #[serde(default)]
110 mcp: Option<String>,
111 #[serde(default)]
112 env: Option<String>,
113 #[serde(default)]
114 soul: Option<String>,
115 #[serde(default)]
116 startup: Option<String>,
117}
118
119#[derive(Debug, Deserialize)]
121struct SeedSkill {
122 name: String,
123 #[serde(default)]
124 trigger: String,
125 #[serde(default = "default_skill_type")]
126 skill_type: String,
127 #[serde(default)]
128 description: String,
129 #[serde(default)]
130 content: String,
131}
132
133fn default_skill_type() -> String {
134 "prompt".to_string()
135}
136
137const SEED_MANIFEST: &str = include_str!("../../agent-seed.json");
139
140pub fn seed_agents(wstore: &Arc<Store>) -> Result<SeedReport, StoreError> {
143 let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST)
144 .map_err(|e| StoreError::Other(format!("agent seed: parse manifest: {e}")))?;
145
146 let existing = wstore.agent_def_list()?;
147 let existing_ids: std::collections::HashSet<String> =
148 existing.iter().map(|a| a.id.clone()).collect();
149
150 let now = SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .unwrap_or_default()
153 .as_millis() as i64;
154
155 let mut created = 0usize;
156 let mut skipped = 0usize;
157
158 for agent_def in &manifest.agents {
159 if existing_ids.contains(&agent_def.id) {
160 skipped += 1;
161 continue;
162 }
163
164 let mut agent = AgentDefinition {
169 id: agent_def.id.clone(),
170 slug: agent_def.id.clone(),
171 name: agent_def.name.clone(),
172 icon: agent_def.icon.clone(),
173 provider: agent_def.provider.clone(),
174 description: agent_def.description.clone(),
175 working_directory: agent_def.working_directory.clone(),
176 shell: agent_def.shell.clone(),
177 provider_flags: String::new(),
178 auto_start: if agent_def.auto_start { 1 } else { 0 },
179 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
180 idle_timeout_minutes: 0,
181 created_at: now,
182 agent_type: agent_def.agent_type.clone(),
183 environment: agent_def.environment.clone(),
184 agent_bus_id: agent_def.agent_bus_id.clone(),
185 is_seeded: 1,
186 accounts: String::new(),
187 parent_id: String::new(),
188 branch_label: String::new(),
189 updated_at: now,
190 user_hidden: 0,
195 container_image: agent_def.container_image.clone(),
196 container_volumes: "[]".to_string(),
197 container_name: String::new(),
198 };
199 wstore.agent_def_insert(&mut agent)?;
200
201 let content_pairs = [
203 ("agentmd", &agent_def.content.agentmd),
204 ("mcp", &agent_def.content.mcp),
205 ("env", &agent_def.content.env),
206 ("soul", &agent_def.content.soul),
207 ("startup", &agent_def.content.startup),
208 ];
209 for (content_type, maybe_content) in &content_pairs {
210 if let Some(content) = maybe_content {
211 if !content.is_empty() {
212 wstore.agent_content_set(&AgentContent {
213 agent_id: agent_def.id.clone(),
214 content_type: content_type.to_string(),
215 content: content.clone(),
216 updated_at: now,
217 })?;
218 }
219 }
220 }
221
222 for skill_def in &agent_def.skills {
224 let skill = AgentSkill {
225 id: uuid::Uuid::new_v4().to_string(),
226 agent_id: agent_def.id.clone(),
227 name: skill_def.name.clone(),
228 trigger: skill_def.trigger.clone(),
229 skill_type: skill_def.skill_type.clone(),
230 description: skill_def.description.clone(),
231 content: skill_def.content.clone(),
232 created_at: now,
233 };
234 wstore.agent_skill_insert(&skill)?;
235 }
236
237 created += 1;
238 }
239
240 Ok(SeedReport { created, skipped })
241}
242
243fn seed_memories(wstore: &Arc<Store>, manifest: &SeedManifest) -> Result<usize, StoreError> {
246 let existing = wstore.bundle_memory_list()?;
247 let existing_ids: std::collections::HashSet<String> =
248 existing.iter().map(|m| m.id.clone()).collect();
249
250 let now = SystemTime::now()
251 .duration_since(UNIX_EPOCH)
252 .unwrap_or_default()
253 .as_millis() as i64;
254
255 let mut created = 0usize;
256 for (idx, mem_def) in manifest.memories.iter().enumerate() {
257 if existing_ids.contains(&mem_def.id) {
258 continue;
259 }
260 let memory = Memory {
261 id: mem_def.id.clone(),
262 name: mem_def.name.clone(),
263 description: mem_def.description.clone(),
264 is_blank: false,
265 is_global: mem_def.is_global,
266 provider: String::new(),
267 model: String::new(),
268 instructions: mem_def.instructions.clone(),
269 context_files: "[]".to_string(),
270 mcp_servers: "[]".to_string(),
271 skills: "[]".to_string(),
272 sort_order: idx as i64,
275 created_at: now,
276 updated_at: now,
277 };
278 match wstore.bundle_memory_upsert(&memory) {
282 Ok(()) => { created += 1; }
283 Err(e) => {
284 tracing::warn!(
285 id = %mem_def.id,
286 name = %mem_def.name,
287 error = %e,
288 "agent seed: skipping memory bundle due to upsert error (name collision?)"
289 );
290 }
291 }
292 }
293
294 Ok(created)
295}
296
297pub fn auto_seed_on_startup(wstore: &Arc<Store>) {
300 let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
301 Ok(m) => m,
302 Err(e) => {
303 tracing::error!("agent seed: failed to parse seed manifest: {e}");
304 return;
305 }
306 };
307
308 match wstore.agent_def_count() {
309 Ok(0) => {
310 tracing::info!("agent seed: no agents found, seeding from manifest v{}...", manifest.version);
311 match seed_agents(wstore) {
312 Ok(report) => {
313 tracing::info!(
314 "agent seed: seeded {} agents ({} skipped)",
315 report.created,
316 report.skipped
317 );
318 }
319 Err(e) => tracing::error!("agent seed: failed: {e}"),
320 }
321 }
322 Ok(count) => {
323 match reseed_if_needed(wstore, &manifest) {
325 Ok(Some(report)) => {
326 tracing::info!(
327 "agent seed: re-seeded from manifest v{}: {} created, {} updated, {} removed",
328 manifest.version, report.created, report.updated, report.removed,
329 );
330 }
331 Ok(None) => {
332 tracing::info!("agent seed: {} agents exist, manifest up to date", count);
333 }
334 Err(e) => tracing::error!("agent seed: re-seed failed: {e}"),
335 }
336 }
337 Err(e) => tracing::error!("agent seed: failed to count agents: {e}"),
338 }
339
340 if !manifest.memories.is_empty() {
342 match seed_memories(wstore, &manifest) {
343 Ok(0) => {}
344 Ok(n) => tracing::info!("agent seed: seeded {n} memory bundles"),
345 Err(e) => tracing::error!("agent seed: failed to seed memories: {e}"),
346 }
347 }
348}
349
350pub struct ReseedReport {
352 pub created: usize,
353 pub updated: usize,
354 pub removed: usize,
355}
356
357fn reseed_if_needed(
360 wstore: &Arc<Store>,
361 manifest: &SeedManifest,
362) -> Result<Option<ReseedReport>, StoreError> {
363 let existing = wstore.agent_def_list()?;
364
365 let manifest_ids: std::collections::HashSet<&str> =
367 manifest.agents.iter().map(|a| a.id.as_str()).collect();
368 let existing_map: std::collections::HashMap<&str, &AgentDefinition> =
369 existing.iter().map(|a| (a.id.as_str(), a)).collect();
370
371 let mut needs_reseed = false;
372
373 for agent_def in &manifest.agents {
375 match existing_map.get(agent_def.id.as_str()) {
376 None => { needs_reseed = true; break; }
377 Some(existing_agent) => {
378 if existing_agent.description != agent_def.description {
381 needs_reseed = true;
382 break;
383 }
384 }
385 }
386 }
387
388 for agent in &existing {
390 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
391 needs_reseed = true;
392 break;
393 }
394 }
395
396 if !needs_reseed {
397 return Ok(None);
398 }
399
400 let now = std::time::SystemTime::now()
401 .duration_since(std::time::UNIX_EPOCH)
402 .unwrap_or_default()
403 .as_millis() as i64;
404
405 let mut created = 0usize;
406 let mut updated = 0usize;
407 let mut removed = 0usize;
408
409 for agent_def in &manifest.agents {
411 let mut agent = AgentDefinition {
412 id: agent_def.id.clone(),
413 slug: agent_def.id.clone(),
414 name: agent_def.name.clone(),
415 icon: agent_def.icon.clone(),
416 provider: agent_def.provider.clone(),
417 description: agent_def.description.clone(),
418 working_directory: agent_def.working_directory.clone(),
419 shell: agent_def.shell.clone(),
420 provider_flags: String::new(),
421 auto_start: if agent_def.auto_start { 1 } else { 0 },
422 restart_on_crash: if agent_def.restart_on_crash { 1 } else { 0 },
423 idle_timeout_minutes: 0,
424 created_at: now,
425 agent_type: agent_def.agent_type.clone(),
426 environment: agent_def.environment.clone(),
427 agent_bus_id: agent_def.agent_bus_id.clone(),
428 is_seeded: 1,
429 accounts: String::new(),
430 parent_id: String::new(),
431 branch_label: String::new(),
432 updated_at: now,
433 user_hidden: 0,
440 container_image: agent_def.container_image.clone(),
441 container_volumes: "[]".to_string(),
442 container_name: String::new(),
443 };
444
445 if let Some(existing_agent) = existing_map.get(agent_def.id.as_str()) {
446 agent.provider = existing_agent.provider.clone();
450 agent.agent_type = existing_agent.agent_type.clone();
451 agent.environment = existing_agent.environment.clone();
452 agent.shell = if existing_agent.shell.is_empty() {
453 agent_def.shell.clone()
454 } else {
455 existing_agent.shell.clone()
456 };
457 agent.auto_start = existing_agent.auto_start;
458 agent.restart_on_crash = existing_agent.restart_on_crash;
459 agent.created_at = existing_agent.created_at;
460 agent.accounts = existing_agent.accounts.clone();
461 agent.user_hidden = existing_agent.user_hidden;
467 wstore.agent_def_update(&mut agent)?;
468 updated += 1;
469 } else {
470 wstore.agent_def_insert(&mut agent)?;
471 created += 1;
472 }
473 }
474
475 for agent in &existing {
477 if agent.is_seeded == 1 && !manifest_ids.contains(agent.id.as_str()) {
478 wstore.agent_def_delete(&agent.id)?;
479 removed += 1;
480 tracing::info!("agent seed: removed seeded agent '{}'", agent.id);
481 }
482 }
483
484 Ok(Some(ReseedReport { created, updated, removed }))
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::backend::storage::store::{AgentDefinition, Store};
491
492 fn manifest_with(ids_and_descriptions: &[(&str, &str)]) -> SeedManifest {
496 SeedManifest {
497 version: 999,
498 agents: ids_and_descriptions
499 .iter()
500 .map(|(id, desc)| SeedAgent {
501 id: id.to_string(),
502 name: id.to_string(),
503 icon: default_icon(),
504 provider: default_provider(),
505 agent_type: default_agent_type(),
506 environment: default_environment(),
507 description: desc.to_string(),
508 working_directory: String::new(),
509 shell: String::new(),
510 agent_bus_id: String::new(),
511 container_image: String::new(),
512 auto_start: false,
513 restart_on_crash: false,
514 content: SeedContent::default(),
515 skills: Vec::new(),
516 })
517 .collect(),
518 memories: Vec::new(),
519 }
520 }
521
522 fn insert_tpl(wstore: &Arc<Store>, id: &str, name: &str, hidden: i64) {
523 let mut def = AgentDefinition {
524 id: id.to_string(),
525 slug: id.to_string(),
526 name: name.to_string(),
527 icon: "✦".to_string(),
528 provider: "claude".to_string(),
529 description: "v1 desc".to_string(),
530 working_directory: String::new(),
531 shell: String::new(),
532 provider_flags: String::new(),
533 auto_start: 0,
534 restart_on_crash: 0,
535 idle_timeout_minutes: 0,
536 created_at: 1_700_000_000_000,
537 agent_type: "host".to_string(),
538 environment: String::new(),
539 agent_bus_id: String::new(),
540 is_seeded: 1,
541 accounts: String::new(),
542 parent_id: String::new(),
543 branch_label: String::new(),
544 updated_at: 1_700_000_000_000,
545 user_hidden: hidden,
546 container_image: String::new(),
547 container_volumes: "[]".to_string(),
548 container_name: String::new(),
549 };
550 wstore.agent_def_insert(&mut def).unwrap();
551 }
552
553 #[test]
554 fn reseed_preserves_user_hidden_on_existing_templates() {
555 let wstore = Arc::new(Store::open_in_memory().unwrap());
559 insert_tpl(&wstore, "tpl-claude", "Claude", 1);
560 let manifest = manifest_with(&[("tpl-claude", "v2 desc")]);
563
564 let report = reseed_if_needed(&wstore, &manifest)
565 .expect("reseed succeeds")
566 .expect("reseed runs because description changed");
567 assert_eq!(report.created, 0);
568 assert_eq!(report.updated, 1);
569
570 let after = wstore.agent_def_list().unwrap();
571 let tpl = after.iter().find(|a| a.id == "tpl-claude").unwrap();
572 assert_eq!(tpl.user_hidden, 1, "hide preference must survive reseed");
573 assert_eq!(tpl.description, "v2 desc", "description must update");
574 }
575
576 #[test]
577 fn reseed_resets_user_hidden_on_newly_added_template_id() {
578 let wstore = Arc::new(Store::open_in_memory().unwrap());
583 insert_tpl(&wstore, "tpl-claude", "Claude", 1);
584 let manifest = manifest_with(&[
585 ("tpl-claude", "v1 desc"), ("tpl-codex", "Codex CLI"), ]);
588
589 let report = reseed_if_needed(&wstore, &manifest)
590 .expect("reseed succeeds")
591 .expect("reseed runs because tpl-codex is new");
592 assert!(report.created >= 1, "tpl-codex should be inserted");
593
594 let after = wstore.agent_def_list().unwrap();
595 let codex = after
596 .iter()
597 .find(|a| a.id == "tpl-codex")
598 .expect("tpl-codex should now exist");
599 assert_eq!(
600 codex.user_hidden, 0,
601 "newly-added template must start visible (Phase 2 spec invariant)",
602 );
603 let claude = after.iter().find(|a| a.id == "tpl-claude").unwrap();
605 assert_eq!(claude.user_hidden, 1);
606 }
607}