@@ -953,6 +953,7 @@ impl Daemon {
953953 initial_metadata,
954954 false , // Send ProtocolCapabilities for legacy NotebookSync handshake
955955 None , // No streaming load for legacy handshake
956+ false , // Not a newly-created notebook at path
956957 )
957958 . await
958959 }
@@ -973,6 +974,7 @@ impl Daemon {
973974 /// Handle an OpenNotebook connection.
974975 ///
975976 /// Daemon loads the .ipynb file, derives notebook_id, creates room, populates doc.
977+ /// If the file doesn't exist, creates a new empty notebook at that path.
976978 /// Returns NotebookConnectionInfo, then continues as normal notebook sync.
977979 async fn handle_open_notebook < S > ( self : Arc < Self > , stream : S , path : String ) -> anyhow:: Result < ( ) >
978980 where
@@ -984,13 +986,82 @@ impl Daemon {
984986
985987 info ! ( "[runtimed] OpenNotebook requested for {}" , path) ;
986988
987- // Canonicalize path to derive notebook_id (stable across processes)
988- let path_buf = std:: path:: PathBuf :: from ( & path) ;
989- let notebook_id = path_buf
990- . canonicalize ( )
991- . unwrap_or_else ( |_| path_buf. clone ( ) )
992- . to_string_lossy ( )
993- . to_string ( ) ;
989+ // Helper to send error response to client
990+ async fn send_error_response < W : AsyncWrite + Unpin > (
991+ writer : & mut W ,
992+ error : String ,
993+ ) -> anyhow:: Result < ( ) > {
994+ let response = NotebookConnectionInfo {
995+ protocol : PROTOCOL_V2 . to_string ( ) ,
996+ protocol_version : Some ( PROTOCOL_VERSION ) ,
997+ daemon_version : Some ( crate :: daemon_version ( ) . to_string ( ) ) ,
998+ notebook_id : String :: new ( ) ,
999+ cell_count : 0 ,
1000+ needs_trust_approval : false ,
1001+ error : Some ( error) ,
1002+ } ;
1003+ send_json_frame ( writer, & response) . await ?;
1004+ Ok ( ( ) )
1005+ }
1006+
1007+ // Check if file exists before canonicalizing (canonicalize fails for non-existent paths)
1008+ let mut path_buf = std:: path:: PathBuf :: from ( & path) ;
1009+ let file_exists = match tokio:: fs:: metadata ( & path_buf) . await {
1010+ Ok ( _) => true ,
1011+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: NotFound => {
1012+ // For new files, ensure .ipynb extension
1013+ if path_buf. extension ( ) . is_none_or ( |ext| ext != "ipynb" ) {
1014+ let mut new_path = path_buf. as_os_str ( ) . to_owned ( ) ;
1015+ new_path. push ( ".ipynb" ) ;
1016+ path_buf = std:: path:: PathBuf :: from ( new_path) ;
1017+ info ! (
1018+ "[runtimed] File {} does not exist, will create new notebook at {}" ,
1019+ path,
1020+ path_buf. display( )
1021+ ) ;
1022+ } else {
1023+ info ! (
1024+ "[runtimed] File {} does not exist, will create new notebook" ,
1025+ path
1026+ ) ;
1027+ }
1028+ false
1029+ }
1030+ Err ( e) => {
1031+ // Permission denied, I/O error, etc. - return error to client
1032+ let ( _reader, mut writer) = tokio:: io:: split ( stream) ;
1033+ send_error_response (
1034+ & mut writer,
1035+ format ! ( "Cannot access notebook '{}': {}" , path, e) ,
1036+ )
1037+ . await ?;
1038+ return Ok ( ( ) ) ;
1039+ }
1040+ } ;
1041+
1042+ // Derive notebook_id from path
1043+ // For existing files: canonicalize for stable cross-process identity
1044+ // For new files: use absolute path (canonicalize would fail)
1045+ let notebook_id = if file_exists {
1046+ match path_buf. canonicalize ( ) {
1047+ Ok ( canonical) => canonical. to_string_lossy ( ) . to_string ( ) ,
1048+ Err ( e) => {
1049+ // Canonicalize failed even though file exists (permission/symlink issues)
1050+ let ( _reader, mut writer) = tokio:: io:: split ( stream) ;
1051+ send_error_response (
1052+ & mut writer,
1053+ format ! ( "Cannot resolve notebook path '{}': {}" , path, e) ,
1054+ )
1055+ . await ?;
1056+ return Ok ( ( ) ) ;
1057+ }
1058+ }
1059+ } else {
1060+ std:: path:: absolute ( & path_buf)
1061+ . unwrap_or_else ( |_| path_buf. clone ( ) )
1062+ . to_string_lossy ( )
1063+ . to_string ( )
1064+ } ;
9941065
9951066 // Get or create room for this notebook
9961067 let docs_dir = self . config . notebook_docs_dir . clone ( ) ;
@@ -1004,10 +1075,47 @@ impl Daemon {
10041075 )
10051076 } ;
10061077
1078+ // Get settings for sync and auto-launch (needed for both new and existing notebooks)
1079+ let settings = self . settings . read ( ) . await . get_all ( ) ;
1080+ let default_runtime = settings. default_runtime ;
1081+ let default_python_env = settings. default_python_env ;
1082+
10071083 // Check whether this connection needs to stream-load the notebook
1008- // from disk. The actual load is deferred to the sync loop so cells
1009- // arrive progressively via Automerge sync messages.
1010- let ( cell_count, needs_load) = {
1084+ // from disk, or create a new empty notebook.
1085+ // Track if we created a new notebook at this path (for auto-launch logic)
1086+ let mut created_new_at_path = false ;
1087+ let ( cell_count, needs_load) = if !file_exists {
1088+ // File doesn't exist - create empty notebook in the doc
1089+ let mut doc = room. doc . write ( ) . await ;
1090+ if doc. cell_count ( ) == 0 {
1091+ match crate :: notebook_sync_server:: create_empty_notebook (
1092+ & mut doc,
1093+ & default_runtime. to_string ( ) ,
1094+ default_python_env. clone ( ) ,
1095+ Some ( & notebook_id) ,
1096+ ) {
1097+ Ok ( _cell_id) => {
1098+ info ! ( "[runtimed] Created new notebook at {}" , path) ;
1099+ created_new_at_path = true ;
1100+ }
1101+ Err ( e) => {
1102+ error ! (
1103+ "[runtimed] Failed to create new notebook at {}: {}" ,
1104+ path, e
1105+ ) ;
1106+ drop ( doc) ;
1107+ let ( _reader, mut writer) = tokio:: io:: split ( stream) ;
1108+ send_error_response (
1109+ & mut writer,
1110+ format ! ( "Failed to create notebook '{}': {}" , path, e) ,
1111+ )
1112+ . await ?;
1113+ return Ok ( ( ) ) ;
1114+ }
1115+ }
1116+ }
1117+ ( doc. cell_count ( ) , None ) // No streaming load needed
1118+ } else {
10111119 let doc = room. doc . read ( ) . await ;
10121120 let existing_count = doc. cell_count ( ) ;
10131121 if existing_count == 0 && !room. is_loading . load ( std:: sync:: atomic:: Ordering :: Acquire ) {
@@ -1053,11 +1161,6 @@ impl Daemon {
10531161 } ;
10541162 send_json_frame ( & mut writer, & response) . await ?;
10551163
1056- // Get settings for sync and auto-launch
1057- let settings = self . settings . read ( ) . await . get_all ( ) ;
1058- let default_runtime = settings. default_runtime ;
1059- let default_python_env = settings. default_python_env ;
1060-
10611164 // working_dir derived from path's parent directory
10621165 let working_dir_path = path_buf. parent ( ) . map ( |p| p. to_path_buf ( ) ) ;
10631166
@@ -1080,6 +1183,7 @@ impl Daemon {
10801183 None , // No initial_metadata - doc is already populated
10811184 true , // Skip ProtocolCapabilities - already sent in NotebookConnectionInfo
10821185 needs_load,
1186+ created_new_at_path, // Enable auto-launch for notebooks created at non-existent paths
10831187 )
10841188 . await
10851189 }
@@ -1206,9 +1310,10 @@ impl Daemon {
12061310 default_python_env,
12071311 self . clone ( ) ,
12081312 working_dir_path,
1209- None , // No initial_metadata - doc is already populated
1210- true , // Skip ProtocolCapabilities - already sent in NotebookConnectionInfo
1211- None , // No streaming load - doc was just created with empty cell
1313+ None , // No initial_metadata - doc is already populated
1314+ true , // Skip ProtocolCapabilities - already sent in NotebookConnectionInfo
1315+ None , // No streaming load - doc was just created with empty cell
1316+ false , // UUID-based new notebook, handled by is_new_notebook check
12121317 )
12131318 . await
12141319 }
0 commit comments