diff options
| author | soryu <soryu@soryu.co> | 2026-01-22 01:26:53 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-22 01:26:53 +0000 |
| commit | b61a907bac09a7649ca3f6d850e771b3b75c7015 (patch) | |
| tree | a7794d5b29bc165b6d76596d6e53cf75158d3a64 | |
| parent | b84b3f782d3a3d6bf7ed8040fd72907ca19db8c6 (diff) | |
| download | soryu-b61a907bac09a7649ca3f6d850e771b3b75c7015.tar.gz soryu-b61a907bac09a7649ca3f6d850e771b3b75c7015.zip | |
Add daemon restart feature from settings (#18)
* Add daemon restart feature from settings
This adds the ability to restart a connected daemon from the settings page.
The feature includes:
- Backend: RestartDaemon command added to DaemonCommand enum
- Backend: New POST /api/v1/mesh/daemons/{id}/restart endpoint
- Backend: Daemon gracefully shuts down tasks and exits with code 42
(can be used by process managers like systemd to detect restart requests)
- Frontend: restartDaemon() API function
- Frontend: Restart button in Connected Daemons section of settings
- Frontend: Confirmation dialog before restart to prevent accidental restarts
When a daemon receives the restart command, it:
1. Gracefully shuts down all running Claude processes (5s timeout)
2. Exits with code 42 to signal restart requested
3. The daemon can be restarted by a process manager or manually
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 23 | ||||
| -rw-r--r-- | makima/frontend/src/routes/settings.tsx | 79 | ||||
| -rw-r--r-- | makima/src/daemon/task/manager.rs | 9 | ||||
| -rw-r--r-- | makima/src/daemon/ws/protocol.rs | 3 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 109 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/state.rs | 3 |
7 files changed, 216 insertions, 11 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 86ff06c..efa72a2 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1087,6 +1087,29 @@ export async function getDaemon(id: string): Promise<Daemon> { return res.json(); } +/** Response from the restart daemon endpoint */ +export interface RestartDaemonResponse { + success: boolean; + daemonId: string; + message: string; +} + +/** + * Restart a connected daemon. + * Sends a restart command to the daemon, which will gracefully terminate + * and restart. Any running tasks will be interrupted. + */ +export async function restartDaemon(id: string): Promise<RestartDaemonResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${id}/restart`, { + method: "POST", + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to restart daemon: ${res.statusText}`); + } + return res.json(); +} + // ============================================================================= // Mesh Chat Types for Task Orchestration // ============================================================================= diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx index d3f4c1b..b93ecbc 100644 --- a/makima/frontend/src/routes/settings.tsx +++ b/makima/frontend/src/routes/settings.tsx @@ -11,6 +11,7 @@ import { changeEmail, deleteAccount, listDaemons, + restartDaemon, listRepositoryHistory, deleteRepositoryHistory, type ApiKeyInfo, @@ -306,6 +307,8 @@ export default function SettingsPage() { const [daemons, setDaemons] = useState<Daemon[]>([]); const [daemonsLoading, setDaemonsLoading] = useState(true); const [daemonsError, setDaemonsError] = useState<string | null>(null); + const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null); + const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null); // Repository history state const [repoHistory, setRepoHistory] = useState<RepositoryHistoryEntry[]>([]); @@ -376,6 +379,23 @@ export default function SettingsPage() { } }; + const handleRestartDaemon = async (id: string) => { + try { + setRestartingDaemonId(id); + setDaemonsError(null); + await restartDaemon(id); + // Daemon will restart, so refresh the list after a short delay + setTimeout(() => { + loadDaemons(); + }, 2000); + } catch (err) { + setDaemonsError(err instanceof Error ? err.message : "Failed to restart daemon"); + } finally { + setRestartingDaemonId(null); + setRestartConfirmDaemonId(null); + } + }; + const handleCreate = async () => { try { setActionLoading(true); @@ -687,17 +707,19 @@ export default function SettingsPage() { <span className="font-mono text-xs text-[#9bc3ff]"> {daemon.hostname || "Unknown Host"} </span> - <span - className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${ - daemon.status === "connected" - ? "text-green-400 border-green-700/50 bg-green-900/20" - : daemon.status === "unhealthy" - ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20" - : "text-[#8899aa] border-[rgba(117,170,252,0.25)]" - }`} - > - {daemon.status} - </span> + <div className="flex items-center gap-2"> + <span + className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${ + daemon.status === "connected" + ? "text-green-400 border-green-700/50 bg-green-900/20" + : daemon.status === "unhealthy" + ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20" + : "text-[#8899aa] border-[rgba(117,170,252,0.25)]" + }`} + > + {daemon.status} + </span> + </div> </div> <div className="font-mono text-[10px] text-[#7788aa] space-y-1"> <div className="flex justify-between"> @@ -721,6 +743,41 @@ export default function SettingsPage() { </div> )} </div> + {/* Restart Section */} + {daemon.status === "connected" && ( + <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]"> + {restartConfirmDaemonId === daemon.id ? ( + <div className="flex items-center justify-between gap-2"> + <span className="text-[10px] font-mono text-yellow-400"> + Restart daemon? Running tasks will be interrupted. + </span> + <div className="flex gap-2"> + <button + onClick={() => setRestartConfirmDaemonId(null)} + className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1" + disabled={restartingDaemonId === daemon.id} + > + Cancel + </button> + <button + onClick={() => handleRestartDaemon(daemon.id)} + disabled={restartingDaemonId === daemon.id} + className="text-[10px] font-mono text-red-400 hover:text-red-300 px-2 py-1 border border-red-700/50 bg-red-900/20 disabled:opacity-50" + > + {restartingDaemonId === daemon.id ? "Restarting..." : "Confirm"} + </button> + </div> + </div> + ) : ( + <button + onClick={() => setRestartConfirmDaemonId(daemon.id)} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + ⟳ Restart Daemon + </button> + )} + </div> + )} </div> ))} </div> diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 5eed0e8..a3e4732 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1663,6 +1663,15 @@ impl TaskManager { tracing::info!(source_dir = ?source_dir, "Inheriting git config"); self.handle_inherit_git_config(source_dir).await?; } + DaemonCommand::RestartDaemon => { + tracing::info!("Received restart command from server, initiating daemon restart..."); + // Shutdown all running tasks gracefully + self.shutdown_all_processes(std::time::Duration::from_secs(5)).await; + // Exit the process - the daemon should be restarted by a process manager + // or the user can restart it manually + tracing::info!("Daemon restart: exiting process with code 42 (restart requested)"); + std::process::exit(42); + } } Ok(()) } diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index d0bcc19..3b02b53 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -624,6 +624,9 @@ pub enum DaemonCommand { code: String, message: String, }, + + /// Restart the daemon process. + RestartDaemon, } impl DaemonMessage { diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 99c3d9d..53e1587 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -3561,3 +3561,112 @@ pub async fn branch_task( ) .into_response() } + +// ============================================================================= +// Daemon Management +// ============================================================================= + +/// Response for restart daemon request. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RestartDaemonResponse { + /// Whether the restart command was sent successfully. + pub success: bool, + /// The daemon ID that received the restart command. + pub daemon_id: Uuid, + /// Message describing the result. + pub message: String, +} + +/// Restart a daemon by ID (requires authentication). +/// +/// Sends a restart command to the specified daemon, which will cause it to +/// gracefully terminate and restart. Any running tasks will be interrupted. +#[utoipa::path( + post, + path = "/api/v1/mesh/daemons/{id}/restart", + params( + ("id" = Uuid, Path, description = "Daemon ID") + ), + responses( + (status = 200, description = "Restart command sent", body = RestartDaemonResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Daemon not found or not connected", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn restart_daemon( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify the daemon exists and belongs to this owner + match repository::get_daemon_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Daemon not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Check if daemon is connected + if !state.is_daemon_connected(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new( + "DAEMON_NOT_CONNECTED", + "Daemon is not currently connected", + )), + ) + .into_response(); + } + + // Send restart command to daemon + let command = DaemonCommand::RestartDaemon; + if let Err(e) = state.send_daemon_command(id, command).await { + tracing::error!("Failed to send restart command to daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + tracing::info!( + daemon_id = %id, + owner_id = %auth.owner_id, + "Restart command sent to daemon" + ); + + Json(RestartDaemonResponse { + success: true, + daemon_id: id, + message: "Restart command sent. The daemon will restart shortly.".to_string(), + }) + .into_response() +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index d575997..0bc1b92 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -93,6 +93,7 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/daemons", get(mesh::list_daemons)) .route("/mesh/daemons/directories", get(mesh::get_daemon_directories)) .route("/mesh/daemons/{id}", get(mesh::get_daemon)) + .route("/mesh/daemons/{id}/restart", post(mesh::restart_daemon)) // Merge endpoints for orchestrators .route("/mesh/tasks/{id}/branches", get(mesh_merge::list_branches)) .route("/mesh/tasks/{id}/merge/start", post(mesh_merge::merge_start)) diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 38aadf5..28d65d0 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -435,6 +435,9 @@ pub enum DaemonCommand { /// Error response Error { code: String, message: String }, + + /// Restart the daemon process + RestartDaemon, } /// Active daemon connection info stored in state. |
