@@ -14,7 +14,7 @@ use std::path::PathBuf;
1414#[ cfg( unix) ]
1515use std:: time:: Duration ;
1616
17- use serde:: Deserialize ;
17+ use serde:: { Deserialize , Serialize } ;
1818
1919#[ cfg( unix) ]
2020use crate :: coven_shared:: coven_home;
@@ -44,6 +44,16 @@ pub struct DaemonSession {
4444 pub project_root : String ,
4545}
4646
47+ /// Payload for creating a new Coven daemon session.
48+ #[ derive( Debug , Clone , Serialize ) ]
49+ pub struct CreateSessionRequest {
50+ pub familiar : String ,
51+ pub project_root : String ,
52+ pub harness : String ,
53+ pub title : String ,
54+ pub initial_message : String ,
55+ }
56+
4757// ---------------------------------------------------------------------------
4858// Raw JSON shapes (private — only used for deserialization)
4959// ---------------------------------------------------------------------------
@@ -124,18 +134,23 @@ impl DaemonClient {
124134 Ok ( stream)
125135 }
126136
127- /// Send a minimal HTTP/1.0 GET and return the body string.
137+ /// Send a minimal HTTP/1.0 request and return the body string.
128138 ///
129139 /// HTTP/1.0 is used so the server closes the connection after the
130140 /// response — no need to parse `Content-Length` or chunked encoding.
131- fn get ( & self , path : & str ) -> Option < String > {
141+ fn request ( & self , method : & str , path : & str , body : Option < & str > ) -> Option < String > {
132142 #[ cfg( unix) ]
133143 {
134144 let mut stream = self . connect ( ) . ok ( ) ?;
135- let request = format ! (
136- "GET {} HTTP/1.0\r \n Host: localhost\r \n Accept: application/json\r \n \r \n " ,
137- path
138- ) ;
145+ let request = match body {
146+ Some ( body) => format ! (
147+ "{method} {path} HTTP/1.0\r \n Host: localhost\r \n Accept: application/json\r \n Content-Type: application/json\r \n Content-Length: {}\r \n \r \n {body}" ,
148+ body. len( )
149+ ) ,
150+ None => format ! (
151+ "{method} {path} HTTP/1.0\r \n Host: localhost\r \n Accept: application/json\r \n \r \n "
152+ ) ,
153+ } ;
139154 stream. write_all ( request. as_bytes ( ) ) . ok ( ) ?;
140155 stream. flush ( ) . ok ( ) ?;
141156
@@ -159,11 +174,18 @@ impl DaemonClient {
159174 }
160175 #[ cfg( not( unix) ) ]
161176 {
177+ let _ = method;
162178 let _ = path;
179+ let _ = body;
163180 None
164181 }
165182 }
166183
184+ /// Send a minimal HTTP/1.0 GET and return the body string.
185+ fn get ( & self , path : & str ) -> Option < String > {
186+ self . request ( "GET" , path, None )
187+ }
188+
167189 // -- public API ---------------------------------------------------------
168190
169191 /// Quick liveness check — returns `true` if the daemon responds with 200.
@@ -214,6 +236,26 @@ impl DaemonClient {
214236 } )
215237 . collect ( )
216238 }
239+
240+ /// Create a daemon session and return its session id.
241+ pub fn create_session ( & self , req : CreateSessionRequest ) -> Result < String , String > {
242+ let body = serde_json:: to_string ( & req)
243+ . map_err ( |e| format ! ( "Failed to encode daemon session request: {e}" ) ) ?;
244+ let response = self
245+ . request ( "POST" , "/api/v1/sessions" , Some ( & body) )
246+ . ok_or_else ( || {
247+ "Coven daemon did not return a successful session response" . to_string ( )
248+ } ) ?;
249+ let value: serde_json:: Value = serde_json:: from_str ( & response)
250+ . map_err ( |e| format ! ( "Coven daemon returned invalid session JSON: {e}" ) ) ?;
251+ value
252+ . get ( "id" )
253+ . or_else ( || value. get ( "session_id" ) )
254+ . or_else ( || value. get ( "sessionId" ) )
255+ . and_then ( |id| id. as_str ( ) )
256+ . map ( |id| id. to_string ( ) )
257+ . ok_or_else ( || "Coven daemon response did not include a session id" . to_string ( ) )
258+ }
217259}
218260
219261// ---------------------------------------------------------------------------
@@ -249,7 +291,9 @@ mod tests {
249291
250292 #[ test]
251293 fn new_returns_none_when_sock_absent ( ) {
252- let _lock = COVEN_HOME_ENV_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
294+ let _lock = COVEN_HOME_ENV_LOCK
295+ . lock ( )
296+ . unwrap_or_else ( |e| e. into_inner ( ) ) ;
253297 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
254298 let _g = EnvGuard :: set ( "COVEN_HOME" , dir. path ( ) . to_str ( ) . unwrap ( ) ) ;
255299 // Directory exists but no coven.sock inside → should return None.
@@ -258,7 +302,9 @@ mod tests {
258302
259303 #[ test]
260304 fn new_returns_some_when_sock_present ( ) {
261- let _lock = COVEN_HOME_ENV_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
305+ let _lock = COVEN_HOME_ENV_LOCK
306+ . lock ( )
307+ . unwrap_or_else ( |e| e. into_inner ( ) ) ;
262308 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
263309 // Create a placeholder file (not a real socket, just needs to exist).
264310 fs:: write ( dir. path ( ) . join ( "coven.sock" ) , b"" ) . unwrap ( ) ;
@@ -290,7 +336,10 @@ mod tests {
290336 assert_eq ! ( raw. len( ) , 2 ) ;
291337
292338 let s0 = FamiliarStatus {
293- display_name : raw[ 0 ] . display_name . clone ( ) . unwrap_or_else ( || raw[ 0 ] . id . clone ( ) ) ,
339+ display_name : raw[ 0 ]
340+ . display_name
341+ . clone ( )
342+ . unwrap_or_else ( || raw[ 0 ] . id . clone ( ) ) ,
294343 emoji : raw[ 0 ] . emoji . clone ( ) . unwrap_or_default ( ) ,
295344 status : raw[ 0 ] . status . clone ( ) . unwrap_or_default ( ) ,
296345 active_sessions : raw[ 0 ] . active_sessions . unwrap_or ( 0 ) ,
@@ -304,7 +353,10 @@ mod tests {
304353 assert_eq ! ( s0. active_sessions, 2 ) ;
305354
306355 let s1 = FamiliarStatus {
307- display_name : raw[ 1 ] . display_name . clone ( ) . unwrap_or_else ( || raw[ 1 ] . id . clone ( ) ) ,
356+ display_name : raw[ 1 ]
357+ . display_name
358+ . clone ( )
359+ . unwrap_or_else ( || raw[ 1 ] . id . clone ( ) ) ,
308360 emoji : raw[ 1 ] . emoji . clone ( ) . unwrap_or_default ( ) ,
309361 status : raw[ 1 ] . status . clone ( ) . unwrap_or_default ( ) ,
310362 active_sessions : raw[ 1 ] . active_sessions . unwrap_or ( 0 ) ,
@@ -318,7 +370,9 @@ mod tests {
318370
319371 #[ test]
320372 fn familiar_statuses_returns_empty_on_bad_json ( ) {
321- let _lock = COVEN_HOME_ENV_LOCK . lock ( ) . unwrap_or_else ( |e| e. into_inner ( ) ) ;
373+ let _lock = COVEN_HOME_ENV_LOCK
374+ . lock ( )
375+ . unwrap_or_else ( |e| e. into_inner ( ) ) ;
322376 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
323377 // Placeholder sock — not a real socket, so connect() will fail.
324378 fs:: write ( dir. path ( ) . join ( "coven.sock" ) , b"" ) . unwrap ( ) ;
@@ -327,4 +381,44 @@ mod tests {
327381 // connect() will fail → familiar_statuses() must return empty vec, not panic.
328382 assert ! ( client. familiar_statuses( ) . is_empty( ) ) ;
329383 }
384+
385+ #[ cfg( unix) ]
386+ #[ test]
387+ fn create_session_posts_payload_and_returns_session_id ( ) {
388+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
389+ let sock = dir. path ( ) . join ( "coven.sock" ) ;
390+ let listener = std:: os:: unix:: net:: UnixListener :: bind ( & sock) . unwrap ( ) ;
391+
392+ let server = std:: thread:: spawn ( move || {
393+ let ( mut stream, _) = listener. accept ( ) . unwrap ( ) ;
394+ let mut buf = [ 0_u8 ; 4096 ] ;
395+ let n = stream. read ( & mut buf) . unwrap ( ) ;
396+ let request = String :: from_utf8_lossy ( & buf[ ..n] ) ;
397+ assert ! ( request. starts_with( "POST /api/v1/sessions HTTP/1.0" ) ) ;
398+ assert ! ( request. contains( "Host: localhost\r \n " ) ) ;
399+ assert ! ( request. contains( "Content-Type: application/json\r \n " ) ) ;
400+ assert ! ( request. contains( "\" familiar\" :\" sage\" " ) ) ;
401+ assert ! ( request. contains( "\" project_root\" :\" /tmp/project\" " ) ) ;
402+ assert ! ( request. contains( "\" initial_message\" :\" handoff context\" " ) ) ;
403+ stream
404+ . write_all (
405+ b"HTTP/1.0 201 Created\r \n Content-Type: application/json\r \n \r \n {\" id\" :\" sess_123\" }" ,
406+ )
407+ . unwrap ( ) ;
408+ } ) ;
409+
410+ let client = DaemonClient { sock_path : sock } ;
411+ let session_id = client
412+ . create_session ( CreateSessionRequest {
413+ familiar : "sage" . to_string ( ) ,
414+ project_root : "/tmp/project" . to_string ( ) ,
415+ harness : "openclaw" . to_string ( ) ,
416+ title : "Handoff from coven-code" . to_string ( ) ,
417+ initial_message : "handoff context" . to_string ( ) ,
418+ } )
419+ . unwrap ( ) ;
420+
421+ server. join ( ) . unwrap ( ) ;
422+ assert_eq ! ( session_id, "sess_123" ) ;
423+ }
330424}
0 commit comments