1use std::path::PathBuf;
29use std::sync::{Arc, Mutex as StdMutex};
30
31use codewhale_protocol::event_msg::EventMsg;
32use codewhale_protocol::ids::{SessionId, ThreadId};
33use codewhale_protocol::op::{Op, OpEnvelope};
34use codewhale_state::StateStore;
35use tokio::sync::mpsc;
36
37use crate::ids::ThreadId as CoreThreadId;
38use crate::journal::Journal;
39use crate::session::{Session, Thread};
40
41pub mod thread;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CancelReason {
51 User,
52 External,
53 Preempted,
54 Internal,
55}
56
57#[derive(Clone)]
63pub struct EngineHandle {
64 pub tx_op: mpsc::Sender<OpEnvelope>,
65 pub rx_event: Arc<tokio::sync::RwLock<mpsc::Receiver<EventMsg>>>,
66 cancel_token: Arc<StdMutex<tokio_util::sync::CancellationToken>>,
67}
68
69impl EngineHandle {
70 pub async fn send(&self, op: OpEnvelope) -> anyhow::Result<()> {
71 self.tx_op
72 .send(op)
73 .await
74 .map_err(|e| anyhow::anyhow!("{e}"))?;
75 Ok(())
76 }
77
78 pub fn cancel(&self) {
79 self.cancel_with_reason(CancelReason::User);
80 }
81
82 pub fn cancel_with_reason(&self, _reason: CancelReason) {
83 if let Ok(token) = self.cancel_token.lock() {
84 token.cancel();
85 }
86 }
87
88 pub async fn steer(
89 &self,
90 thread_id: ThreadId,
91 content: impl Into<String>,
92 ) -> anyhow::Result<()> {
93 let env = OpEnvelope {
94 op_id: format!("op-{}", uuid::Uuid::new_v4()),
95 thread_id,
96 session_id: SessionId::new(),
97 op: Op::Steer {
98 content: content.into(),
99 },
100 };
101 self.tx_op
102 .send(env)
103 .await
104 .map_err(|e| anyhow::anyhow!("{e}"))?;
105 Ok(())
106 }
107}
108
109#[derive(Debug, Clone)]
117pub struct EngineConfig {
118 pub workspace: PathBuf,
119 pub model: String,
120 pub model_provider: String,
121 pub thread_id: ThreadId,
122 pub session_id: SessionId,
123 pub max_steps: u32,
124}
125
126impl Default for EngineConfig {
127 fn default() -> Self {
128 Self {
129 workspace: PathBuf::from("."),
130 model: "deepseek-v4-flash".to_string(),
131 model_provider: "deepseek".to_string(),
132 thread_id: ThreadId::new(),
133 session_id: SessionId::new(),
134 max_steps: 32,
135 }
136 }
137}
138
139pub struct Engine {
144 rx_op: mpsc::Receiver<OpEnvelope>,
145 tx_event: mpsc::Sender<EventMsg>,
146 journal: Journal,
147 session: Session,
148 thread: Thread,
149}
150
151const ENGINE_OP_CHANNEL_CAPACITY: usize = 32;
152const ENGINE_EVENT_CHANNEL_CAPACITY: usize = 128;
153
154impl Engine {
155 #[must_use]
156 pub fn new(config: EngineConfig, _state: StateStore) -> (Self, EngineHandle) {
157 let (tx_op, rx_op) = mpsc::channel(ENGINE_OP_CHANNEL_CAPACITY);
158 let (tx_event, rx_event) = mpsc::channel(ENGINE_EVENT_CHANNEL_CAPACITY);
159 let thread = Thread::new(
160 CoreThreadId::from_string(config.thread_id.as_str().to_string()),
161 config.workspace.clone(),
162 config.model.clone(),
163 );
164 let session = Session::new(
165 CoreThreadId::from_string(config.thread_id.as_str().to_string()),
166 config.workspace.clone(),
167 config.model.clone(),
168 );
169 let handle = EngineHandle {
170 tx_op,
171 rx_event: Arc::new(tokio::sync::RwLock::new(rx_event)),
172 cancel_token: Arc::new(StdMutex::new(tokio_util::sync::CancellationToken::new())),
173 };
174 let engine = Self {
175 rx_op,
176 tx_event,
177 journal: Journal::new(),
178 session,
179 thread,
180 };
181 (engine, handle)
182 }
183
184 pub async fn run(mut self) {
190 while let Some(env) = self.rx_op.recv().await {
191 let _ = self
192 .tx_event
193 .send(EventMsg::TurnStarted {
194 thread_id: env.thread_id.clone(),
195 session_id: env.session_id.clone(),
196 turn_id: format!("turn-{}", uuid::Uuid::new_v4()),
197 })
198 .await;
199
200 match env.op {
201 Op::SendMessage { content, .. } => {
202 self.journal.append("user", serde_json::json!(content));
204 self.thread.leaf_id = self.journal.leaf_id.clone();
205 self.session.bump_revision();
206 let turn_id = format!("turn-{}", uuid::Uuid::new_v4());
207 let _ = self
208 .tx_event
209 .send(EventMsg::TurnComplete {
210 thread_id: env.thread_id.clone(),
211 session_id: env.session_id.clone(),
212 turn_id,
213 status: "completed".to_string(),
214 error: None,
215 })
216 .await;
217 }
218 Op::Steer { content } => {
219 self.journal.append("user", serde_json::json!(content));
220 self.thread.leaf_id = self.journal.leaf_id.clone();
221 }
222 Op::Shutdown | Op::Cancel => break,
223 _ => {}
224 }
225 }
226 }
227}
228
229pub fn spawn_engine(config: EngineConfig, state: StateStore) -> EngineHandle {
234 let (engine, handle) = Engine::new(config, state);
235 let handle_clone = handle.clone();
236 tokio::spawn(async move {
237 engine.run().await;
238 });
239 handle_clone
240}
241
242pub fn spawn_supervised(config: EngineConfig, state: StateStore) -> EngineHandle {
244 spawn_engine(config, state)
245}
246
247pub fn spawn_headless_thread(
258 workspace: PathBuf,
259 model: impl Into<String>,
260 state: StateStore,
261) -> (EngineHandle, ThreadId, SessionId) {
262 let thread_id = ThreadId::new();
263 let session_id = SessionId::new();
264 let config = EngineConfig {
265 workspace,
266 model: model.into(),
267 model_provider: "deepseek".to_string(),
268 thread_id: thread_id.clone(),
269 session_id: session_id.clone(),
270 max_steps: 32,
271 };
272 let handle = spawn_engine(config, state);
273 (handle, thread_id, session_id)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use codewhale_state::StateStore;
280
281 #[tokio::test]
282 async fn headless_session_can_be_started_with_no_tui() {
283 let dir = tempfile::tempdir().unwrap();
284 let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
285 let (handle, thread_id, _session_id) =
286 spawn_headless_thread(dir.path().to_path_buf(), "deepseek-v4-flash", state);
287 let env = OpEnvelope {
289 op_id: "op-1".into(),
290 thread_id: thread_id.clone(),
291 session_id: SessionId::new(),
292 op: Op::SendMessage {
293 content: "hello".into(),
294 mode: "agent".into(),
295 model: None,
296 model_provider: None,
297 allowed_tools: None,
298 dynamic_tools: vec![],
299 provenance: "external_user".into(),
300 },
301 };
302 handle.send(env).await.unwrap();
303 drop(handle);
305 }
306}