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