Skip to content

Commit 5b4b633

Browse files
committed
feat: add server status monitoring and global model list for settings
Add OpenCode server status/restart controls and a new settings_model_list endpoint that loads models from configured providers without requiring a connected workspace session. Fix model selection to use qualified IDs when multiple providers share the same model slug.
1 parent f75cdc7 commit 5b4b633

File tree

16 files changed

+830
-211
lines changed

16 files changed

+830
-211
lines changed

src-tauri/src/backend/app_server.rs

Lines changed: 132 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,57 @@ struct ServerProcess {
5555
base_url: String,
5656
}
5757

58+
fn rest_base_url() -> String {
59+
format!("http://127.0.0.1:{REST_PORT}")
60+
}
61+
62+
async fn start_managed_server_process(
63+
codex_bin: Option<String>,
64+
codex_args: Option<&str>,
65+
) -> Result<ServerProcess, String> {
66+
let base_url = rest_base_url();
67+
let mut command = build_codex_command_with_bin(
68+
codex_bin,
69+
codex_args,
70+
vec![
71+
"serve".to_string(),
72+
"--port".to_string(),
73+
REST_PORT.to_string(),
74+
],
75+
)?;
76+
command.stdin(std::process::Stdio::null());
77+
command.stdout(std::process::Stdio::null());
78+
command.stderr(std::process::Stdio::null());
79+
80+
let child = command.spawn().map_err(|e| {
81+
if e.kind() == ErrorKind::NotFound {
82+
"OpenCode CLI not found. Install OpenCode and ensure `opencode` is on your PATH."
83+
.to_string()
84+
} else {
85+
e.to_string()
86+
}
87+
})?;
88+
89+
let start = std::time::Instant::now();
90+
let health_timeout = Duration::from_secs(30);
91+
loop {
92+
if start.elapsed() > health_timeout {
93+
return Err("OpenCode server did not become healthy within 30 seconds.".to_string());
94+
}
95+
if health_check(&base_url).await.is_ok() {
96+
break;
97+
}
98+
sleep(Duration::from_millis(200)).await;
99+
}
100+
101+
Ok(ServerProcess { child, base_url })
102+
}
103+
58104
async fn ensure_server_running(
59105
codex_bin: Option<String>,
60106
codex_args: Option<&str>,
61107
) -> Result<String, String> {
62-
let base_url = format!("http://127.0.0.1:{REST_PORT}");
108+
let base_url = rest_base_url();
63109

64110
// Fast path: if already initialized, just return the URL.
65111
if SERVER_PROCESS.get().is_some() {
@@ -73,47 +119,8 @@ async fn ensure_server_running(
73119

74120
let init_result = SERVER_PROCESS
75121
.get_or_try_init(|| async {
76-
let mut command = build_codex_command_with_bin(
77-
codex_bin,
78-
codex_args,
79-
vec![
80-
"serve".to_string(),
81-
"--port".to_string(),
82-
REST_PORT.to_string(),
83-
],
84-
)?;
85-
command.stdin(std::process::Stdio::null());
86-
command.stdout(std::process::Stdio::null());
87-
command.stderr(std::process::Stdio::null());
88-
89-
let child = command.spawn().map_err(|e| {
90-
if e.kind() == ErrorKind::NotFound {
91-
"OpenCode CLI not found. Install OpenCode and ensure `opencode` is on your PATH."
92-
.to_string()
93-
} else {
94-
e.to_string()
95-
}
96-
})?;
97-
98-
// Poll health endpoint until ready.
99-
let start = std::time::Instant::now();
100-
let health_timeout = Duration::from_secs(30);
101-
loop {
102-
if start.elapsed() > health_timeout {
103-
return Err(
104-
"OpenCode server did not become healthy within 30 seconds.".to_string()
105-
);
106-
}
107-
if health_check(&base_url).await.is_ok() {
108-
break;
109-
}
110-
sleep(Duration::from_millis(200)).await;
111-
}
112-
113-
Ok(Mutex::new(ServerProcess {
114-
child,
115-
base_url: base_url.clone(),
116-
}))
122+
let server = start_managed_server_process(codex_bin, codex_args).await?;
123+
Ok::<Mutex<ServerProcess>, String>(Mutex::new(server))
117124
})
118125
.await;
119126

@@ -139,6 +146,89 @@ async fn health_check(base_url: &str) -> Result<Value, String> {
139146
resp.json::<Value>().await.map_err(|e| e.to_string())
140147
}
141148

149+
pub(crate) async fn global_rest_get(
150+
codex_bin: Option<String>,
151+
codex_args: Option<&str>,
152+
path: &str,
153+
directory: Option<&str>,
154+
) -> Result<Value, String> {
155+
let base_url = ensure_server_running(codex_bin, codex_args).await?;
156+
let client = reqwest::Client::builder()
157+
.timeout(Duration::from_secs(300))
158+
.build()
159+
.map_err(|e| e.to_string())?;
160+
let mut url = format!("{base_url}{path}");
161+
if let Some(directory) = directory.filter(|value| !value.trim().is_empty()) {
162+
let separator = if path.contains('?') { "&" } else { "?" };
163+
url = format!("{url}{separator}directory={}", urlencoding::encode(directory));
164+
}
165+
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
166+
if !resp.status().is_success() {
167+
let status = resp.status();
168+
let body = resp.text().await.unwrap_or_default();
169+
return Err(format!("REST GET {path} failed ({status}): {body}"));
170+
}
171+
resp.json::<Value>().await.map_err(|e| e.to_string())
172+
}
173+
174+
pub(crate) async fn opencode_server_status() -> Value {
175+
let base_url = rest_base_url();
176+
let managed = SERVER_PROCESS.get().is_some();
177+
match health_check(&base_url).await {
178+
Ok(health) => json!({
179+
"baseUrl": base_url,
180+
"healthy": true,
181+
"managed": managed,
182+
"source": if managed { "managed" } else { "external" },
183+
"version": health.get("version").cloned().unwrap_or(Value::Null),
184+
"health": health,
185+
}),
186+
Err(error) => json!({
187+
"baseUrl": base_url,
188+
"healthy": false,
189+
"managed": managed,
190+
"source": if managed { "managed" } else { "none" },
191+
"version": Value::Null,
192+
"error": error,
193+
}),
194+
}
195+
}
196+
197+
pub(crate) async fn restart_opencode_server(
198+
codex_bin: Option<String>,
199+
codex_args: Option<&str>,
200+
) -> Result<Value, String> {
201+
let base_url = rest_base_url();
202+
if let Some(server_mutex) = SERVER_PROCESS.get() {
203+
let mut guard = server_mutex.lock().await;
204+
let _ = kill_child_process_tree(&mut guard.child).await;
205+
let replacement = start_managed_server_process(codex_bin, codex_args).await?;
206+
*guard = replacement;
207+
return Ok(json!({
208+
"restarted": true,
209+
"status": opencode_server_status().await,
210+
}));
211+
}
212+
213+
if health_check(&base_url).await.is_ok() {
214+
return Err(format!(
215+
"OpenCode server at {base_url} is running but is not managed by OpenCode Monitor. Stop it manually, then retry."
216+
));
217+
}
218+
219+
let _ = SERVER_PROCESS
220+
.get_or_try_init(|| async {
221+
let server = start_managed_server_process(codex_bin, codex_args).await?;
222+
Ok::<Mutex<ServerProcess>, String>(Mutex::new(server))
223+
})
224+
.await?;
225+
226+
Ok(json!({
227+
"restarted": true,
228+
"status": opencode_server_status().await,
229+
}))
230+
}
231+
142232
// ---------------------------------------------------------------------------
143233
// WorkspaceSession (REST-based)
144234
// ---------------------------------------------------------------------------

src-tauri/src/bin/codex_monitor_daemon.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,54 @@ impl DaemonState {
797797
codex_core::model_list_core(&self.sessions, workspace_id).await
798798
}
799799

800+
async fn settings_model_list(&self, workspace_id: Option<String>) -> Result<Value, String> {
801+
let (codex_bin, codex_args) = {
802+
let settings = self.app_settings.lock().await;
803+
(settings.codex_bin.clone(), settings.codex_args.clone())
804+
};
805+
let directory = if let Some(workspace_id) = workspace_id.clone() {
806+
let workspaces = self.workspaces.lock().await;
807+
workspaces.get(&workspace_id).map(|entry| entry.path.clone())
808+
} else {
809+
None
810+
};
811+
let providers =
812+
backend::app_server::global_rest_get(
813+
codex_bin,
814+
codex_args.as_deref(),
815+
"/config/providers",
816+
directory.as_deref(),
817+
)
818+
.await?;
819+
let mut response = codex_core::model_list_response_from_providers(&providers);
820+
if let Some(obj) = response.as_object_mut() {
821+
obj.insert(
822+
"debug".to_string(),
823+
{
824+
let mut debug = codex_core::model_list_debug_from_providers(&providers);
825+
if let Some(debug_obj) = debug.as_object_mut() {
826+
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
827+
debug_obj.insert("requestDirectory".to_string(), json!(directory));
828+
}
829+
debug
830+
},
831+
);
832+
}
833+
Ok(response)
834+
}
835+
836+
async fn opencode_server_status(&self) -> Result<Value, String> {
837+
Ok(backend::app_server::opencode_server_status().await)
838+
}
839+
840+
async fn opencode_server_restart(&self) -> Result<Value, String> {
841+
let (codex_bin, codex_args) = {
842+
let settings = self.app_settings.lock().await;
843+
(settings.codex_bin.clone(), settings.codex_args.clone())
844+
};
845+
backend::app_server::restart_opencode_server(codex_bin, codex_args.as_deref()).await
846+
}
847+
800848
async fn collaboration_mode_list(&self, workspace_id: String) -> Result<Value, String> {
801849
codex_core::collaboration_mode_list_core(&self.sessions, workspace_id).await
802850
}

src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,12 @@ pub(super) async fn try_handle(
250250
};
251251
Some(state.model_list(workspace_id).await)
252252
}
253+
"settings_model_list" => {
254+
let workspace_id = parse_optional_string(params, "workspaceId");
255+
Some(state.settings_model_list(workspace_id).await)
256+
}
257+
"opencode_server_status" => Some(state.opencode_server_status().await),
258+
"opencode_server_restart" => Some(state.opencode_server_restart().await),
253259
"collaboration_mode_list" => {
254260
let workspace_id = match parse_string(params, "workspaceId") {
255261
Ok(value) => value,

src-tauri/src/codex/mod.rs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ pub(crate) mod args;
88
pub(crate) mod config;
99
pub(crate) mod home;
1010

11-
use crate::backend::app_server::spawn_workspace_session as spawn_workspace_session_inner;
11+
use crate::backend::app_server::{
12+
global_rest_get, opencode_server_status as app_server_status,
13+
restart_opencode_server as app_server_restart, spawn_workspace_session as spawn_workspace_session_inner,
14+
};
1215
pub(crate) use crate::backend::app_server::WorkspaceSession;
1316
use crate::backend::events::AppServerEvent;
1417
use crate::event_sink::TauriEventSink;
@@ -481,6 +484,82 @@ pub(crate) async fn model_list(
481484
codex_core::model_list_core(&state.sessions, workspace_id).await
482485
}
483486

487+
#[tauri::command]
488+
pub(crate) async fn settings_model_list(
489+
workspace_id: Option<String>,
490+
state: State<'_, AppState>,
491+
app: AppHandle,
492+
) -> Result<Value, String> {
493+
if remote_backend::is_remote_mode(&*state).await {
494+
return remote_backend::call_remote(
495+
&*state,
496+
app,
497+
"settings_model_list",
498+
json!({ "workspaceId": workspace_id }),
499+
)
500+
.await;
501+
}
502+
503+
let (codex_bin, codex_args) = {
504+
let settings = state.app_settings.lock().await;
505+
(settings.codex_bin.clone(), settings.codex_args.clone())
506+
};
507+
let directory = if let Some(workspace_id) = workspace_id.clone() {
508+
let workspaces = state.workspaces.lock().await;
509+
workspaces.get(&workspace_id).map(|entry| entry.path.clone())
510+
} else {
511+
None
512+
};
513+
let providers = global_rest_get(
514+
codex_bin,
515+
codex_args.as_deref(),
516+
"/config/providers",
517+
directory.as_deref(),
518+
)
519+
.await?;
520+
let mut response = codex_core::model_list_response_from_providers(&providers);
521+
if let Some(obj) = response.as_object_mut() {
522+
obj.insert(
523+
"debug".to_string(),
524+
{
525+
let mut debug = codex_core::model_list_debug_from_providers(&providers);
526+
if let Some(debug_obj) = debug.as_object_mut() {
527+
debug_obj.insert("requestWorkspaceId".to_string(), json!(workspace_id));
528+
debug_obj.insert("requestDirectory".to_string(), json!(directory));
529+
}
530+
debug
531+
},
532+
);
533+
}
534+
Ok(response)
535+
}
536+
537+
#[tauri::command]
538+
pub(crate) async fn opencode_server_status(
539+
state: State<'_, AppState>,
540+
app: AppHandle,
541+
) -> Result<Value, String> {
542+
if remote_backend::is_remote_mode(&*state).await {
543+
return remote_backend::call_remote(&*state, app, "opencode_server_status", json!({})).await;
544+
}
545+
Ok(app_server_status().await)
546+
}
547+
548+
#[tauri::command]
549+
pub(crate) async fn opencode_server_restart(
550+
state: State<'_, AppState>,
551+
app: AppHandle,
552+
) -> Result<Value, String> {
553+
if remote_backend::is_remote_mode(&*state).await {
554+
return remote_backend::call_remote(&*state, app, "opencode_server_restart", json!({})).await;
555+
}
556+
let (codex_bin, codex_args) = {
557+
let settings = state.app_settings.lock().await;
558+
(settings.codex_bin.clone(), settings.codex_args.clone())
559+
};
560+
app_server_restart(codex_bin, codex_args.as_deref()).await
561+
}
562+
484563
#[tauri::command]
485564
pub(crate) async fn account_rate_limits(
486565
workspace_id: String,

src-tauri/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ pub fn run() {
276276
git::checkout_git_branch,
277277
git::create_git_branch,
278278
codex::model_list,
279+
codex::settings_model_list,
280+
codex::opencode_server_status,
281+
codex::opencode_server_restart,
279282
codex::account_rate_limits,
280283
codex::account_read,
281284
codex::codex_login,

src-tauri/src/remote_backend/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,11 @@ fn can_retry_after_disconnect(method: &str) -> bool {
172172
| "list_workspace_files"
173173
| "list_workspaces"
174174
| "model_list"
175+
| "opencode_server_restart"
176+
| "opencode_server_status"
175177
| "read_workspace_file"
176178
| "resume_thread"
179+
| "settings_model_list"
177180
| "skills_list"
178181
| "worktree_setup_status"
179182
)

0 commit comments

Comments
 (0)