1use std::collections::HashMap;
13use std::path::Path;
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::backend::obj::Block;
18use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
19use crate::backend::storage::store::Store;
20
21pub const SNAPSHOT_FILE: &str = "output.state.json";
27pub const OUTPUT_FILE: &str = "output";
29
30pub const MIGRATION_MARKER_V1: &str = "migration_agent_zones_v1.flag";
32
33pub fn is_valid_definition_id(s: &str) -> bool {
44 if s.is_empty() {
45 return false;
46 }
47 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
48}
49
50pub fn agent_current_zone(definition_id: &str) -> String {
53 debug_assert!(
54 is_valid_definition_id(definition_id),
55 "agent_current_zone: invalid definition_id"
56 );
57 format!("agent:{}:current", definition_id)
58}
59
60pub fn agent_archive_zone(definition_id: &str, ts_ms: u64) -> String {
62 debug_assert!(
63 is_valid_definition_id(definition_id),
64 "agent_archive_zone: invalid definition_id"
65 );
66 format!("agent:{}:archive:{}", definition_id, ts_ms)
67}
68
69pub fn validate_and_current(definition_id: &str) -> Result<String, String> {
73 if !is_valid_definition_id(definition_id) {
74 return Err(format!(
75 "INVALID_DEFINITION_ID: must match [A-Za-z0-9_-]+, got {:?}",
76 definition_id
77 ));
78 }
79 Ok(agent_current_zone(definition_id))
80}
81
82static GLOBAL_TRANSCRIPT_STORE: std::sync::OnceLock<Arc<FileStore>> = std::sync::OnceLock::new();
99
100pub fn set_global_transcript_store(store: Arc<FileStore>) {
103 let _ = GLOBAL_TRANSCRIPT_STORE.set(store);
104}
105
106pub fn global_transcript_store() -> Option<&'static Arc<FileStore>> {
108 GLOBAL_TRANSCRIPT_STORE.get()
109}
110
111pub fn agent_zone_for_block_meta(meta: &crate::backend::obj::MetaMapType) -> Option<String> {
119 let def_id = crate::backend::obj::meta_get_string(meta, "agentId", "");
120 if is_valid_definition_id(&def_id) {
121 Some(agent_current_zone(&def_id))
122 } else {
123 None
124 }
125}
126
127fn now_ms() -> u64 {
128 SystemTime::now()
129 .duration_since(UNIX_EPOCH)
130 .unwrap_or_default()
131 .as_millis() as u64
132}
133
134pub fn write_session_state(
148 filestore: &FileStore,
149 definition_id: &str,
150 content: &[u8],
151) -> Result<(), String> {
152 let zone = validate_and_current(definition_id)?;
153 write_zone_file(filestore, &zone, SNAPSHOT_FILE, content)?;
154 if let Some(gfs) = global_transcript_store() {
155 let global_content = normalize_snapshot_for_global(content);
166 if let Err(e) = write_zone_file(gfs, &zone, SNAPSHOT_FILE, &global_content) {
167 tracing::warn!(zone = %zone, error = %e, "global transcripts: snapshot mirror failed");
168 }
169 }
170 Ok(())
171}
172
173pub fn normalize_snapshot_for_global(content: &[u8]) -> Vec<u8> {
182 let Ok(mut v) = serde_json::from_slice::<serde_json::Value>(content) else {
183 return content.to_vec();
184 };
185 if let Some(obj) = v.as_object_mut() {
186 if obj.contains_key("sourceBlockId") {
187 obj.insert(
188 "sourceBlockId".to_string(),
189 serde_json::Value::String(String::new()),
190 );
191 }
192 }
193 let result = serde_json::to_vec(&v).unwrap_or_else(|_| content.to_vec());
194 debug_assert!(
197 serde_json::from_slice::<serde_json::Value>(&result)
198 .ok()
199 .and_then(|v| v.get("sourceBlockId").and_then(|s| s.as_str()).map(|s| s.is_empty()))
200 .unwrap_or(true),
201 "normalize_snapshot_for_global: G1 violated — sourceBlockId was not stripped"
202 );
203 result
204}
205
206pub fn heal_global_snapshot_source_block_ids(gfs: &FileStore, def_ids: &[String]) -> usize {
212 let mut healed = 0;
213 for def_id in def_ids {
214 if !is_valid_definition_id(def_id) {
215 continue;
216 }
217 let zone = agent_current_zone(def_id);
218 let bytes = match gfs.read_file(&zone, SNAPSHOT_FILE) {
219 Ok(Some(b)) => b,
220 _ => continue, };
222 let poisoned = serde_json::from_slice::<serde_json::Value>(&bytes)
224 .ok()
225 .and_then(|v| {
226 v.get("sourceBlockId")
227 .and_then(|s| s.as_str())
228 .map(|s| !s.is_empty())
229 })
230 .unwrap_or(false);
231 if !poisoned {
232 continue;
233 }
234 let fixed = normalize_snapshot_for_global(&bytes);
235 if write_zone_file(gfs, &zone, SNAPSHOT_FILE, &fixed).is_ok() {
236 healed += 1;
237 tracing::info!(zone = %zone, "global transcripts: healed poisoned snapshot sourceBlockId");
238 }
239 }
240 healed
241}
242
243pub fn append_session_output(
246 filestore: &FileStore,
247 definition_id: &str,
248 line: &str,
249) -> Result<u64, String> {
250 let zone = validate_and_current(definition_id)?;
251 let mut buf = line.as_bytes().to_vec();
253 if !buf.ends_with(b"\n") {
254 buf.push(b'\n');
255 }
256 ensure_file(filestore, &zone, OUTPUT_FILE)?;
257 filestore
258 .append_data(&zone, OUTPUT_FILE, &buf)
259 .map_err(|e| format!("append_data: {e}"))?;
260 Ok(buf.len() as u64)
261}
262
263pub fn read_session_state(
273 filestore: &FileStore,
274 definition_id: &str,
275) -> Result<(Option<String>, Option<i64>), String> {
276 let zone = validate_and_current(definition_id)?;
277 if let Some(gfs) = global_transcript_store() {
285 if let Some(found) = read_snapshot_from(gfs, &zone)? {
286 return Ok((Some(found.0), Some(found.1)));
287 }
288 }
289 if let Some(found) = read_snapshot_from(filestore, &zone)? {
290 return Ok((Some(found.0), Some(found.1)));
291 }
292 Ok((None, None))
293}
294
295fn read_snapshot_from(store: &FileStore, zone: &str) -> Result<Option<(String, i64)>, String> {
297 let stat = store
298 .stat(zone, SNAPSHOT_FILE)
299 .map_err(|e| format!("stat: {e}"))?;
300 let Some(file) = stat else {
301 return Ok(None);
302 };
303 let bytes = store
304 .read_file(zone, SNAPSHOT_FILE)
305 .map_err(|e| format!("read_file: {e}"))?
306 .unwrap_or_default();
307 Ok(Some((String::from_utf8_lossy(&bytes).into_owned(), file.modts)))
308}
309
310pub fn archive_session(
329 filestore: &FileStore,
330 definition_id: &str,
331) -> Result<Option<(String, i64)>, String> {
332 let current_zone = validate_and_current(definition_id)?;
333
334 if let Some(archived) = archive_global_current(filestore, definition_id)? {
347 clear_local_current_zone(filestore, ¤t_zone);
348 clear_global_current_zone(definition_id);
349 return Ok(Some(archived));
350 }
351
352 let state_stat = filestore
354 .stat(¤t_zone, SNAPSHOT_FILE)
355 .map_err(|e| format!("stat current: {e}"))?;
356 let has_state = match &state_stat {
357 Some(f) => f.size > 0,
358 None => false,
359 };
360 let output_stat = filestore
361 .stat(¤t_zone, OUTPUT_FILE)
362 .map_err(|e| format!("stat current output: {e}"))?;
363 let has_output = match &output_stat {
364 Some(f) => f.size > 0,
365 None => false,
366 };
367 if !has_state && !has_output {
368 return Ok(None);
369 }
370
371 let ts = now_ms();
372 let archive_zone = agent_archive_zone(definition_id, ts);
373
374 if has_state {
376 let snapshot_bytes = filestore
377 .read_file(¤t_zone, SNAPSHOT_FILE)
378 .map_err(|e| format!("read current snapshot: {e}"))?
379 .unwrap_or_default();
380 write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, &snapshot_bytes)?;
381 }
382 if has_output {
384 let output_bytes = filestore
385 .read_file(¤t_zone, OUTPUT_FILE)
386 .map_err(|e| format!("read current output: {e}"))?
387 .unwrap_or_default();
388 if !output_bytes.is_empty() {
389 write_zone_file(filestore, &archive_zone, OUTPUT_FILE, &output_bytes)?;
390 }
391 }
392
393 if state_stat.is_some() {
395 if let Err(e) = filestore.delete_file(¤t_zone, SNAPSHOT_FILE) {
396 tracing::warn!(
397 definition_id = %definition_id,
398 error = %e,
399 "agent_session: failed to clear current snapshot after archive (archive already persisted)"
400 );
401 }
402 }
403 if output_stat.is_some() {
404 if let Err(e) = filestore.delete_file(¤t_zone, OUTPUT_FILE) {
405 tracing::warn!(
406 definition_id = %definition_id,
407 error = %e,
408 "agent_session: failed to clear current output after archive (archive already persisted)"
409 );
410 }
411 }
412
413 clear_global_current_zone(definition_id);
421
422 tracing::info!(
423 definition_id = %definition_id,
424 archive_zoneid = %archive_zone,
425 archived_at_ms = ts,
426 "agent_session: archived current session"
427 );
428
429 Ok(Some((archive_zone, ts as i64)))
430}
431
432fn archive_global_current(
441 filestore: &FileStore,
442 definition_id: &str,
443) -> Result<Option<(String, i64)>, String> {
444 let Some(gfs) = global_transcript_store() else {
445 return Ok(None);
446 };
447 let current_zone = validate_and_current(definition_id)?;
448
449 let snap = read_snapshot_bytes(gfs, ¤t_zone, SNAPSHOT_FILE)?;
450 let out = read_snapshot_bytes(gfs, ¤t_zone, OUTPUT_FILE)?;
451 let has_snap = snap.as_ref().is_some_and(|b| !b.is_empty());
452 let has_out = out.as_ref().is_some_and(|b| !b.is_empty());
453 if !has_snap && !has_out {
454 return Ok(None);
455 }
456
457 let ts = now_ms();
458 let archive_zone = agent_archive_zone(definition_id, ts);
459 if has_snap {
460 write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, snap.as_ref().unwrap())?;
461 }
462 if has_out {
463 write_zone_file(filestore, &archive_zone, OUTPUT_FILE, out.as_ref().unwrap())?;
464 }
465 tracing::info!(
466 definition_id = %definition_id,
467 archive_zoneid = %archive_zone,
468 archived_at_ms = ts,
469 "agent_session: archived cross-channel (global) session into local archive"
470 );
471 Ok(Some((archive_zone, ts as i64)))
472}
473
474fn read_snapshot_bytes(store: &FileStore, zone: &str, name: &str) -> Result<Option<Vec<u8>>, String> {
476 match store.stat(zone, name).map_err(|e| format!("stat: {e}"))? {
477 Some(_) => store
478 .read_file(zone, name)
479 .map_err(|e| format!("read_file: {e}")),
480 None => Ok(None),
481 }
482}
483
484fn clear_local_current_zone(filestore: &FileStore, zone: &str) {
489 for name in [SNAPSHOT_FILE, OUTPUT_FILE] {
490 match filestore.stat(zone, name) {
491 Ok(Some(_)) => {
492 if let Err(e) = filestore.delete_file(zone, name) {
493 tracing::warn!(zone = %zone, file = %name, error = %e, "agent_session: failed to clear local current after global archive");
494 }
495 }
496 Ok(None) => {}
497 Err(e) => tracing::warn!(zone = %zone, file = %name, error = %e, "agent_session: stat failed clearing local current"),
498 }
499 }
500}
501
502fn clear_global_current_zone(definition_id: &str) {
508 let Some(gfs) = global_transcript_store() else {
509 return;
510 };
511 let Ok(zone) = validate_and_current(definition_id) else {
512 return;
513 };
514 for name in [SNAPSHOT_FILE, OUTPUT_FILE] {
515 match gfs.stat(&zone, name) {
517 Ok(Some(_)) => {
518 if let Err(e) = gfs.delete_file(&zone, name) {
519 tracing::warn!(
520 zone = %zone, file = %name, error = %e,
521 "global transcripts: failed to clear current zone on archive"
522 );
523 }
524 }
525 Ok(None) => {}
526 Err(e) => tracing::warn!(
527 zone = %zone, file = %name, error = %e,
528 "global transcripts: stat failed clearing current zone on archive"
529 ),
530 }
531 }
532}
533
534pub fn list_archives(
540 filestore: &FileStore,
541 definition_id: &str,
542 limit: usize,
543) -> Result<Vec<ArchiveSummary>, String> {
544 if !is_valid_definition_id(definition_id) {
545 return Err(format!(
546 "INVALID_DEFINITION_ID: must match [A-Za-z0-9_-]+, got {:?}",
547 definition_id
548 ));
549 }
550 let prefix = format!("agent:{}:archive:", definition_id);
551 let limit = if limit == 0 { 20 } else { limit.min(100) };
552
553 let all_zones = filestore
554 .get_all_zone_ids()
555 .map_err(|e| format!("get_all_zone_ids: {e}"))?;
556
557 let mut matches: Vec<(u64, String)> = Vec::new();
558 for zone in all_zones {
559 if let Some(suffix) = zone.strip_prefix(&prefix) {
560 if let Ok(ts) = suffix.parse::<u64>() {
561 matches.push((ts, zone));
562 }
563 }
564 }
565 matches.sort_by(|a, b| b.0.cmp(&a.0));
567 matches.truncate(limit);
568
569 let mut rows = Vec::with_capacity(matches.len());
570 for (ts, zone) in matches {
571 let (preview, node_count) = read_archive_preview(filestore, &zone);
572 rows.push(ArchiveSummary {
573 archive_zoneid: zone,
574 archived_at_ms: ts as i64,
575 preview,
576 node_count,
577 });
578 }
579 Ok(rows)
580}
581
582#[derive(Debug, Clone)]
585pub struct ArchiveSummary {
586 pub archive_zoneid: String,
587 pub archived_at_ms: i64,
588 pub preview: String,
589 pub node_count: usize,
590}
591
592fn ensure_file(filestore: &FileStore, zone: &str, name: &str) -> Result<(), String> {
598 match filestore.stat(zone, name) {
599 Ok(Some(_)) => Ok(()),
600 Ok(None) => filestore
601 .make_file(zone, name, FileMeta::default(), FileOpts::default())
602 .map_err(|e| format!("make_file: {e}")),
603 Err(e) => Err(format!("stat: {e}")),
604 }
605}
606
607fn write_zone_file(
610 filestore: &FileStore,
611 zone: &str,
612 name: &str,
613 content: &[u8],
614) -> Result<(), String> {
615 use crate::backend::storage::StoreError;
616 match filestore.write_file(zone, name, content) {
617 Ok(()) => Ok(()),
618 Err(StoreError::NotFound) => {
619 filestore
620 .make_file(zone, name, FileMeta::default(), FileOpts::default())
621 .map_err(|e| format!("make_file: {e}"))?;
622 filestore
623 .write_file(zone, name, content)
624 .map_err(|e| format!("write_file: {e}"))
625 }
626 Err(e) => Err(format!("write_file: {e}")),
627 }
628}
629
630fn read_archive_preview(filestore: &FileStore, zone: &str) -> (String, usize) {
637 let bytes = match filestore.read_file(zone, SNAPSHOT_FILE) {
638 Ok(Some(b)) => b,
639 _ => return (String::new(), 0),
640 };
641 if bytes.len() > 4 * 1024 * 1024 {
642 return (String::new(), 0);
643 }
644 let json: serde_json::Value = match serde_json::from_slice(&bytes) {
645 Ok(v) => v,
646 Err(_) => return (String::new(), 0),
647 };
648 let nodes = match json.get("nodes").and_then(|v| v.as_array()) {
649 Some(a) => a,
650 None => return (String::new(), 0),
651 };
652 let node_count = nodes.len();
653 let mut preview = String::new();
654 for node in nodes {
655 let ty = node.get("type").and_then(|v| v.as_str()).unwrap_or("");
656 if ty != "user_message" {
657 continue;
658 }
659 let msg = node
660 .get("message")
661 .and_then(|v| v.as_str())
662 .unwrap_or("")
663 .trim();
664 if msg.is_empty() {
665 continue;
666 }
667 if preview.is_empty() && msg.starts_with("# Session Context") {
668 preview = collapse_preview(msg);
669 continue;
670 }
671 preview = collapse_preview(msg);
672 break;
673 }
674 (preview, node_count)
675}
676
677fn collapse_preview(s: &str) -> String {
678 const MAX_CHARS: usize = 240;
679 let mut buf = String::with_capacity(s.len().min(MAX_CHARS + 4));
680 let mut prev_space = false;
681 for ch in s.chars() {
682 if buf.chars().count() >= MAX_CHARS {
683 buf.push('\u{2026}');
684 return buf;
685 }
686 if ch.is_whitespace() {
687 if !prev_space && !buf.is_empty() {
688 buf.push(' ');
689 prev_space = true;
690 }
691 } else {
692 buf.push(ch);
693 prev_space = false;
694 }
695 }
696 buf
697}
698
699#[derive(Debug, Clone, Default)]
705pub struct MigrationStats {
706 pub blocks_scanned: usize,
707 pub archives_written: usize,
708 pub current_zones_seeded: usize,
709 pub skipped_no_snapshot: usize,
710 pub failures: usize,
711}
712
713pub fn migrate_block_zones_v1(
722 wstore: &Arc<Store>,
723 filestore: &Arc<FileStore>,
724 data_dir: &Path,
725) -> MigrationStats {
726 let marker_path = data_dir.join(MIGRATION_MARKER_V1);
727 if marker_path.exists() {
728 tracing::debug!(
729 marker = %marker_path.display(),
730 "agent_session migration: marker present, skipping"
731 );
732 return MigrationStats::default();
733 }
734
735 let mut stats = MigrationStats::default();
736
737 let blocks: Vec<Block> = match wstore.get_all::<Block>() {
738 Ok(v) => v,
739 Err(e) => {
740 tracing::warn!(
741 error = %e,
742 "agent_session migration: wstore.get_all<Block> failed; skipping migration"
743 );
744 return stats;
746 }
747 };
748
749 let mut per_def_latest: HashMap<String, (i64, Vec<u8>)> = HashMap::new();
752
753 for block in &blocks {
754 let view = block.meta.get("view").and_then(|v| v.as_str()).unwrap_or("");
755 if view != "agent" {
756 continue;
757 }
758 let def_id = block
762 .meta
763 .get("agentId")
764 .and_then(|v| v.as_str())
765 .or_else(|| block.meta.get("agent:id").and_then(|v| v.as_str()))
766 .unwrap_or("");
767 if !is_valid_definition_id(def_id) {
768 continue;
769 }
770 stats.blocks_scanned += 1;
771
772 let snapshot_stat = match filestore.stat(&block.oid, SNAPSHOT_FILE) {
775 Ok(Some(f)) => f,
776 Ok(None) => {
777 stats.skipped_no_snapshot += 1;
778 continue;
779 }
780 Err(e) => {
781 tracing::warn!(
782 block_id = %block.oid,
783 error = %e,
784 "agent_session migration: stat failed; skipping"
785 );
786 stats.failures += 1;
787 continue;
788 }
789 };
790 if snapshot_stat.size == 0 {
791 stats.skipped_no_snapshot += 1;
792 continue;
793 }
794
795 let snapshot_bytes = match filestore.read_file(&block.oid, SNAPSHOT_FILE) {
796 Ok(Some(b)) => b,
797 _ => {
798 stats.failures += 1;
799 continue;
800 }
801 };
802
803 let mut archive_ts: u64 = if snapshot_stat.createdts > 0 {
808 snapshot_stat.createdts as u64
809 } else if snapshot_stat.modts > 0 {
810 snapshot_stat.modts as u64
811 } else {
812 now_ms()
813 };
814 loop {
818 let candidate = agent_archive_zone(def_id, archive_ts);
819 let occupied = matches!(
820 filestore.stat(&candidate, SNAPSHOT_FILE),
821 Ok(Some(_))
822 );
823 if !occupied {
824 break;
825 }
826 archive_ts += 1;
827 }
828 let archive_zone = agent_archive_zone(def_id, archive_ts);
829 if let Err(e) = write_zone_file(filestore, &archive_zone, SNAPSHOT_FILE, &snapshot_bytes) {
830 tracing::warn!(
831 block_id = %block.oid,
832 definition_id = %def_id,
833 error = %e,
834 "agent_session migration: archive write failed"
835 );
836 stats.failures += 1;
837 continue;
838 }
839 stats.archives_written += 1;
840
841 let entry = per_def_latest
844 .entry(def_id.to_string())
845 .or_insert_with(|| (0, Vec::new()));
846 if snapshot_stat.modts > entry.0 {
847 *entry = (snapshot_stat.modts, snapshot_bytes);
848 }
849 }
850
851 for (def_id, (_modts, bytes)) in per_def_latest {
856 let current_zone = agent_current_zone(&def_id);
857 let already = matches!(
858 filestore.stat(¤t_zone, SNAPSHOT_FILE),
859 Ok(Some(f)) if f.size > 0
860 );
861 if already {
862 continue;
863 }
864 match write_zone_file(filestore, ¤t_zone, SNAPSHOT_FILE, &bytes) {
865 Ok(()) => {
866 stats.current_zones_seeded += 1;
867 }
868 Err(e) => {
869 tracing::warn!(
870 definition_id = %def_id,
871 error = %e,
872 "agent_session migration: current-zone seed failed"
873 );
874 stats.failures += 1;
875 }
876 }
877 }
878
879 if let Err(e) = std::fs::write(&marker_path, b"v1\n") {
881 tracing::warn!(
882 marker = %marker_path.display(),
883 error = %e,
884 "agent_session migration: marker write failed; migration may re-run on next startup"
885 );
886 }
887
888 tracing::info!(
889 blocks_scanned = stats.blocks_scanned,
890 archives_written = stats.archives_written,
891 current_zones_seeded = stats.current_zones_seeded,
892 skipped_no_snapshot = stats.skipped_no_snapshot,
893 failures = stats.failures,
894 "agent_session migration: complete"
895 );
896
897 stats
898}
899
900pub const TEMPLATE_PROMOTE_MARKER_V1: &str = "migration_template_promote_v1.flag";
916
917#[derive(Debug, Clone, Default)]
919pub struct TemplatePromoteStats {
920 pub templates_scanned: usize,
921 pub templates_promoted: usize,
922 pub archives_moved: usize,
924 pub instances_repointed: usize,
927 pub failures: usize,
928}
929
930pub fn migrate_promote_template_sessions_v1(
983 wstore: &Arc<Store>,
984 filestore: &Arc<FileStore>,
985 _data_dir: &Path,
986) -> TemplatePromoteStats {
987
988 let mut stats = TemplatePromoteStats::default();
989
990 let all_zones = match filestore.get_all_zone_ids() {
991 Ok(v) => v,
992 Err(e) => {
993 tracing::warn!(
994 error = %e,
995 "template_promote migration: get_all_zone_ids failed; aborting (will retry next start)"
996 );
997 return stats;
998 }
999 };
1000
1001 let mut per_def_zones: HashMap<String, Vec<String>> = HashMap::new();
1006 for zone in &all_zones {
1007 let rest = match zone.strip_prefix("agent:") {
1008 Some(r) => r,
1009 None => continue,
1010 };
1011 let (def_id, tail) = match rest.split_once(':') {
1013 Some(p) => p,
1014 None => continue,
1015 };
1016 if !is_valid_definition_id(def_id) {
1017 continue;
1018 }
1019 let is_current = tail == "current";
1020 let is_archive = tail.starts_with("archive:");
1021 if !is_current && !is_archive {
1022 continue;
1023 }
1024 per_def_zones
1025 .entry(def_id.to_string())
1026 .or_default()
1027 .push(zone.clone());
1028 }
1029
1030 let defs = match wstore.agent_def_list() {
1033 Ok(v) => v,
1034 Err(e) => {
1035 tracing::warn!(
1036 error = %e,
1037 "template_promote migration: agent_def_list failed; aborting (will retry next start)"
1038 );
1039 return stats;
1040 }
1041 };
1042
1043 for (old_def_id, zones) in per_def_zones {
1044 let template = match defs.iter().find(|d| d.id == old_def_id) {
1046 Some(d) => d,
1047 None => {
1048 continue;
1051 }
1052 };
1053 if template.is_seeded != 1 {
1056 continue;
1057 }
1058 stats.templates_scanned += 1;
1059
1060 let new_name = match wstore.instance_list_named(
1071 1,
1072 Some(&old_def_id),
1073 None,
1074 true,
1075 ) {
1076 Ok(rows) => rows
1077 .into_iter()
1078 .next()
1079 .map(|i| i.instance_name)
1080 .filter(|n| !n.is_empty())
1081 .unwrap_or_else(|| template.name.clone()),
1082 Err(e) => {
1083 tracing::warn!(
1084 template_id = %old_def_id,
1085 error = %e,
1086 "template_promote migration: instance_list_named failed; using template name"
1087 );
1088 template.name.clone()
1089 }
1090 };
1091
1092 let promote_target_id =
1116 format!("template-promote-v1-{}", template.id);
1117 debug_assert!(
1118 is_valid_definition_id(&promote_target_id),
1119 "deterministic promote-target id must satisfy the zone-id charset"
1120 );
1121
1122 let existing_target = match wstore.agent_def_get(&promote_target_id) {
1123 Ok(Some(def)) => Some(def),
1124 Ok(None) => None,
1125 Err(e) => {
1126 tracing::warn!(
1127 template_id = %old_def_id,
1128 promote_target_id = %promote_target_id,
1129 error = %e,
1130 "template_promote migration: agent_def_get failed; aborting this template"
1131 );
1132 stats.failures += 1;
1133 continue;
1134 }
1135 };
1136 let new_def = if let Some(existing) = existing_target {
1137 tracing::info!(
1138 template_id = %old_def_id,
1139 promote_target_id = %promote_target_id,
1140 "template_promote migration: reusing prior promote-target clone (idempotent retry)"
1141 );
1142 existing
1143 } else {
1144 let now = now_ms() as i64;
1148 let mut new_def = crate::backend::storage::store::AgentDefinition {
1149 id: promote_target_id.clone(),
1150 slug: String::new(),
1151 name: new_name.clone(),
1152 icon: template.icon.clone(),
1153 provider: template.provider.clone(),
1154 description: template.description.clone(),
1155 working_directory: String::new(),
1156 shell: template.shell.clone(),
1157 provider_flags: template.provider_flags.clone(),
1158 auto_start: 0,
1159 restart_on_crash: template.restart_on_crash,
1160 idle_timeout_minutes: template.idle_timeout_minutes,
1161 created_at: now,
1162 agent_type: template.agent_type.clone(),
1163 environment: template.environment.clone(),
1164 agent_bus_id: String::new(),
1165 is_seeded: 0,
1166 accounts: String::new(),
1167 parent_id: template.id.clone(),
1168 branch_label: String::new(),
1169 updated_at: now,
1170 user_hidden: 0,
1171 container_image: template.container_image.clone(),
1172 container_volumes: template.container_volumes.clone(),
1173 container_name: String::new(),
1174 };
1175 if let Err(e) = wstore.agent_def_insert(&mut new_def) {
1176 tracing::warn!(
1177 template_id = %old_def_id,
1178 promote_target_id = %promote_target_id,
1179 error = %e,
1180 "template_promote migration: agent_def_insert failed; skipping this template"
1181 );
1182 stats.failures += 1;
1183 continue;
1184 }
1185 new_def
1186 };
1187
1188 let mut archives_for_this_def: usize = 0;
1192 for old_zone in &zones {
1193 let suffix = match old_zone.strip_prefix(&format!("agent:{}:", old_def_id)) {
1197 Some(s) => s,
1198 None => continue,
1199 };
1200 let new_zone = format!("agent:{}:{}", new_def.id, suffix);
1201 let is_archive = suffix.starts_with("archive:");
1202
1203 if let Err(e) = move_zone(filestore, old_zone, &new_zone) {
1204 tracing::warn!(
1205 template_id = %old_def_id,
1206 old_zone = %old_zone,
1207 new_zone = %new_zone,
1208 error = %e,
1209 "template_promote migration: move_zone failed"
1210 );
1211 stats.failures += 1;
1212 continue;
1213 }
1214 if is_archive {
1215 archives_for_this_def += 1;
1216 }
1217 }
1218
1219 let repointed = match wstore.instance_repoint_definition(&old_def_id, &new_def.id) {
1224 Ok(n) => n,
1225 Err(e) => {
1226 tracing::warn!(
1227 template_id = %old_def_id,
1228 new_definition_id = %new_def.id,
1229 error = %e,
1230 "template_promote migration: instance_repoint_definition failed"
1231 );
1232 stats.failures += 1;
1233 0
1234 }
1235 };
1236 stats.instances_repointed += repointed;
1237 stats.archives_moved += archives_for_this_def;
1238 stats.templates_promoted += 1;
1239 tracing::info!(
1240 template_id = %old_def_id,
1241 template_name = %template.name,
1242 new_definition_id = %new_def.id,
1243 new_name = %new_def.name,
1244 archives_moved = archives_for_this_def,
1245 instances_repointed = repointed,
1246 "template_promote migration: promoted template into user agent"
1247 );
1248 }
1249
1250 tracing::info!(
1256 templates_scanned = stats.templates_scanned,
1257 templates_promoted = stats.templates_promoted,
1258 archives_moved = stats.archives_moved,
1259 instances_repointed = stats.instances_repointed,
1260 failures = stats.failures,
1261 "template_promote migration: complete"
1262 );
1263
1264 stats
1265}
1266
1267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270enum CopyAction {
1271 Copy,
1273 Overwrite,
1275 Preserve,
1278 TieBreakByBytes,
1280 Conflict,
1283}
1284
1285fn move_zone(
1290 filestore: &FileStore,
1291 old_zone: &str,
1292 new_zone: &str,
1293) -> Result<(), String> {
1294 let files = filestore
1295 .list_files(old_zone)
1296 .map_err(|e| format!("list_files: {e}"))?;
1297 if files.is_empty() {
1298 return Ok(());
1299 }
1300 let dest_meta: std::collections::HashMap<String, crate::backend::storage::filestore::WaveFile> = filestore
1334 .list_files(new_zone)
1335 .map_err(|e| format!("list_files (new): {e}"))?
1336 .into_iter()
1337 .map(|f| (f.name.clone(), f))
1338 .collect();
1339 let mut copied = 0usize;
1340 let mut overwritten = 0usize;
1341 let mut preserved = 0usize;
1342 let mut conflicts = 0usize;
1343 for f in &files {
1344 let dest = dest_meta.get(&f.name);
1345 let action = match dest {
1346 None => CopyAction::Copy, Some(d) if f.modts > d.modts => CopyAction::Overwrite, Some(d) if d.modts > f.modts => CopyAction::Preserve, Some(_) => CopyAction::TieBreakByBytes, };
1351 let resolved = match action {
1352 CopyAction::Copy | CopyAction::Overwrite => action,
1353 CopyAction::Preserve => action,
1354 CopyAction::Conflict => action, CopyAction::TieBreakByBytes => {
1356 let src_bytes = filestore
1361 .read_file(old_zone, &f.name)
1362 .map_err(|e| format!("read_file {}: {e}", f.name))?
1363 .unwrap_or_default();
1364 let dest_bytes = filestore
1365 .read_file(new_zone, &f.name)
1366 .map_err(|e| format!("read_file (dest) {}: {e}", f.name))?
1367 .unwrap_or_default();
1368 if src_bytes == dest_bytes {
1369 CopyAction::Preserve
1370 } else {
1371 CopyAction::Conflict
1381 }
1382 }
1383 };
1384 match resolved {
1385 CopyAction::Copy => {
1386 let bytes = filestore
1387 .read_file(old_zone, &f.name)
1388 .map_err(|e| format!("read_file {}: {e}", f.name))?
1389 .unwrap_or_default();
1390 write_zone_file(filestore, new_zone, &f.name, &bytes)?;
1391 copied += 1;
1392 }
1393 CopyAction::Overwrite => {
1394 let bytes = filestore
1395 .read_file(old_zone, &f.name)
1396 .map_err(|e| format!("read_file {}: {e}", f.name))?
1397 .unwrap_or_default();
1398 write_zone_file(filestore, new_zone, &f.name, &bytes)?;
1399 overwritten += 1;
1400 }
1401 CopyAction::Preserve => {
1402 preserved += 1;
1403 }
1404 CopyAction::Conflict => {
1405 conflicts += 1;
1406 tracing::warn!(
1407 old_zone = %old_zone,
1408 new_zone = %new_zone,
1409 file = %f.name,
1410 modts = f.modts,
1411 "template_promote migration: same-ms conflict — bytes differ at equal modts; preserving destination + leaving source for manual recovery"
1412 );
1413 }
1414 CopyAction::TieBreakByBytes => unreachable!("resolved above"),
1415 }
1416 }
1417 if preserved > 0 || overwritten > 0 || conflicts > 0 {
1418 tracing::info!(
1419 old_zone = %old_zone,
1420 new_zone = %new_zone,
1421 copied,
1422 overwritten,
1423 preserved,
1424 conflicts,
1425 "template_promote migration: per-file move (R4 user-continuation, R5 partial-copy fill, R6 newer-source promotion, R7 same-ms conflict)"
1426 );
1427 }
1428 if conflicts > 0 {
1429 return Ok(());
1435 }
1436 let post_dest: std::collections::HashSet<String> = filestore
1443 .list_files(new_zone)
1444 .map_err(|e| format!("list_files (new, post): {e}"))?
1445 .into_iter()
1446 .map(|f| f.name)
1447 .collect();
1448 let missing: Vec<&str> = files
1449 .iter()
1450 .map(|f| f.name.as_str())
1451 .filter(|n| !post_dest.contains(*n))
1452 .collect();
1453 if !missing.is_empty() {
1454 tracing::warn!(
1455 old_zone = %old_zone,
1456 new_zone = %new_zone,
1457 missing = ?missing,
1458 "template_promote migration: destination missing files post-copy; leaving source in place for retry"
1459 );
1460 return Ok(());
1461 }
1462 if let Err(e) = filestore.delete_zone(old_zone) {
1465 tracing::warn!(
1468 old_zone = %old_zone,
1469 error = %e,
1470 "template_promote migration: delete_zone failed after copy; source remains"
1471 );
1472 }
1473 Ok(())
1474}
1475
1476#[cfg(test)]
1481mod tests {
1482 use super::*;
1483 use crate::backend::obj::MetaMapType;
1484 use crate::backend::storage::filestore::FileStore;
1485 use crate::backend::storage::store::Store;
1486 use std::sync::Arc;
1487 use tempfile::tempdir;
1488
1489 fn fresh_filestore() -> Arc<FileStore> {
1490 Arc::new(FileStore::open_in_memory().unwrap())
1491 }
1492
1493 #[test]
1496 fn agent_zone_for_block_meta_resolves_from_agent_id() {
1497 let mut meta = MetaMapType::new();
1498 meta.insert("agentId".to_string(), serde_json::json!("def-abc123"));
1499 assert_eq!(
1500 agent_zone_for_block_meta(&meta).as_deref(),
1501 Some("agent:def-abc123:current"),
1502 );
1503 }
1504
1505 #[test]
1506 fn agent_zone_for_block_meta_none_when_missing_or_invalid() {
1507 assert_eq!(agent_zone_for_block_meta(&MetaMapType::new()), None);
1509 let mut empty = MetaMapType::new();
1511 empty.insert("agentId".to_string(), serde_json::json!(""));
1512 assert_eq!(agent_zone_for_block_meta(&empty), None);
1513 let mut bad = MetaMapType::new();
1515 bad.insert("agentId".to_string(), serde_json::json!("../etc"));
1516 assert_eq!(agent_zone_for_block_meta(&bad), None);
1517 }
1518
1519 #[test]
1525 fn global_store_read_fallback_and_archive_clear() {
1526 let per_channel = fresh_filestore();
1527 let global = fresh_filestore();
1528 set_global_transcript_store(global.clone());
1529
1530 let def_id = "def-global-fallback-xyz";
1531 let zone = agent_current_zone(def_id);
1532
1533 let seed_global = |snap: &[u8]| {
1534 global
1535 .make_file(&zone, SNAPSHOT_FILE, FileMeta::default(), FileOpts::default())
1536 .unwrap();
1537 global.write_file(&zone, SNAPSHOT_FILE, snap).unwrap();
1538 global
1539 .make_file(&zone, OUTPUT_FILE, FileMeta::default(), FileOpts::default())
1540 .unwrap();
1541 global.append_data(&zone, OUTPUT_FILE, b"{\"type\":\"user\"}\n").unwrap();
1542 };
1543
1544 let snap = br#"{"schemaVersion":2,"highWaterMark":3}"#;
1548 seed_global(snap);
1549
1550 let (content, modts) = read_session_state(&per_channel, def_id).unwrap();
1552 assert_eq!(content.as_deref(), Some(std::str::from_utf8(snap).unwrap()));
1553 assert!(modts.is_some());
1554
1555 let archived = archive_session(&per_channel, def_id).unwrap();
1558 assert!(archived.is_some(), "empty-local archive must preserve the global conversation");
1559 assert!(global.stat(&zone, SNAPSHOT_FILE).unwrap().is_none(), "global snapshot not cleared (empty-local path)");
1560 assert!(global.stat(&zone, OUTPUT_FILE).unwrap().is_none(), "global output not cleared (empty-local path)");
1561 assert!(!list_archives(&per_channel, def_id, 0).unwrap().is_empty(), "global content must be archived locally");
1563 let (after, _) = read_session_state(&per_channel, def_id).unwrap();
1565 assert_eq!(after, None, "archived conversation must not be resurrected from global zone");
1566
1567 seed_global(snap);
1570 write_zone_file(&per_channel, &zone, SNAPSHOT_FILE, b"{\"local\":true}").unwrap();
1571 let archived_b = archive_session(&per_channel, def_id).unwrap();
1572 assert!(archived_b.is_some(), "should have archived the local current");
1573 assert!(global.stat(&zone, SNAPSHOT_FILE).unwrap().is_none(), "global snapshot not cleared (local-present path)");
1574 assert!(global.stat(&zone, OUTPUT_FILE).unwrap().is_none(), "global output not cleared (local-present path)");
1575 let (after_b, _) = read_session_state(&per_channel, def_id).unwrap();
1576 assert_eq!(after_b, None, "no resurrection after local archive");
1577 }
1578
1579 #[test]
1580 fn zone_names_match_spec() {
1581 assert_eq!(
1582 agent_current_zone("def-abc"),
1583 "agent:def-abc:current"
1584 );
1585 assert_eq!(
1586 agent_archive_zone("def-abc", 1_700_000_000_000),
1587 "agent:def-abc:archive:1700000000000"
1588 );
1589 }
1590
1591 #[test]
1592 fn validate_definition_id_rejects_bad_input() {
1593 assert!(is_valid_definition_id("abc-123_DEF"));
1594 assert!(is_valid_definition_id("a"));
1595 assert!(!is_valid_definition_id(""));
1596 assert!(!is_valid_definition_id("../etc"));
1598 assert!(!is_valid_definition_id("a:b"));
1599 assert!(!is_valid_definition_id("a/b"));
1600 assert!(!is_valid_definition_id("a b"));
1601 assert!(!is_valid_definition_id("a\x00b"));
1602 assert!(!is_valid_definition_id("café"));
1604 }
1605
1606 #[test]
1607 fn validate_and_current_surfaces_error_prefix() {
1608 let err = validate_and_current("../etc").unwrap_err();
1609 assert!(err.starts_with("INVALID_DEFINITION_ID:"));
1610 }
1611
1612 #[test]
1613 fn read_returns_none_when_zone_missing() {
1614 let fs = fresh_filestore();
1615 let (content, modts) = read_session_state(&fs, "def-fresh").unwrap();
1617 assert!(content.is_none(), "missing zone should NOT be an error");
1618 assert!(modts.is_none());
1619 }
1620
1621 #[test]
1622 fn read_rejects_invalid_definition_id() {
1623 let fs = fresh_filestore();
1624 let err = read_session_state(&fs, "../bad").unwrap_err();
1625 assert!(err.starts_with("INVALID_DEFINITION_ID:"));
1626 }
1627
1628 #[test]
1629 #[ignore = "process-global read cache leaks across in-memory stores; fix isolation then un-ignore"]
1636 fn write_then_read_roundtrip() {
1637 let fs = fresh_filestore();
1638 let payload = r#"{"nodes":[{"type":"user_message","message":"hi"}]}"#;
1639 write_session_state(&fs, "def-a", payload.as_bytes()).unwrap();
1640 let (content, modts) = read_session_state(&fs, "def-a").unwrap();
1641 assert_eq!(content.as_deref(), Some(payload));
1642 assert!(modts.unwrap_or(0) > 0);
1643 }
1644
1645 #[test]
1646 fn write_is_idempotent_replaces_content() {
1647 let fs = fresh_filestore();
1648 write_session_state(&fs, "def-a", b"first").unwrap();
1649 write_session_state(&fs, "def-a", b"second").unwrap();
1650 let (content, _) = read_session_state(&fs, "def-a").unwrap();
1651 assert_eq!(content.as_deref(), Some("second"));
1652 }
1653
1654 #[test]
1655 fn append_output_grows_ndjson_file() {
1656 let fs = fresh_filestore();
1657 let n1 = append_session_output(&fs, "def-a", "line1").unwrap();
1658 let n2 = append_session_output(&fs, "def-a", "line2\n").unwrap();
1659 assert_eq!(n1, b"line1\n".len() as u64);
1661 assert_eq!(n2, b"line2\n".len() as u64);
1662 let zone = agent_current_zone("def-a");
1663 let bytes = fs.read_file(&zone, OUTPUT_FILE).unwrap().unwrap();
1664 assert_eq!(bytes, b"line1\nline2\n");
1665 }
1666
1667 #[test]
1668 fn archive_moves_content_and_clears_current() {
1669 let fs = fresh_filestore();
1670 let payload = br#"{"nodes":[{"type":"user_message","message":"x"}]}"#;
1671 write_session_state(&fs, "def-a", payload).unwrap();
1672 append_session_output(&fs, "def-a", "raw1").unwrap();
1673
1674 let result = archive_session(&fs, "def-a").unwrap();
1675 let (zone, ts) = result.expect("archive should have happened");
1676 assert!(zone.starts_with("agent:def-a:archive:"));
1677 assert!(ts > 0);
1678
1679 let archived = fs.read_file(&zone, SNAPSHOT_FILE).unwrap();
1681 assert_eq!(archived.as_deref(), Some(payload.as_slice()));
1682 let archived_output = fs.read_file(&zone, OUTPUT_FILE).unwrap().unwrap();
1684 assert_eq!(archived_output, b"raw1\n");
1685
1686 let current_zone = agent_current_zone("def-a");
1688 let still_there = fs.stat(¤t_zone, SNAPSHOT_FILE).unwrap();
1689 assert!(still_there.is_none(), ":current snapshot must be cleared");
1690 let still_output = fs.stat(¤t_zone, OUTPUT_FILE).unwrap();
1691 assert!(still_output.is_none(), ":current output must be cleared");
1692
1693 let (content, _) = read_session_state(&fs, "def-a").unwrap();
1695 assert!(content.is_none());
1696 }
1697
1698 #[test]
1699 fn archive_on_empty_current_is_noop() {
1700 let fs = fresh_filestore();
1701 let result = archive_session(&fs, "def-empty").unwrap();
1703 assert!(result.is_none(), "archive on empty :current should no-op");
1704 let zones = fs.get_all_zone_ids().unwrap();
1706 assert!(
1707 !zones.iter().any(|z| z.contains(":archive:")),
1708 "no archive zone should have been created"
1709 );
1710 }
1711
1712 #[test]
1713 fn archive_on_zero_byte_state_is_noop() {
1714 let fs = fresh_filestore();
1715 let zone = agent_current_zone("def-zero");
1717 fs.make_file(&zone, SNAPSHOT_FILE, FileMeta::default(), FileOpts::default())
1718 .unwrap();
1719 let result = archive_session(&fs, "def-zero").unwrap();
1720 assert!(result.is_none(), "zero-byte :current must NOT create archive");
1721 }
1722
1723 #[test]
1727 fn two_agents_have_independent_zones() {
1728 let fs = fresh_filestore();
1729 write_session_state(&fs, "def-A", br#"{"nodes":[{"type":"user_message","message":"A"}]}"#)
1730 .unwrap();
1731
1732 let (content_b, _) = read_session_state(&fs, "def-B").unwrap();
1734 assert!(content_b.is_none(), "AgentB must NOT see AgentA's data");
1735
1736 let (content_a, _) = read_session_state(&fs, "def-A").unwrap();
1738 assert!(content_a.unwrap().contains("\"A\""));
1739 }
1740
1741 #[test]
1742 fn list_archives_sorted_newest_first_with_previews() {
1743 let fs = fresh_filestore();
1744 let make = |ts: u64, label: &str| {
1746 let zone = agent_archive_zone("def-a", ts);
1747 let payload = serde_json::json!({
1748 "nodes": [
1749 {"type": "user_message", "message": label}
1750 ]
1751 });
1752 write_zone_file(&fs, &zone, SNAPSHOT_FILE, payload.to_string().as_bytes()).unwrap();
1753 };
1754 make(1_000, "old");
1755 make(3_000, "newest");
1756 make(2_000, "mid");
1757
1758 let rows = list_archives(&fs, "def-a", 0).unwrap();
1759 assert_eq!(rows.len(), 3);
1760 assert_eq!(rows[0].archived_at_ms, 3_000);
1761 assert_eq!(rows[0].preview, "newest");
1762 assert_eq!(rows[0].node_count, 1);
1763 assert_eq!(rows[1].archived_at_ms, 2_000);
1764 assert_eq!(rows[2].archived_at_ms, 1_000);
1765 }
1766
1767 #[test]
1768 fn list_archives_respects_limit() {
1769 let fs = fresh_filestore();
1770 for ts in 1..=5u64 {
1771 let zone = agent_archive_zone("def-a", ts);
1772 fs.make_file(&zone, SNAPSHOT_FILE, FileMeta::default(), FileOpts::default()).unwrap();
1773 fs.write_file(&zone, SNAPSHOT_FILE, b"{}").unwrap();
1774 }
1775 let rows = list_archives(&fs, "def-a", 2).unwrap();
1776 assert_eq!(rows.len(), 2);
1777 }
1778
1779 #[test]
1780 fn list_archives_rejects_bad_definition_id() {
1781 let fs = fresh_filestore();
1782 assert!(list_archives(&fs, "../bad", 0).is_err());
1783 }
1784
1785 fn open_temp_wstore(dir: &Path) -> Arc<Store> {
1788 let path = dir.join("objects.db");
1789 Arc::new(Store::open(&path).expect("open wstore"))
1790 }
1791
1792 fn insert_agent_block(wstore: &Arc<Store>, def_id: &str) -> String {
1793 let oid = uuid::Uuid::new_v4().to_string();
1794 let mut meta = MetaMapType::new();
1795 meta.insert("view".to_string(), serde_json::json!("agent"));
1796 meta.insert("agentId".to_string(), serde_json::json!(def_id));
1797 let mut block = Block {
1798 oid: oid.clone(),
1799 parentoref: String::new(),
1800 version: 1,
1801 runtimeopts: None,
1802 stickers: None,
1803 meta,
1804 subblockids: None,
1805 };
1806 wstore.insert(&mut block).expect("insert block");
1807 oid
1808 }
1809
1810 fn seed_block_snapshot(filestore: &Arc<FileStore>, block_id: &str, body: &str) {
1811 filestore
1812 .make_file(block_id, SNAPSHOT_FILE, FileMeta::default(), FileOpts::default())
1813 .unwrap();
1814 filestore.write_file(block_id, SNAPSHOT_FILE, body.as_bytes()).unwrap();
1815 }
1816
1817 #[test]
1818 fn migration_backfills_archives_and_seeds_current() {
1819 let dir = tempdir().unwrap();
1820 let wstore = open_temp_wstore(dir.path());
1821 let filestore = fresh_filestore();
1822
1823 let block1 = insert_agent_block(&wstore, "def-maks");
1826 seed_block_snapshot(
1827 &filestore,
1828 &block1,
1829 r#"{"nodes":[{"type":"user_message","message":"old"}]}"#,
1830 );
1831 std::thread::sleep(std::time::Duration::from_millis(5));
1834 let block2 = insert_agent_block(&wstore, "def-maks");
1835 seed_block_snapshot(
1836 &filestore,
1837 &block2,
1838 r#"{"nodes":[{"type":"user_message","message":"newer"}]}"#,
1839 );
1840
1841 let block_other = insert_agent_block(&wstore, "def-other");
1843 seed_block_snapshot(
1844 &filestore,
1845 &block_other,
1846 r#"{"nodes":[{"type":"user_message","message":"other"}]}"#,
1847 );
1848
1849 let stats = migrate_block_zones_v1(&wstore, &filestore, dir.path());
1850 assert_eq!(stats.blocks_scanned, 3);
1851 assert_eq!(stats.archives_written, 3);
1852 assert_eq!(stats.current_zones_seeded, 2);
1853 assert_eq!(stats.failures, 0);
1854
1855 assert!(dir.path().join(MIGRATION_MARKER_V1).exists());
1857
1858 let (content, _) = read_session_state(&filestore, "def-maks").unwrap();
1861 assert!(content.unwrap().contains("newer"));
1862
1863 let archives = list_archives(&filestore, "def-maks", 0).unwrap();
1865 assert_eq!(archives.len(), 2);
1866
1867 let (other, _) = read_session_state(&filestore, "def-other").unwrap();
1869 assert!(other.unwrap().contains("other"));
1870 let other_archives = list_archives(&filestore, "def-other", 0).unwrap();
1871 assert_eq!(other_archives.len(), 1);
1872
1873 let still_block1 = filestore.stat(&block1, SNAPSHOT_FILE).unwrap();
1875 assert!(still_block1.is_some(), "old block zone must remain");
1876 }
1877
1878 #[test]
1879 fn migration_is_idempotent() {
1880 let dir = tempdir().unwrap();
1881 let wstore = open_temp_wstore(dir.path());
1882 let filestore = fresh_filestore();
1883
1884 let block = insert_agent_block(&wstore, "def-a");
1885 seed_block_snapshot(
1886 &filestore,
1887 &block,
1888 r#"{"nodes":[{"type":"user_message","message":"x"}]}"#,
1889 );
1890
1891 let first = migrate_block_zones_v1(&wstore, &filestore, dir.path());
1892 assert_eq!(first.archives_written, 1);
1893 assert_eq!(first.current_zones_seeded, 1);
1894
1895 let second = migrate_block_zones_v1(&wstore, &filestore, dir.path());
1897 assert_eq!(second.blocks_scanned, 0);
1898 assert_eq!(second.archives_written, 0);
1899 assert_eq!(second.current_zones_seeded, 0);
1900 }
1901
1902 use crate::backend::storage::store::{AgentDefinition, AgentInstance, InstanceStatus};
1905
1906 fn insert_template(
1907 wstore: &Arc<Store>,
1908 id: &str,
1909 name: &str,
1910 provider: &str,
1911 ) -> AgentDefinition {
1912 let mut def = AgentDefinition {
1913 id: id.to_string(),
1914 slug: String::new(),
1915 name: name.to_string(),
1916 icon: String::new(),
1917 provider: provider.to_string(),
1918 description: format!("{name} template"),
1919 working_directory: String::new(),
1920 shell: String::new(),
1921 provider_flags: String::new(),
1922 auto_start: 0,
1923 restart_on_crash: 0,
1924 idle_timeout_minutes: 0,
1925 created_at: 1_700_000_000_000,
1926 agent_type: "host".to_string(),
1927 environment: String::new(),
1928 agent_bus_id: String::new(),
1929 is_seeded: 1, accounts: String::new(),
1931 parent_id: String::new(),
1932 branch_label: String::new(),
1933 updated_at: 1_700_000_000_000,
1934 user_hidden: 0,
1935 container_image: String::new(),
1936 container_volumes: "[]".to_string(),
1937 container_name: String::new(),
1938 };
1939 wstore.agent_def_insert(&mut def).unwrap();
1940 def
1941 }
1942
1943 fn insert_named_instance(
1944 wstore: &Arc<Store>,
1945 id: &str,
1946 def_id: &str,
1947 instance_name: &str,
1948 started_at: i64,
1949 ) {
1950 let inst = AgentInstance {
1951 id: id.to_string(),
1952 definition_id: def_id.to_string(),
1953 parent_instance_id: String::new(),
1954 block_id: String::new(),
1955 session_id: String::new(),
1956 status: InstanceStatus::Running.as_str().to_string(),
1957 github_context: String::new(),
1958 started_at,
1959 ended_at: 0,
1960 created_at: started_at,
1961 identity_id: String::new(),
1962 memory_id: String::new(),
1963 instance_name: instance_name.to_string(),
1964 working_directory: String::new(),
1965 display_hidden: false,
1966 };
1967 wstore.instance_create(&inst).unwrap();
1968 }
1969
1970 #[test]
1971 fn template_promote_clones_template_and_moves_zones() {
1972 let dir = tempdir().unwrap();
1973 let wstore = open_temp_wstore(dir.path());
1974 let filestore = fresh_filestore();
1975
1976 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
1979 insert_named_instance(&wstore, "inst-maks", &template.id, "Maks", 1_700_000_100_000);
1980 write_session_state(
1981 &filestore,
1982 &template.id,
1983 br#"{"nodes":[{"type":"user_message","message":"hi"}]}"#,
1984 )
1985 .unwrap();
1986 let archive_zone = agent_archive_zone(&template.id, 1_699_000_000_000);
1988 write_zone_file(&filestore, &archive_zone, SNAPSHOT_FILE, b"archived").unwrap();
1989
1990 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
1991 assert_eq!(stats.templates_scanned, 1);
1992 assert_eq!(stats.templates_promoted, 1);
1993 assert_eq!(stats.archives_moved, 1);
1994 assert_eq!(stats.instances_repointed, 1);
1995 assert_eq!(stats.failures, 0);
1996
1997 let stale_current = agent_current_zone(&template.id);
1999 let stale = filestore.list_files(&stale_current).unwrap();
2000 assert!(stale.is_empty(), "template current zone should be empty post-promote");
2001 let stale_archive = filestore.list_files(&archive_zone).unwrap();
2003 assert!(stale_archive.is_empty(), "template archive zone should be empty post-promote");
2004
2005 let all = wstore.agent_def_list().unwrap();
2008 let new_def = all
2009 .iter()
2010 .find(|d| d.is_seeded == 0 && d.parent_id == template.id)
2011 .expect("a new user-owned definition should exist");
2012 assert_eq!(new_def.name, "Maks");
2013 assert_eq!(new_def.provider, "claude");
2014
2015 let new_current = agent_current_zone(&new_def.id);
2017 let new_files = filestore.list_files(&new_current).unwrap();
2018 assert!(
2019 new_files.iter().any(|f| f.name == SNAPSHOT_FILE),
2020 "new current zone should have output.state.json"
2021 );
2022 let new_archive = agent_archive_zone(&new_def.id, 1_699_000_000_000);
2023 let new_archive_files = filestore.list_files(&new_archive).unwrap();
2024 assert!(
2025 new_archive_files.iter().any(|f| f.name == SNAPSHOT_FILE),
2026 "new archive zone should be populated"
2027 );
2028
2029 let inst = wstore.instance_get("inst-maks").unwrap().unwrap();
2031 assert_eq!(
2032 inst.definition_id, new_def.id,
2033 "instance should now reference new user-agent def"
2034 );
2035
2036 let still_seeded = all.iter().find(|d| d.id == template.id).unwrap();
2039 assert_eq!(still_seeded.is_seeded, 1);
2040
2041 assert!(!dir.path().join(TEMPLATE_PROMOTE_MARKER_V1).exists());
2046 }
2047
2048 #[test]
2049 fn template_promote_is_idempotent_on_second_run() {
2050 let dir = tempdir().unwrap();
2051 let wstore = open_temp_wstore(dir.path());
2052 let filestore = fresh_filestore();
2053
2054 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2055 write_session_state(&filestore, &template.id, br#"{"nodes":[]}"#).unwrap();
2056
2057 let first = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2058 assert_eq!(first.templates_promoted, 1);
2059
2060 let second = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2061 assert_eq!(second.templates_scanned, 0);
2062 assert_eq!(second.templates_promoted, 0);
2063 assert_eq!(second.archives_moved, 0);
2064 assert_eq!(second.instances_repointed, 0);
2065 }
2066
2067 #[test]
2068 fn template_promote_runs_when_seeded_def_grows_zone_after_first_run() {
2069 let dir = tempdir().unwrap();
2088 let wstore = open_temp_wstore(dir.path());
2089 let filestore = fresh_filestore();
2090
2091 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2094 let first = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2095 assert_eq!(first.templates_scanned, 0);
2096 assert_eq!(first.templates_promoted, 0);
2097 assert!(!dir.path().join(TEMPLATE_PROMOTE_MARKER_V1).exists());
2099
2100 write_session_state(&filestore, &template.id, br#"{"nodes":[]}"#).unwrap();
2103
2104 let second = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2108 assert_eq!(second.templates_scanned, 1);
2109 assert_eq!(second.templates_promoted, 1);
2110 assert_eq!(second.failures, 0);
2111
2112 let all = wstore.agent_def_list().unwrap();
2114 assert!(
2115 all.iter().any(|d| d.is_seeded == 0 && d.parent_id == template.id),
2116 "second-run promotion should create a user-owned def"
2117 );
2118
2119 let third = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2121 assert_eq!(third.templates_scanned, 0);
2122 assert_eq!(third.templates_promoted, 0);
2123 }
2124
2125 #[test]
2126 fn template_promote_does_not_reuse_clone_with_active_zone() {
2127 let dir = tempdir().unwrap();
2136 let wstore = open_temp_wstore(dir.path());
2137 let filestore = fresh_filestore();
2138
2139 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2140 let now = now_ms() as i64;
2144 let mut user_clone = crate::backend::storage::store::AgentDefinition {
2145 id: "user-made-clone".to_string(),
2146 slug: String::new(),
2147 name: "MyAgent".to_string(),
2148 icon: template.icon.clone(),
2149 provider: template.provider.clone(),
2150 description: template.description.clone(),
2151 working_directory: String::new(),
2152 shell: template.shell.clone(),
2153 provider_flags: template.provider_flags.clone(),
2154 auto_start: 0,
2155 restart_on_crash: template.restart_on_crash,
2156 idle_timeout_minutes: template.idle_timeout_minutes,
2157 created_at: now - 2_000,
2158 agent_type: template.agent_type.clone(),
2159 environment: template.environment.clone(),
2160 agent_bus_id: String::new(),
2161 is_seeded: 0,
2162 accounts: String::new(),
2163 parent_id: template.id.clone(),
2164 branch_label: String::new(),
2165 updated_at: now - 2_000,
2166 user_hidden: 0,
2167 container_image: String::new(),
2168 container_volumes: "[]".to_string(),
2169 container_name: String::new(),
2170 };
2171 wstore.agent_def_insert(&mut user_clone).unwrap();
2172 write_session_state(
2174 &filestore,
2175 &user_clone.id,
2176 br#"{"nodes":[{"type":"user_message","message":"mine"}]}"#,
2177 )
2178 .unwrap();
2179
2180 write_session_state(
2183 &filestore,
2184 &template.id,
2185 br#"{"nodes":[{"type":"user_message","message":"theirs"}]}"#,
2186 )
2187 .unwrap();
2188
2189 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2190 assert_eq!(stats.templates_promoted, 1);
2191
2192 let user_zone_files = filestore
2196 .list_files(&agent_current_zone(&user_clone.id))
2197 .unwrap();
2198 let user_snapshot = user_zone_files
2199 .iter()
2200 .find(|f| f.name == SNAPSHOT_FILE)
2201 .expect("user-clone's own zone snapshot must still exist");
2202 let user_bytes = filestore
2203 .read_file(&agent_current_zone(&user_clone.id), &user_snapshot.name)
2204 .unwrap()
2205 .unwrap_or_default();
2206 assert!(
2207 std::str::from_utf8(&user_bytes).unwrap().contains("mine"),
2208 "user-clone's existing conversation must NOT be overwritten by the seeded session"
2209 );
2210
2211 let all = wstore.agent_def_list().unwrap();
2214 let new_clone = all
2215 .iter()
2216 .find(|d| d.is_seeded == 0 && d.parent_id == template.id && d.id != "user-made-clone")
2217 .expect("a NEW clone must have been created (not reusing the user's clone)");
2218 let new_zone_bytes = filestore
2219 .read_file(&agent_current_zone(&new_clone.id), SNAPSHOT_FILE)
2220 .unwrap()
2221 .unwrap_or_default();
2222 assert!(
2223 std::str::from_utf8(&new_zone_bytes).unwrap().contains("theirs"),
2224 "promoted session must land under the fresh clone's id"
2225 );
2226 }
2227
2228 #[test]
2229 fn template_promote_preserves_user_continuation_on_clone() {
2230 let dir = tempdir().unwrap();
2242 let wstore = open_temp_wstore(dir.path());
2243 let filestore = fresh_filestore();
2244
2245 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2246 let promote_target_id = format!("template-promote-v1-{}", template.id);
2249 let now = now_ms() as i64;
2250 let mut prior_target = crate::backend::storage::store::AgentDefinition {
2251 id: promote_target_id.clone(),
2252 slug: String::new(),
2253 name: "Claude Code".to_string(),
2254 icon: template.icon.clone(),
2255 provider: template.provider.clone(),
2256 description: template.description.clone(),
2257 working_directory: String::new(),
2258 shell: template.shell.clone(),
2259 provider_flags: template.provider_flags.clone(),
2260 auto_start: 0,
2261 restart_on_crash: template.restart_on_crash,
2262 idle_timeout_minutes: template.idle_timeout_minutes,
2263 created_at: now - 1_000,
2264 agent_type: template.agent_type.clone(),
2265 environment: template.environment.clone(),
2266 agent_bus_id: String::new(),
2267 is_seeded: 0,
2268 accounts: String::new(),
2269 parent_id: template.id.clone(),
2270 branch_label: String::new(),
2271 updated_at: now - 1_000,
2272 user_hidden: 0,
2273 container_image: String::new(),
2274 container_volumes: "[]".to_string(),
2275 container_name: String::new(),
2276 };
2277 wstore.agent_def_insert(&mut prior_target).unwrap();
2278 write_session_state(
2282 &filestore,
2283 &template.id,
2284 br#"{"nodes":[{"type":"user_message","message":"old-stale-seeded"}]}"#,
2285 )
2286 .unwrap();
2287 std::thread::sleep(std::time::Duration::from_millis(10));
2291 write_session_state(
2293 &filestore,
2294 &promote_target_id,
2295 br#"{"nodes":[{"type":"user_message","message":"my-newer-message"}]}"#,
2296 )
2297 .unwrap();
2298
2299 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2300 assert_eq!(stats.templates_promoted, 1);
2301
2302 let clone_bytes = filestore
2304 .read_file(&agent_current_zone(&promote_target_id), SNAPSHOT_FILE)
2305 .unwrap()
2306 .unwrap_or_default();
2307 let clone_str = std::str::from_utf8(&clone_bytes).unwrap();
2308 assert!(
2309 clone_str.contains("my-newer-message"),
2310 "user's newer continuation must survive the partial-failure retry; got: {clone_str}"
2311 );
2312 assert!(
2313 !clone_str.contains("old-stale-seeded"),
2314 "stale seeded content must NOT overwrite user's newer continuation"
2315 );
2316
2317 let seeded_files = filestore
2319 .list_files(&agent_current_zone(&template.id))
2320 .unwrap();
2321 assert!(
2322 seeded_files.is_empty(),
2323 "seeded current zone must be drained after the retry's safety drop"
2324 );
2325 }
2326
2327 #[test]
2328 fn template_promote_recovers_partial_copy_at_zone() {
2329 let dir = tempdir().unwrap();
2340 let wstore = open_temp_wstore(dir.path());
2341 let filestore = fresh_filestore();
2342
2343 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2344 let promote_target_id = format!("template-promote-v1-{}", template.id);
2347 let now = now_ms() as i64;
2348 let mut prior_target = crate::backend::storage::store::AgentDefinition {
2349 id: promote_target_id.clone(),
2350 slug: String::new(),
2351 name: "Claude Code".to_string(),
2352 icon: template.icon.clone(),
2353 provider: template.provider.clone(),
2354 description: template.description.clone(),
2355 working_directory: String::new(),
2356 shell: template.shell.clone(),
2357 provider_flags: template.provider_flags.clone(),
2358 auto_start: 0,
2359 restart_on_crash: template.restart_on_crash,
2360 idle_timeout_minutes: template.idle_timeout_minutes,
2361 created_at: now - 1_000,
2362 agent_type: template.agent_type.clone(),
2363 environment: template.environment.clone(),
2364 agent_bus_id: String::new(),
2365 is_seeded: 0,
2366 accounts: String::new(),
2367 parent_id: template.id.clone(),
2368 branch_label: String::new(),
2369 updated_at: now - 1_000,
2370 user_hidden: 0,
2371 container_image: String::new(),
2372 container_volumes: "[]".to_string(),
2373 container_name: String::new(),
2374 };
2375 wstore.agent_def_insert(&mut prior_target).unwrap();
2376
2377 let seeded_current = agent_current_zone(&template.id);
2379 write_zone_file(&filestore, &seeded_current, SNAPSHOT_FILE, b"seeded-snapshot").unwrap();
2380 write_zone_file(&filestore, &seeded_current, OUTPUT_FILE, b"seeded-output-stream").unwrap();
2381
2382 write_zone_file(
2385 &filestore,
2386 &agent_current_zone(&promote_target_id),
2387 SNAPSHOT_FILE,
2388 b"seeded-snapshot",
2389 )
2390 .unwrap();
2391
2392 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2393 assert_eq!(stats.templates_promoted, 1);
2394
2395 let clone_zone = agent_current_zone(&promote_target_id);
2398 let clone_files = filestore.list_files(&clone_zone).unwrap();
2399 let clone_names: std::collections::HashSet<String> =
2400 clone_files.iter().map(|f| f.name.clone()).collect();
2401 assert!(
2402 clone_names.contains(SNAPSHOT_FILE),
2403 "snapshot file must remain at destination"
2404 );
2405 assert!(
2406 clone_names.contains(OUTPUT_FILE),
2407 "output file must be copied over from source on retry (codex R5)"
2408 );
2409 let output_bytes = filestore
2410 .read_file(&clone_zone, OUTPUT_FILE)
2411 .unwrap()
2412 .unwrap_or_default();
2413 assert_eq!(
2414 output_bytes, b"seeded-output-stream",
2415 "the unwritten file from the partial copy must arrive intact"
2416 );
2417
2418 let seeded_files = filestore.list_files(&seeded_current).unwrap();
2421 assert!(
2422 seeded_files.is_empty(),
2423 "seeded current zone must be drained after the complete copy"
2424 );
2425 }
2426
2427 #[test]
2428 fn template_promote_promotes_newer_source_over_stale_destination() {
2429 let dir = tempdir().unwrap();
2442 let wstore = open_temp_wstore(dir.path());
2443 let filestore = fresh_filestore();
2444
2445 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2446 let promote_target_id = format!("template-promote-v1-{}", template.id);
2447 let now = now_ms() as i64;
2448 let mut prior_target = crate::backend::storage::store::AgentDefinition {
2449 id: promote_target_id.clone(),
2450 slug: String::new(),
2451 name: "Claude Code".to_string(),
2452 icon: template.icon.clone(),
2453 provider: template.provider.clone(),
2454 description: template.description.clone(),
2455 working_directory: String::new(),
2456 shell: template.shell.clone(),
2457 provider_flags: template.provider_flags.clone(),
2458 auto_start: 0,
2459 restart_on_crash: template.restart_on_crash,
2460 idle_timeout_minutes: template.idle_timeout_minutes,
2461 created_at: now - 1_000,
2462 agent_type: template.agent_type.clone(),
2463 environment: template.environment.clone(),
2464 agent_bus_id: String::new(),
2465 is_seeded: 0,
2466 accounts: String::new(),
2467 parent_id: template.id.clone(),
2468 branch_label: String::new(),
2469 updated_at: now - 1_000,
2470 user_hidden: 0,
2471 container_image: String::new(),
2472 container_volumes: "[]".to_string(),
2473 container_name: String::new(),
2474 };
2475 wstore.agent_def_insert(&mut prior_target).unwrap();
2476
2477 let clone_zone = agent_current_zone(&promote_target_id);
2479 write_zone_file(&filestore, &clone_zone, SNAPSHOT_FILE, b"stale-old-copy").unwrap();
2480 std::thread::sleep(std::time::Duration::from_millis(10));
2484 let seeded_zone = agent_current_zone(&template.id);
2488 write_zone_file(&filestore, &seeded_zone, SNAPSHOT_FILE, b"user-newer-continuation").unwrap();
2489
2490 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2491 assert_eq!(stats.templates_promoted, 1);
2492
2493 let clone_bytes = filestore
2495 .read_file(&clone_zone, SNAPSHOT_FILE)
2496 .unwrap()
2497 .unwrap_or_default();
2498 let clone_str = std::str::from_utf8(&clone_bytes).unwrap();
2499 assert!(
2500 clone_str.contains("user-newer-continuation"),
2501 "user's newer continuation must be promoted from seeded source to clone; got: {clone_str}"
2502 );
2503 assert!(
2504 !clone_str.contains("stale-old-copy"),
2505 "stale older destination bytes must be replaced by the newer source"
2506 );
2507
2508 let seeded_files = filestore.list_files(&seeded_zone).unwrap();
2510 assert!(seeded_files.is_empty(), "seeded zone drained after promotion");
2511 }
2512
2513 #[test]
2514 fn template_promote_uses_deterministic_clone_id() {
2515 let dir = tempdir().unwrap();
2522 let wstore = open_temp_wstore(dir.path());
2523 let filestore = fresh_filestore();
2524
2525 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2526 write_session_state(
2527 &filestore,
2528 &template.id,
2529 br#"{"nodes":[{"type":"user_message","message":"hi"}]}"#,
2530 )
2531 .unwrap();
2532
2533 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2534 assert_eq!(stats.templates_promoted, 1);
2535
2536 let expected_id = format!("template-promote-v1-{}", template.id);
2537 let clone = wstore.agent_def_get(&expected_id).unwrap();
2538 assert!(clone.is_some(), "promote target must be created at the deterministic id");
2539 assert_eq!(clone.unwrap().parent_id, template.id);
2540 }
2541
2542 #[test]
2543 fn template_promote_idempotent_under_partial_failure_at_archive_move() {
2544 let dir = tempdir().unwrap();
2555 let wstore = open_temp_wstore(dir.path());
2556 let filestore = fresh_filestore();
2557
2558 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2559 insert_named_instance(&wstore, "inst-maks", &template.id, "Maks", 1_700_000_100_000);
2560
2561 let promote_target_id = format!("template-promote-v1-{}", template.id);
2566 let now = now_ms() as i64;
2567 let mut prior_target = crate::backend::storage::store::AgentDefinition {
2568 id: promote_target_id.clone(),
2569 slug: String::new(),
2570 name: "Maks".to_string(),
2571 icon: template.icon.clone(),
2572 provider: template.provider.clone(),
2573 description: template.description.clone(),
2574 working_directory: String::new(),
2575 shell: template.shell.clone(),
2576 provider_flags: template.provider_flags.clone(),
2577 auto_start: 0,
2578 restart_on_crash: template.restart_on_crash,
2579 idle_timeout_minutes: template.idle_timeout_minutes,
2580 created_at: now - 1_000,
2581 agent_type: template.agent_type.clone(),
2582 environment: template.environment.clone(),
2583 agent_bus_id: String::new(),
2584 is_seeded: 0,
2585 accounts: String::new(),
2586 parent_id: template.id.clone(),
2587 branch_label: String::new(),
2588 updated_at: now - 1_000,
2589 user_hidden: 0,
2590 container_image: String::new(),
2591 container_volumes: "[]".to_string(),
2592 container_name: String::new(),
2593 };
2594 wstore.agent_def_insert(&mut prior_target).unwrap();
2595 let snapshot_bytes = b"snapshot-from-prior-run".as_slice();
2602 write_zone_file(&filestore, &agent_current_zone(&promote_target_id), SNAPSHOT_FILE, snapshot_bytes).unwrap();
2603 write_zone_file(&filestore, &agent_current_zone(&template.id), SNAPSHOT_FILE, snapshot_bytes).unwrap();
2604 let stale_archive = agent_archive_zone(&template.id, 1_699_000_000_000);
2605 write_zone_file(&filestore, &stale_archive, SNAPSHOT_FILE, b"old archive").unwrap();
2606
2607 let clones_pre = wstore.user_clone_defs_for_template(&template.id).unwrap();
2613 assert_eq!(clones_pre.len(), 1, "test setup: one prior clone at deterministic id");
2614 assert_eq!(clones_pre[0].id, promote_target_id);
2615
2616 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2617 assert_eq!(stats.templates_scanned, 1);
2618 assert_eq!(stats.templates_promoted, 1);
2619
2620 let clones_post = wstore.user_clone_defs_for_template(&template.id).unwrap();
2623 assert_eq!(
2624 clones_post.len(),
2625 1,
2626 "deterministic-id reuse must not create a duplicate clone on partial-failure retry"
2627 );
2628 assert_eq!(clones_post[0].id, promote_target_id);
2629
2630 let seeded_current = filestore
2632 .list_files(&agent_current_zone(&template.id))
2633 .unwrap();
2634 assert!(
2635 seeded_current.is_empty(),
2636 "seeded current zone should be empty after the retry's successful move"
2637 );
2638 let seeded_archive_files = filestore.list_files(&stale_archive).unwrap();
2639 assert!(
2640 seeded_archive_files.is_empty(),
2641 "seeded archive zone should be empty after the retry's successful move"
2642 );
2643
2644 let stats2 = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2646 assert_eq!(stats2.templates_scanned, 0);
2647 assert_eq!(stats2.templates_promoted, 0);
2648 }
2649
2650 #[test]
2651 fn template_promote_ignores_legacy_marker_file() {
2652 let dir = tempdir().unwrap();
2657 let wstore = open_temp_wstore(dir.path());
2658 let filestore = fresh_filestore();
2659
2660 std::fs::write(dir.path().join(TEMPLATE_PROMOTE_MARKER_V1), b"v1\n").unwrap();
2662
2663 let template = insert_template(&wstore, "tpl-claude", "Claude Code", "claude");
2665 write_session_state(&filestore, &template.id, br#"{"nodes":[]}"#).unwrap();
2666
2667 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2668 assert_eq!(stats.templates_scanned, 1);
2670 assert_eq!(stats.templates_promoted, 1);
2671 }
2672
2673 #[test]
2674 fn template_promote_falls_back_to_template_name_when_no_named_instance() {
2675 let dir = tempdir().unwrap();
2676 let wstore = open_temp_wstore(dir.path());
2677 let filestore = fresh_filestore();
2678
2679 let template = insert_template(&wstore, "tpl-x", "Cursor", "cursor");
2680 write_session_state(&filestore, &template.id, br#"{"nodes":[]}"#).unwrap();
2681 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2684 assert_eq!(stats.templates_promoted, 1);
2685
2686 let all = wstore.agent_def_list().unwrap();
2687 let new_def = all
2688 .iter()
2689 .find(|d| d.is_seeded == 0 && d.parent_id == template.id)
2690 .expect("should clone the template");
2691 assert_eq!(new_def.name, "Cursor");
2693 }
2694
2695 #[test]
2696 fn template_promote_skips_already_user_owned_definitions() {
2697 let dir = tempdir().unwrap();
2698 let wstore = open_temp_wstore(dir.path());
2699 let filestore = fresh_filestore();
2700
2701 let mut user_def = AgentDefinition {
2704 id: "user-abc".to_string(),
2705 slug: String::new(),
2706 name: "My Agent".to_string(),
2707 icon: String::new(),
2708 provider: "claude".to_string(),
2709 description: String::new(),
2710 working_directory: String::new(),
2711 shell: String::new(),
2712 provider_flags: String::new(),
2713 auto_start: 0,
2714 restart_on_crash: 0,
2715 idle_timeout_minutes: 0,
2716 created_at: 1_700_000_000_000,
2717 agent_type: "host".to_string(),
2718 environment: String::new(),
2719 agent_bus_id: String::new(),
2720 is_seeded: 0,
2721 accounts: String::new(),
2722 parent_id: String::new(),
2723 branch_label: String::new(),
2724 updated_at: 1_700_000_000_000,
2725 user_hidden: 0,
2726 container_image: String::new(),
2727 container_volumes: "[]".to_string(),
2728 container_name: String::new(),
2729 };
2730 wstore.agent_def_insert(&mut user_def).unwrap();
2731 write_session_state(&filestore, &user_def.id, br#"{"nodes":[]}"#).unwrap();
2732
2733 let stats = migrate_promote_template_sessions_v1(&wstore, &filestore, dir.path());
2734 assert_eq!(stats.templates_scanned, 0);
2735 assert_eq!(stats.templates_promoted, 0);
2736
2737 let all = wstore.agent_def_list().unwrap();
2739 let still_there = all.iter().find(|d| d.id == "user-abc").unwrap();
2740 assert_eq!(still_there.is_seeded, 0);
2741
2742 let cur = agent_current_zone(&user_def.id);
2744 let files = filestore.list_files(&cur).unwrap();
2745 assert!(!files.is_empty());
2746 }
2747
2748 #[test]
2749 fn migration_skips_non_agent_and_empty_blocks() {
2750 let dir = tempdir().unwrap();
2751 let wstore = open_temp_wstore(dir.path());
2752 let filestore = fresh_filestore();
2753
2754 let term_oid = uuid::Uuid::new_v4().to_string();
2756 let mut term_meta = MetaMapType::new();
2757 term_meta.insert("view".to_string(), serde_json::json!("term"));
2758 let mut term = Block {
2759 oid: term_oid.clone(),
2760 parentoref: String::new(),
2761 version: 1,
2762 runtimeopts: None,
2763 stickers: None,
2764 meta: term_meta,
2765 subblockids: None,
2766 };
2767 wstore.insert(&mut term).unwrap();
2768 seed_block_snapshot(&filestore, &term_oid, r#"{"nodes":[]}"#);
2769
2770 let _empty = insert_agent_block(&wstore, "def-x");
2772
2773 let stats = migrate_block_zones_v1(&wstore, &filestore, dir.path());
2774 assert_eq!(stats.blocks_scanned, 1);
2777 assert_eq!(stats.skipped_no_snapshot, 1);
2778 assert_eq!(stats.archives_written, 0);
2779 assert_eq!(stats.current_zones_seeded, 0);
2780 }
2781
2782 #[test]
2783 fn normalize_snapshot_strips_source_block_id_for_global_mirror() {
2784 let local = br#"{"schemaVersion":2,"highWaterMark":1015,"sourceBlockId":"1cfdef4b-6784-4dc9-aea8-4977097736b6","documentState":{}}"#;
2787 let global = normalize_snapshot_for_global(local);
2788 let v: serde_json::Value = serde_json::from_slice(&global).unwrap();
2789 assert_eq!(v["sourceBlockId"], "", "global copy must be agent-anchored");
2790 assert_eq!(v["highWaterMark"], 1015, "other fields preserved");
2791 assert_eq!(v["schemaVersion"], 2);
2792
2793 let again = normalize_snapshot_for_global(&global);
2795 let v2: serde_json::Value = serde_json::from_slice(&again).unwrap();
2796 assert_eq!(v2["sourceBlockId"], "");
2797
2798 assert_eq!(normalize_snapshot_for_global(b"not json"), b"not json".to_vec());
2800 }
2801}