1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use wm_core::{
11 Context, EffectRow, EpisodicKind, Galaxy, Gana, ProvenanceSource, Resource, Tool, ToolStats,
12};
13use wm_memory::{Memory, MemoryStore};
14
15fn capture_git_state(root: &Path) -> Option<Value> {
20 let run = |git_args: &[&str]| -> Option<String> {
21 let out = std::process::Command::new("git")
22 .args(git_args)
23 .current_dir(root)
24 .env("GIT_OPTIONAL_LOCKS", "0")
29 .output()
30 .ok()?;
31 if out.status.success() {
32 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
33 } else {
34 None
35 }
36 };
37 run(&["rev-parse", "--is-inside-work-tree"])?;
40 let commit = run(&["rev-parse", "HEAD"]).unwrap_or_default();
41 let branch = run(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
42 let dirty_count = run(&["status", "--porcelain"]).map_or(0, |s| s.lines().count());
43 Some(json!({
44 "commit": commit,
45 "branch": branch,
46 "dirty_count": dirty_count,
47 }))
48}
49
50fn resolve_project_root(args: &Value) -> Option<PathBuf> {
53 args.get("root")
54 .and_then(|v| v.as_str())
55 .filter(|s| !s.is_empty())
56 .map(PathBuf::from)
57 .or_else(|| {
58 std::env::var("WM_PROJECT_ROOT")
59 .ok()
60 .filter(|s| !s.is_empty())
61 .map(PathBuf::from)
62 })
63}
64
65fn latest_session_start(store: &MemoryStore) -> Option<String> {
68 store
69 .scan_all(Galaxy::Sessions)
70 .ok()?
71 .iter()
72 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
73 .max_by_key(|m| m.metadata.created_at)
74 .map(|m| m.metadata.id.to_string())
75}
76
77pub struct SessionStartTool {
79 store: Arc<MemoryStore>,
80 stats: ToolStats,
81 effects: EffectRow,
82 search: Option<Arc<wm_memory::SearchEngine>>,
83}
84
85impl SessionStartTool {
86 pub fn new(store: Arc<MemoryStore>) -> Self {
87 Self {
88 store,
89 stats: ToolStats::default(),
90 effects: EffectRow {
91 writes: vec![Resource::Galaxy("sessions".into())],
92 ..Default::default()
93 },
94 search: None,
95 }
96 }
97
98 #[must_use]
102 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
103 self.search = search;
104 self
105 }
106}
107
108#[async_trait]
109impl Tool for SessionStartTool {
110 fn name(&self) -> &str {
111 "session.start"
112 }
113 fn gana(&self) -> Gana {
114 Gana::StraddlingLegs
115 }
116 fn effects(&self) -> &EffectRow {
117 &self.effects
118 }
119 fn input_schema(&self) -> Value {
120 super::common::schema(
121 &json!({
122 "title": super::common::str_prop("Session title"),
123 "user": super::common::str_prop("User identifier (default 'default')"),
124 }),
125 &[],
126 )
127 }
128 fn description(&self) -> &str {
129 "Start a new session — creates a session memory in Sessions galaxy"
130 }
131 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
132 let title = args
133 .get("title")
134 .and_then(|v| v.as_str())
135 .unwrap_or("Untitled Session");
136 let user = args
137 .get("user")
138 .and_then(|v| v.as_str())
139 .unwrap_or("default");
140 let mut mem = Memory::new(
141 Galaxy::Sessions,
142 json!({
143 "type": "session_start",
144 "title": title,
145 "user": user,
146 })
147 .to_string(),
148 );
149 mem.metadata.tags = vec!["session".into(), "start".into()];
150 mem.metadata.importance = 0.7;
151 mem.metadata.source = "system".to_string();
153 mem.metadata.source_trust = 0.7;
154 self.store.put(Galaxy::Sessions, &mem)?;
155 super::common::index_memory(self.search.as_deref(), &mem);
156 let episodic_capture_error = crate::capture_explicit_memory(
157 &self.store,
158 &mem,
159 EpisodicKind::SystemEvent,
160 ProvenanceSource::System,
161 Some(mem.metadata.id),
162 0,
163 );
164 let mut response = json!({
165 "status": "success",
166 "session_id": mem.metadata.id,
167 "title": title,
168 "user": user,
169 });
170 if let Some(error) = episodic_capture_error {
171 response["warnings"] = json!([format!(
172 "episodic capture failed after the session event was stored: {error}"
173 )]);
174 }
175 Ok(response)
176 }
177 fn stats(&self) -> &ToolStats {
178 &self.stats
179 }
180}
181
182fn store_checkpoint_record(
186 store: &MemoryStore,
187 search: Option<&wm_memory::SearchEngine>,
188 session_id: &str,
189 label: &str,
190 data: &Value,
191 handoff: &Value,
192) -> wm_core::Result<Value> {
193 let mut mem = Memory::new(
194 Galaxy::Sessions,
195 json!({
196 "type": "checkpoint",
197 "session_id": session_id,
198 "label": label,
199 "data": data,
200 "handoff": handoff,
201 })
202 .to_string(),
203 );
204 mem.metadata.tags = vec!["session".into(), "checkpoint".into()];
205 mem.metadata.importance = 0.5;
206 mem.metadata.source = "system".to_string();
208 mem.metadata.source_trust = 0.7;
209 store.put(Galaxy::Sessions, &mem)?;
210 super::common::index_memory(search, &mem);
211 let episodic_capture_error = crate::capture_explicit_memory(
212 store,
213 &mem,
214 EpisodicKind::SystemEvent,
215 ProvenanceSource::System,
216 uuid::Uuid::parse_str(session_id).ok(),
217 0,
218 );
219 let mut response = json!({
220 "status": "success",
221 "checkpoint_id": mem.metadata.id,
222 "session_id": session_id,
223 "label": label,
224 "handoff": handoff,
225 });
226 if let Some(error) = episodic_capture_error {
227 response["warnings"] = json!([format!(
228 "episodic capture failed after the checkpoint was stored: {error}"
229 )]);
230 }
231 Ok(response)
232}
233
234pub struct SessionCheckpointTool {
236 store: Arc<MemoryStore>,
237 stats: ToolStats,
238 effects: EffectRow,
239 search: Option<Arc<wm_memory::SearchEngine>>,
240}
241
242impl SessionCheckpointTool {
243 pub fn new(store: Arc<MemoryStore>) -> Self {
244 Self {
245 store,
246 stats: ToolStats::default(),
247 effects: EffectRow {
255 reads: vec![Resource::Filesystem, Resource::Process],
256 writes: vec![Resource::Galaxy("sessions".into())],
257 spawns: true,
258 ..Default::default()
259 },
260 search: None,
261 }
262 }
263
264 #[must_use]
268 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
269 self.search = search;
270 self
271 }
272}
273
274#[async_trait]
275impl Tool for SessionCheckpointTool {
276 fn name(&self) -> &str {
277 "session.checkpoint"
278 }
279 fn gana(&self) -> Gana {
280 Gana::StraddlingLegs
281 }
282 fn effects(&self) -> &EffectRow {
283 &self.effects
284 }
285 fn input_schema(&self) -> Value {
286 super::common::schema(
287 &json!({
288 "session_id": super::common::str_prop("Target session (default: most recent session)"),
289 "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
290 "data": {
291 "type": "object",
292 "description": "Legacy free-form passthrough stored beside the handoff."
293 },
294 "commit": super::common::str_prop("Manual commit hash (auto-captured from git when root/WM_PROJECT_ROOT is set)"),
295 "branch": super::common::str_prop("Manual branch name (auto-captured when git is available)"),
296 "tests_green": {
297 "type": "boolean",
298 "description": "Whether the test suite was green at checkpoint time."
299 },
300 "next_queue": {
301 "type": "array",
302 "description": "Ordered next-step strings for the next session."
303 },
304 "open_flags": {
305 "type": "array",
306 "description": "Open concerns/flags worth surfacing on resume."
307 },
308 "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
309 "root": super::common::str_prop("Repository root for auto git-capture (default: WM_PROJECT_ROOT env)"),
310 }),
311 &[],
312 )
313 }
314 fn description(&self) -> &str {
315 "Save a session checkpoint with a verifiable structured handoff: commit, branch, dirty count (auto-captured from git via WM_PROJECT_ROOT), tests_green, next_queue, open_flags, lease_id (a code.claim scope that stays held)."
316 }
317 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
318 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
319 Some(sid) if !sid.is_empty() => sid.to_string(),
320 _ => latest_session_start(&self.store).ok_or_else(|| {
321 wm_core::CoreError::Tool("no session found — run session.start first".into())
322 })?,
323 };
324 let label = args
325 .get("label")
326 .and_then(|v| v.as_str())
327 .unwrap_or("checkpoint");
328 let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
329
330 let git_state = resolve_project_root(&args).and_then(|root| capture_git_state(&root));
333 let mut handoff = json!({});
334 {
335 let h = handoff.as_object_mut().expect("just created");
336 for (key, value) in [
337 ("commit", args.get("commit")),
338 ("branch", args.get("branch")),
339 ("tests_green", args.get("tests_green")),
340 ("next_queue", args.get("next_queue")),
341 ("open_flags", args.get("open_flags")),
342 ("lease_id", args.get("lease_id")),
343 ] {
344 if value.is_some() {
345 h.insert(key.to_string(), value.cloned().expect("checked above"));
346 }
347 }
348 if let Some(git) = git_state {
349 h.insert("git".to_string(), git);
350 }
351 }
352
353 store_checkpoint_record(
354 &self.store,
355 self.search.as_deref(),
356 &session_id,
357 label,
358 &data,
359 &handoff,
360 )
361 }
362 fn stats(&self) -> &ToolStats {
363 &self.stats
364 }
365}
366
367pub struct SessionCheckpointNodiscoveryTool {
377 store: Arc<MemoryStore>,
378 stats: ToolStats,
379 effects: EffectRow,
380 search: Option<Arc<wm_memory::SearchEngine>>,
381}
382
383impl SessionCheckpointNodiscoveryTool {
384 pub fn new(store: Arc<MemoryStore>) -> Self {
385 Self {
386 store,
387 stats: ToolStats::default(),
388 effects: EffectRow {
391 writes: vec![Resource::Galaxy("sessions".into())],
392 ..Default::default()
393 },
394 search: None,
395 }
396 }
397
398 #[must_use]
400 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
401 self.search = search;
402 self
403 }
404}
405
406#[async_trait]
407impl Tool for SessionCheckpointNodiscoveryTool {
408 fn name(&self) -> &str {
409 "session.checkpoint_nodiscovery"
410 }
411 fn gana(&self) -> Gana {
412 Gana::StraddlingLegs
413 }
414 fn effects(&self) -> &EffectRow {
415 &self.effects
416 }
417 fn input_schema(&self) -> Value {
418 super::common::schema(
419 &json!({
420 "session_id": super::common::str_prop("Target session (default: most recent session)"),
421 "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
422 "commit": super::common::str_prop("Caller-supplied commit hash (no discovery is performed)"),
423 "branch": super::common::str_prop("Caller-supplied branch name"),
424 "tests_green": {
425 "type": "boolean",
426 "description": "Whether the test suite was green at checkpoint time."
427 },
428 "next_queue": {
429 "type": "array",
430 "description": "Ordered next-step strings for the next session."
431 },
432 "open_flags": {
433 "type": "array",
434 "description": "Open concerns/flags worth surfacing on resume."
435 },
436 "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
437 "data": {
438 "type": "object",
439 "description": "Legacy free-form passthrough stored beside the handoff."
440 },
441 }),
442 &[],
443 )
444 }
445 fn description(&self) -> &str {
446 "Store a no-discovery session checkpoint: exactly the supplied handoff fields — no repository discovery, no filesystem read, no subprocess. Available under AHIMSA strict mode; git auto-capture is deliberately absent (use session.checkpoint when git capture is wanted and allowed)."
447 }
448 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
449 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
450 Some(sid) if !sid.is_empty() => sid.to_string(),
451 _ => latest_session_start(&self.store).ok_or_else(|| {
452 wm_core::CoreError::Tool("no session found — run session.start first".into())
453 })?,
454 };
455 let label = args
456 .get("label")
457 .and_then(|v| v.as_str())
458 .unwrap_or("checkpoint");
459 let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
460
461 let mut handoff = json!({});
464 {
465 let h = handoff.as_object_mut().expect("just created");
466 for (key, value) in [
467 ("commit", args.get("commit")),
468 ("branch", args.get("branch")),
469 ("tests_green", args.get("tests_green")),
470 ("next_queue", args.get("next_queue")),
471 ("open_flags", args.get("open_flags")),
472 ("lease_id", args.get("lease_id")),
473 ] {
474 if value.is_some() {
475 h.insert(key.to_string(), value.cloned().expect("checked above"));
476 }
477 }
478 }
479
480 store_checkpoint_record(
481 &self.store,
482 self.search.as_deref(),
483 &session_id,
484 label,
485 &data,
486 &handoff,
487 )
488 }
489 fn stats(&self) -> &ToolStats {
490 &self.stats
491 }
492}
493
494pub struct SessionVerifyTool {
501 store: Arc<MemoryStore>,
502 stats: ToolStats,
503 effects: EffectRow,
504}
505
506impl SessionVerifyTool {
507 pub fn new(store: Arc<MemoryStore>) -> Self {
508 Self {
509 store,
510 stats: ToolStats::default(),
511 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
512 }
513 }
514}
515
516#[async_trait]
517impl Tool for SessionVerifyTool {
518 fn name(&self) -> &str {
519 "session.verify"
520 }
521 fn gana(&self) -> Gana {
522 Gana::StraddlingLegs
523 }
524 fn effects(&self) -> &EffectRow {
525 &self.effects
526 }
527 fn input_schema(&self) -> Value {
528 super::common::schema(
529 &json!({
530 "session_id": super::common::str_prop("Session whose latest checkpoint to verify (default: most recent session)"),
531 "root": super::common::str_prop("Repository root to verify against (default: WM_PROJECT_ROOT env)"),
532 }),
533 &[],
534 )
535 }
536 fn description(&self) -> &str {
537 "Verify a session's stored checkpoint against live git state — reports commit drift and dirty-count delta ('your memory says HEAD was X; git says Y')."
538 }
539 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
540 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
541 Some(sid) if !sid.is_empty() => sid.to_string(),
542 _ => latest_session_start(&self.store).ok_or_else(|| {
543 wm_core::CoreError::Tool("no session found — run session.start first".into())
544 })?,
545 };
546
547 let memories = self.store.scan_all(Galaxy::Sessions)?;
550 let stored = memories
551 .iter()
552 .filter(|m| {
553 m.metadata.tags.contains(&"checkpoint".to_string())
554 && m.content.contains(&session_id)
555 })
556 .filter_map(|m| {
557 let parsed: Value = serde_json::from_str(&m.content).ok()?;
558 let git = parsed.get("handoff")?.get("git")?.clone();
559 if git.get("commit").and_then(Value::as_str).is_some() {
560 Some((m.metadata.id.to_string(), m.metadata.created_at, git))
561 } else {
562 None
563 }
564 })
565 .max_by_key(|(_, created_at, _)| *created_at);
566
567 let Some((checkpoint_id, _, stored_git)) = stored else {
568 return Ok(json!({
569 "status": "success",
570 "verifiable": false,
571 "message": "no checkpoint with captured git state found for this session — checkpoint with WM_PROJECT_ROOT set (or an explicit root) to enable verification"
572 }));
573 };
574
575 let Some(root) = resolve_project_root(&args) else {
576 return Ok(json!({
577 "status": "error",
578 "checkpoint_id": checkpoint_id,
579 "stored_git": stored_git,
580 "message": "no repository root available — pass 'root' or set WM_PROJECT_ROOT to verify against live git"
581 }));
582 };
583 let Some(current_git) = capture_git_state(&root) else {
584 return Ok(json!({
585 "status": "error",
586 "checkpoint_id": checkpoint_id,
587 "stored_git": stored_git,
588 "message": format!("'{}' is not a usable git work tree", root.display())
589 }));
590 };
591
592 let stored_commit = stored_git["commit"].as_str().unwrap_or_default();
593 let current_commit = current_git["commit"].as_str().unwrap_or_default();
594 let commits_ahead = if stored_commit == current_commit {
595 Some(0)
596 } else {
597 std::process::Command::new("git")
598 .args(["rev-list", "--count", &format!("{stored_commit}..HEAD")])
599 .current_dir(&root)
600 .output()
601 .ok()
602 .filter(|o| o.status.success())
603 .and_then(|o| {
604 String::from_utf8_lossy(&o.stdout)
605 .trim()
606 .parse::<u64>()
607 .ok()
608 })
609 };
610 let dirty_delta = current_git["dirty_count"].as_i64().unwrap_or(0)
611 - stored_git["dirty_count"].as_i64().unwrap_or(0);
612
613 let verdict = if stored_commit == current_commit && dirty_delta == 0 {
614 "clean"
615 } else if stored_commit == current_commit {
616 "dirty-drift"
617 } else {
618 "drifted"
619 };
620
621 Ok(json!({
622 "status": "success",
623 "verifiable": true,
624 "session_id": session_id,
625 "checkpoint_id": checkpoint_id,
626 "stored_git": stored_git,
627 "current_git": current_git,
628 "commits_ahead": commits_ahead,
629 "dirty_delta": dirty_delta,
630 "verdict": verdict,
631 }))
632 }
633 fn stats(&self) -> &ToolStats {
634 &self.stats
635 }
636}
637
638pub struct SessionRecallTool {
640 store: Arc<MemoryStore>,
641 stats: ToolStats,
642 effects: EffectRow,
643}
644
645impl SessionRecallTool {
646 pub fn new(store: Arc<MemoryStore>) -> Self {
647 Self {
648 store,
649 stats: ToolStats::default(),
650 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
651 }
652 }
653}
654
655#[async_trait]
656impl Tool for SessionRecallTool {
657 fn input_schema(&self) -> Value {
658 super::common::schema(
659 &json!({
660 "session_id": super::common::str_prop("Session UUID to recall (defaults to the most recent session)"),
661 "limit": super::common::int_prop("Maximum turns to return (default 50)"),
662 }),
663 &[],
664 )
665 }
666 fn name(&self) -> &str {
667 "session.recall"
668 }
669 fn gana(&self) -> Gana {
670 Gana::StraddlingLegs
671 }
672 fn effects(&self) -> &EffectRow {
673 &self.effects
674 }
675 fn description(&self) -> &str {
676 "Recall session memories by session_id"
677 }
678 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
679 let session_id = args
680 .get("session_id")
681 .and_then(|v| v.as_str())
682 .unwrap_or("");
683 let limit = args
684 .get("limit")
685 .and_then(serde_json::Value::as_u64)
686 .unwrap_or(50) as usize;
687 let memories = self.store.scan_all(Galaxy::Sessions)?;
688 let filtered: Vec<Value> = memories
689 .iter()
690 .filter(|m| m.content.contains(session_id))
691 .take(limit)
692 .map(|m| {
693 json!({
694 "id": m.metadata.id,
695 "content": m.content,
696 "tags": m.metadata.tags,
697 "created_at": m.metadata.created_at.to_rfc3339(),
698 })
699 })
700 .collect();
701 Ok(json!({
702 "status": "success",
703 "session_id": session_id,
704 "count": filtered.len(),
705 "memories": filtered,
706 }))
707 }
708 fn stats(&self) -> &ToolStats {
709 &self.stats
710 }
711}
712
713pub struct SessionEndTool {
715 store: Arc<MemoryStore>,
716 stats: ToolStats,
717 effects: EffectRow,
718 search: Option<Arc<wm_memory::SearchEngine>>,
719}
720
721impl SessionEndTool {
722 pub fn new(store: Arc<MemoryStore>) -> Self {
723 Self {
724 store,
725 stats: ToolStats::default(),
726 effects: EffectRow {
727 writes: vec![Resource::Galaxy("sessions".into())],
728 ..Default::default()
729 },
730 search: None,
731 }
732 }
733
734 #[must_use]
738 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
739 self.search = search;
740 self
741 }
742}
743
744#[async_trait]
745impl Tool for SessionEndTool {
746 fn name(&self) -> &str {
747 "session.end"
748 }
749 fn gana(&self) -> Gana {
750 Gana::StraddlingLegs
751 }
752 fn effects(&self) -> &EffectRow {
753 &self.effects
754 }
755 fn description(&self) -> &str {
756 "End a session — writes a session_end marker"
757 }
758 fn input_schema(&self) -> Value {
759 super::common::schema(
760 &json!({
761 "session_id": super::common::str_prop("Session UUID to end (start-memory id)"),
762 "summary": super::common::str_prop("Optional closing summary"),
763 }),
764 &["session_id"],
765 )
766 }
767 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
768 let session_id = args
769 .get("session_id")
770 .and_then(|v| v.as_str())
771 .unwrap_or("");
772 let summary = args.get("summary").and_then(|v| v.as_str()).unwrap_or("");
773 let mut mem = Memory::new(
774 Galaxy::Sessions,
775 json!({
776 "type": "session_end",
777 "session_id": session_id,
778 "summary": summary,
779 })
780 .to_string(),
781 );
782 mem.metadata.tags = vec!["session".into(), "end".into()];
783 mem.metadata.importance = 0.6;
784 mem.metadata.source = "system".to_string();
786 mem.metadata.source_trust = 0.7;
787 self.store.put(Galaxy::Sessions, &mem)?;
788 super::common::index_memory(self.search.as_deref(), &mem);
789 let episodic_capture_error = crate::capture_explicit_memory(
790 &self.store,
791 &mem,
792 EpisodicKind::SystemEvent,
793 ProvenanceSource::System,
794 uuid::Uuid::parse_str(session_id).ok(),
795 0,
796 );
797 let mut response = json!({
798 "status": "success",
799 "session_id": session_id,
800 "end_id": mem.metadata.id,
801 });
802 if let Some(error) = episodic_capture_error {
803 response["warnings"] = json!([format!(
804 "episodic capture failed after the session event was stored: {error}"
805 )]);
806 }
807 Ok(response)
808 }
809 fn stats(&self) -> &ToolStats {
810 &self.stats
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 fn test_store() -> Arc<MemoryStore> {
819 let dir = tempfile::tempdir().unwrap();
820 let path = dir.path().join("lmdb");
821 std::fs::create_dir_all(&path).unwrap();
822 Arc::new(MemoryStore::open_default(path).unwrap())
823 }
824
825 fn start_session(store: &MemoryStore) -> String {
826 let mut mem = Memory::new(
827 Galaxy::Sessions,
828 json!({"type": "session_start"}).to_string(),
829 );
830 mem.metadata.tags = vec!["session".into(), "start".into()];
831 store.put(Galaxy::Sessions, &mem).unwrap();
832 mem.metadata.id.to_string()
833 }
834
835 fn git_repo() -> (tempfile::TempDir, PathBuf) {
838 let dir = tempfile::tempdir().unwrap();
839 let root = dir.path().to_path_buf();
840 let run = |args: &[&str]| {
841 std::process::Command::new("git")
842 .args(args)
843 .current_dir(&root)
844 .output()
845 .expect("git must be available")
846 };
847 assert!(run(&["init", "-q"]).status.success());
848 assert!(run(&["config", "user.email", "t@t"]).status.success());
849 assert!(run(&["config", "user.name", "t"]).status.success());
850 assert!(
851 run(&["commit", "--allow-empty", "-m", "c1"])
852 .status
853 .success()
854 );
855 (dir, root)
856 }
857
858 #[tokio::test]
861 async fn start_marker_stamps_system_provenance() {
862 let store = test_store();
863 let tool = SessionStartTool::new(store.clone());
864 let mut ctx = Context::default();
865 let out = tool
866 .call(&mut ctx, json!({"title": "prov test"}))
867 .await
868 .unwrap();
869 let sid = uuid::Uuid::parse_str(out["session_id"].as_str().unwrap()).unwrap();
870 let mem = store
871 .get(Galaxy::Sessions, sid)
872 .expect("start stored")
873 .expect("start present");
874 assert_eq!(mem.metadata.source, "system");
875 assert!((mem.metadata.source_trust - 0.7).abs() < 1e-5);
876 }
877
878 #[tokio::test]
879 async fn checkpoint_auto_captures_git_state() {
880 let store = test_store();
881 let sid = start_session(&store);
882 let (_guard, root) = git_repo();
883
884 let tool = SessionCheckpointTool::new(store.clone());
885 let mut ctx = Context::default();
886 let r = tool
887 .call(
888 &mut ctx,
889 json!({"session_id": sid, "root": root.display().to_string(), "tests_green": true}),
890 )
891 .await
892 .unwrap();
893
894 assert_eq!(r["status"], "success");
895 let git = &r["handoff"]["git"];
896 let expected = String::from_utf8(
897 std::process::Command::new("git")
898 .args(["rev-parse", "HEAD"])
899 .current_dir(&root)
900 .output()
901 .unwrap()
902 .stdout,
903 )
904 .unwrap();
905 assert_eq!(
906 git["commit"].as_str().unwrap().trim(),
907 expected.trim(),
908 "checkpoint must auto-capture the live HEAD"
909 );
910 assert_eq!(git["dirty_count"], 0);
911 assert_eq!(r["handoff"]["tests_green"], true);
912 }
913
914 #[tokio::test]
915 async fn checkpoint_resolves_latest_session_when_absent() {
916 let store = test_store();
917 let _old = start_session(&store);
918 let newest = start_session(&store);
919
920 let tool = SessionCheckpointTool::new(store);
921 let mut ctx = Context::default();
922 let r = tool.call(&mut ctx, json!({"label": "wrap"})).await.unwrap();
923
924 assert_eq!(r["status"], "success");
925 assert_eq!(r["session_id"], newest, "must target the newest session");
926 }
927
928 #[tokio::test]
929 async fn verify_reports_clean_then_drifted() {
930 let store = test_store();
931 let sid = start_session(&store);
932 let (dir_guard, root) = git_repo();
933 let root_str = root.display().to_string();
934
935 let cp = SessionCheckpointTool::new(store.clone());
936 let mut ctx = Context::default();
937 cp.call(&mut ctx, json!({"session_id": sid, "root": root_str}))
938 .await
939 .unwrap();
940
941 let verify = SessionVerifyTool::new(store.clone());
942 let clean = verify
943 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
944 .await
945 .unwrap();
946 assert_eq!(clean["verifiable"], true);
947 assert_eq!(clean["verdict"], "clean", "got: {clean}");
948 assert_eq!(clean["commits_ahead"], 0);
949
950 assert!(
952 std::process::Command::new("git")
953 .args(["commit", "--allow-empty", "-m", "c2"])
954 .current_dir(&root)
955 .output()
956 .unwrap()
957 .status
958 .success()
959 );
960
961 let drifted = verify
962 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
963 .await
964 .unwrap();
965 assert_eq!(drifted["verdict"], "drifted", "got: {drifted}");
966 assert_eq!(drifted["commits_ahead"], 1);
967 assert_ne!(
968 drifted["stored_git"]["commit"],
969 drifted["current_git"]["commit"]
970 );
971
972 drop(dir_guard);
973 }
974
975 #[tokio::test]
976 async fn checkpoint_carries_lease_id_in_handoff() {
977 let store = test_store();
978 let sid = start_session(&store);
979
980 let tool = SessionCheckpointTool::new(store);
981 let mut ctx = Context::default();
982 let r = tool
983 .call(
984 &mut ctx,
985 json!({"session_id": sid, "lease_id": "src/expansion/"}),
986 )
987 .await
988 .unwrap();
989
990 assert_eq!(r["status"], "success");
991 assert_eq!(r["handoff"]["lease_id"], "src/expansion/");
992 }
993
994 #[tokio::test]
995 async fn nodiscovery_checkpoint_stores_exactly_the_supplied_fields() {
996 let store = test_store();
997 let sid = start_session(&store);
998
999 let tool = SessionCheckpointNodiscoveryTool::new(store.clone());
1001 assert!(tool.effects().reads.is_empty());
1002 assert!(!tool.effects().spawns);
1003 assert!(tool.effects().is_no_discovery_checkpoint());
1004
1005 let rich = SessionCheckpointTool::new(store.clone());
1007 assert!(rich.effects().spawns, "git capture must declare its spawns");
1008 assert!(rich.effects().reads.contains(&Resource::Filesystem));
1009 assert!(
1010 rich.effects().reads.contains(&Resource::Process),
1011 "spawning tools must declare the Process resource (effect audit)"
1012 );
1013
1014 let mut ctx = Context::default();
1015 let r = tool
1016 .call(
1017 &mut ctx,
1018 json!({
1019 "session_id": sid,
1020 "label": "stress",
1021 "commit": "abc1234",
1022 "branch": "main",
1023 "tests_green": true,
1024 "next_queue": ["finish A"],
1025 "open_flags": ["gate"],
1026 "lease_id": "scope/x"
1027 }),
1028 )
1029 .await
1030 .unwrap();
1031
1032 assert_eq!(r["status"], "success");
1033 assert_eq!(r["handoff"]["commit"], "abc1234");
1034 assert_eq!(r["handoff"]["branch"], "main");
1035 assert_eq!(r["handoff"]["tests_green"], true);
1036 assert_eq!(r["handoff"]["next_queue"][0], "finish A");
1037 assert_eq!(r["handoff"]["open_flags"][0], "gate");
1038 assert_eq!(r["handoff"]["lease_id"], "scope/x");
1039 assert!(
1040 r["handoff"].get("git").is_none(),
1041 "no-discovery checkpoint must never contain captured git state: {r}"
1042 );
1043 }
1044
1045 #[tokio::test]
1046 async fn verify_reports_unverifiable_without_git_checkpoint() {
1047 let store = test_store();
1048 let sid = start_session(&store);
1049
1050 let cp = SessionCheckpointTool::new(store.clone());
1052 let mut ctx = Context::default();
1053 let r = cp.call(&mut ctx, json!({"session_id": sid})).await.unwrap();
1055 assert!(r["handoff"]["git"].is_null());
1056
1057 let verify = SessionVerifyTool::new(store);
1058 let v = verify
1059 .call(&mut ctx, json!({"session_id": sid}))
1060 .await
1061 .unwrap();
1062 assert_eq!(v["verifiable"], false, "got: {v}");
1063 assert!(v["message"].as_str().unwrap().contains("no checkpoint"));
1064 }
1065}