Skip to content

Commit a42397f

Browse files
authored
feat(runtimed): create new notebook when opening non-existent path (#685)
When `runt notebook newfile.ipynb` is called with a path that doesn't exist, the daemon now creates a new empty notebook at that path instead of failing. This matches the expected behavior of CLI tools like vim. - Check file existence before canonicalizing path - For non-existent files: use absolute path for notebook_id, create empty notebook in doc - For permission/IO errors: return error to client instead of silently failing
1 parent a6cb454 commit a42397f

File tree

3 files changed

+156
-23
lines changed

3 files changed

+156
-23
lines changed

crates/runtimed/src/daemon.rs

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

crates/runtimed/src/notebook_sync_server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,9 @@ pub async fn handle_notebook_sync_connection<R, W>(
675675
initial_metadata: Option<String>,
676676
skip_capabilities: bool,
677677
needs_load: Option<PathBuf>,
678+
// True if this is a newly-created notebook at a non-existent path.
679+
// Used to enable auto-launch for notebooks created via `runt notebook newfile.ipynb`.
680+
created_new_at_path: bool,
678681
) -> anyhow::Result<()>
679682
where
680683
R: AsyncRead + Unpin,
@@ -731,7 +734,8 @@ where
731734
)
732735
// For existing files: trust must be verified (Trusted or NoDependencies)
733736
// For new notebooks (UUID, no file): NoDependencies is safe to auto-launch
734-
&& (room.notebook_path.exists() || is_new_notebook);
737+
// For newly-created notebooks at a path: also safe to auto-launch
738+
&& (room.notebook_path.exists() || is_new_notebook || created_new_at_path);
735739
(should_launch, status)
736740
};
737741

python/runtimed/tests/test_daemon_integration.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,14 +2572,38 @@ def test_open_notebook_returns_connection_info(
25722572
assert info.cell_count == 0
25732573
assert info.notebook_id == session.notebook_id
25742574

2575-
def test_open_nonexistent_file_errors(self, daemon_process, monkeypatch, tmp_path):
2576-
"""Opening missing file returns error."""
2575+
def test_open_nonexistent_file_creates_notebook(self, daemon_process, monkeypatch, tmp_path):
2576+
"""Opening missing file creates a new notebook at that path."""
25772577
socket_path, _ = daemon_process
25782578
if socket_path is not None:
25792579
monkeypatch.setenv("RUNTIMED_SOCKET_PATH", str(socket_path))
25802580

2581-
with pytest.raises(runtimed.RuntimedError):
2582-
runtimed.Session.open_notebook(str(tmp_path / "missing.ipynb"))
2581+
# Opening a non-existent path creates a new notebook
2582+
session = runtimed.Session.open_notebook(str(tmp_path / "new_notebook.ipynb"))
2583+
try:
2584+
info = session.connection_info
2585+
# Notebook is created with the path as notebook_id
2586+
assert "new_notebook.ipynb" in info.notebook_id
2587+
# New notebook starts with cells (one empty code cell)
2588+
# Note: cell_count in handshake may be 0 due to streaming, but notebook_id is set
2589+
assert info.notebook_id != ""
2590+
finally:
2591+
session.close()
2592+
2593+
def test_open_nonexistent_file_auto_appends_ipynb(self, daemon_process, monkeypatch, tmp_path):
2594+
"""Opening missing file without .ipynb extension auto-appends it."""
2595+
socket_path, _ = daemon_process
2596+
if socket_path is not None:
2597+
monkeypatch.setenv("RUNTIMED_SOCKET_PATH", str(socket_path))
2598+
2599+
# Opening a path without .ipynb extension creates notebook with .ipynb appended
2600+
session = runtimed.Session.open_notebook(str(tmp_path / "mynotebook"))
2601+
try:
2602+
info = session.connection_info
2603+
# The .ipynb extension is auto-appended
2604+
assert info.notebook_id.endswith("mynotebook.ipynb")
2605+
finally:
2606+
session.close()
25832607

25842608
@pytest.mark.skipif(
25852609
os.environ.get("RUNTIMED_INTEGRATION_TEST") == "1",

0 commit comments

Comments
 (0)