1use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use nexus_core::{CognitiveLevel, CognitiveMetadata, MemoryCategory};
7use nexus_storage::repository::{MemoryRepository, NamespaceRepository, StoreMemoryParams};
8use nexus_storage::StorageManager;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11
12use crate::error::AgentError;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum RuntimeMode {
18 SessionScoped,
19 Persistent,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RuntimeShutdownReason {
24 SessionEnded,
25 IdleTimeout,
26 Manual,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub(crate) struct RuntimeState {
33 pub agent_type: String,
34 pub session_key: String,
35 pub mode: RuntimeModeSerde,
36 pub started_at: DateTime<Utc>,
37 pub updated_at: DateTime<Utc>,
38}
39
40pub(crate) struct RuntimeMarker<'a> {
41 pub agent_type: &'a str,
42 pub session_key: Option<&'a str>,
43 pub cwd: Option<&'a str>,
44 pub event: &'a str,
45 pub detail: &'a str,
46 pub agent_namespace: &'a str,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub(crate) enum RuntimeModeSerde {
52 SessionScoped,
53 Persistent,
54}
55
56impl From<RuntimeMode> for RuntimeModeSerde {
57 fn from(value: RuntimeMode) -> Self {
58 match value {
59 RuntimeMode::SessionScoped => Self::SessionScoped,
60 RuntimeMode::Persistent => Self::Persistent,
61 }
62 }
63}
64
65pub(crate) fn state_root() -> PathBuf {
68 if let Some(dir) = dirs::state_dir() {
69 dir.join("nexus-memory-system").join("runtime")
70 } else {
71 std::env::var("HOME")
72 .map(|h| PathBuf::from(h).join(".local/state/nexus-memory-system/runtime"))
73 .unwrap_or_else(|_| PathBuf::from(".nexus-runtime"))
74 }
75}
76
77pub(crate) fn state_file_path(agent_type: &str, session_key: &str) -> Result<PathBuf, AgentError> {
78 let root = state_root().join("sessions");
79 std::fs::create_dir_all(&root)?;
80 Ok(root.join(format!(
81 "{}__{}.json",
82 sanitize_component(agent_type),
83 sanitize_component(session_key)
84 )))
85}
86
87pub(crate) fn read_runtime_state(path: &Path) -> Result<Option<RuntimeState>, AgentError> {
88 if !path.exists() {
89 return Ok(None);
90 }
91
92 let contents = std::fs::read_to_string(path)?;
93 let state =
94 serde_json::from_str(&contents).map_err(|e| AgentError::Supervisor(e.to_string()))?;
95 Ok(Some(state))
96}
97
98pub(crate) fn write_runtime_state(path: &Path, state: &RuntimeState) -> Result<(), AgentError> {
99 let contents =
100 serde_json::to_string_pretty(state).map_err(|e| AgentError::Supervisor(e.to_string()))?;
101 std::fs::write(path, contents)?;
102 Ok(())
103}
104
105pub fn derive_session_key(
108 agent_type: &str,
109 session_key: Option<&str>,
110 cwd: Option<&str>,
111) -> String {
112 if let Some(value) = session_key.filter(|value| !value.trim().is_empty()) {
113 return value.to_string();
114 }
115
116 let canonical_agent = nexus_core::canonicalize_agent_type(agent_type);
117 let fallback_scope = cwd
118 .filter(|value| !value.trim().is_empty())
119 .map(nexus_core::normalize_project_path)
120 .unwrap_or_else(|| "unknown-cwd".to_string());
121 format!(
122 "derived-{}-{}",
123 sanitize_component(&canonical_agent),
124 sanitize_component(&fallback_scope)
125 )
126}
127
128pub(crate) fn sanitize_component(value: &str) -> String {
129 value
130 .chars()
131 .map(|ch| {
132 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
133 ch
134 } else {
135 '_'
136 }
137 })
138 .collect()
139}
140
141pub(crate) fn runtime_reason_label(reason: RuntimeShutdownReason) -> &'static str {
142 match reason {
143 RuntimeShutdownReason::SessionEnded => "session-ended",
144 RuntimeShutdownReason::IdleTimeout => "idle-timeout",
145 RuntimeShutdownReason::Manual => "manual",
146 }
147}
148
149pub(crate) async fn namespace_id_for(
150 agent_type: &str,
151 storage: &StorageManager,
152) -> Result<i64, AgentError> {
153 let canonical = nexus_core::canonicalize_agent_type(agent_type);
154 let namespace_repo = NamespaceRepository::new(storage.pool().clone());
155 namespace_repo
156 .get_or_create(&canonical, &canonical)
157 .await
158 .map(|namespace| namespace.id)
159 .map_err(|error| AgentError::Storage(error.to_string()))
160}
161
162pub(crate) async fn store_runtime_marker(
163 memory_repo: &MemoryRepository,
164 namespace_id: i64,
165 marker: RuntimeMarker<'_>,
166) -> Result<(), AgentError> {
167 let session_tag = derive_session_key(marker.agent_type, marker.session_key, marker.cwd);
168 let content = format!(
169 "Runtime {} for {} [session:{}] ({})",
170 marker.event.replace('_', " "),
171 marker.agent_type,
172 session_tag,
173 marker.detail
174 );
175 let metadata = json!({
176 "runtime": {
177 "event": marker.event,
178 "detail": marker.detail,
179 "session_key": marker.session_key,
180 "derived_session_key": session_tag,
181 "cwd": marker.cwd,
182 "agent_type": marker.agent_type,
183 "agent_namespace": marker.agent_namespace,
184 "captured_at": Utc::now(),
185 }
186 });
187 let mut cognitive = CognitiveMetadata::new(
188 CognitiveLevel::Explicit,
189 marker.agent_type,
190 marker.agent_type,
191 Some(session_tag.clone()),
192 "runtime_controller",
193 );
194 cognitive.confidence = Some(1.0);
195 let metadata = cognitive.merge_into(&metadata);
196
197 memory_repo
198 .store(StoreMemoryParams {
199 namespace_id,
200 content: &content,
201 category: &MemoryCategory::Session,
202 memory_lane_type: None,
203 labels: &[
204 "runtime".to_string(),
205 "session".to_string(),
206 marker.event.to_string(),
207 ],
208 metadata: &metadata,
209 embedding: None,
210 embedding_model: None,
211 })
212 .await
213 .map_err(|e| AgentError::Storage(e.to_string()))?;
214
215 Ok(())
216}