summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-22 01:26:53 +0000
committerGitHub <noreply@github.com>2026-01-22 01:26:53 +0000
commitb61a907bac09a7649ca3f6d850e771b3b75c7015 (patch)
treea7794d5b29bc165b6d76596d6e53cf75158d3a64
parentb84b3f782d3a3d6bf7ed8040fd72907ca19db8c6 (diff)
downloadsoryu-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.ts23
-rw-r--r--makima/frontend/src/routes/settings.tsx79
-rw-r--r--makima/src/daemon/task/manager.rs9
-rw-r--r--makima/src/daemon/ws/protocol.rs3
-rw-r--r--makima/src/server/handlers/mesh.rs109
-rw-r--r--makima/src/server/mod.rs1
-rw-r--r--makima/src/server/state.rs3
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.