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