mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: restart (#531)
* feat: restart * review fixes * add IRONCLAW_IN_DOCKER env variable * review fixes * fix tests * set default value as false
This commit is contained in:
@@ -115,5 +115,12 @@ HEARTBEAT_NOTIFY_USER=default
|
|||||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||||
SAFETY_INJECTION_CHECK_ENABLED=true
|
SAFETY_INJECTION_CHECK_ENABLED=true
|
||||||
|
|
||||||
|
# Restart Feature (Docker containers only)
|
||||||
|
# Set IRONCLAW_IN_DOCKER=true in the container entrypoint to enable the restart feature.
|
||||||
|
# Without this, the restart tool and /restart command will be disabled.
|
||||||
|
# IRONCLAW_IN_DOCKER=false
|
||||||
|
# IRONCLAW_RESTART_DELAY=5 # default wait before exit (seconds, range: 1-30)
|
||||||
|
# IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
RUST_LOG=ironclaw=debug,tower_http=debug
|
RUST_LOG=ironclaw=debug,tower_http=debug
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ GATEWAY_HOST=0.0.0.0
|
|||||||
GATEWAY_PORT=3000
|
GATEWAY_PORT=3000
|
||||||
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
||||||
|
|
||||||
|
# Restart Feature (Docker containers only)
|
||||||
|
# IMPORTANT: Set this in the container entrypoint or docker-compose to enable restart.
|
||||||
|
# The Docker entrypoint loop monitors exit codes:
|
||||||
|
# - Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY, restart
|
||||||
|
# - Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES
|
||||||
|
IRONCLAW_IN_DOCKER=false
|
||||||
|
IRONCLAW_RESTART_DELAY=5 # seconds to wait before restarting (range: 1-30)
|
||||||
|
IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
|
||||||
|
|
||||||
# Disabled for initial deploy
|
# Disabled for initial deploy
|
||||||
SANDBOX_ENABLED=false
|
SANDBOX_ENABLED=false
|
||||||
HEARTBEAT_ENABLED=false
|
HEARTBEAT_ENABLED=false
|
||||||
|
|||||||
+12
-1
@@ -635,6 +635,10 @@ impl Agent {
|
|||||||
|
|
||||||
// Parse submission type first
|
// Parse submission type first
|
||||||
let mut submission = SubmissionParser::parse(&message.content);
|
let mut submission = SubmissionParser::parse(&message.content);
|
||||||
|
tracing::debug!(
|
||||||
|
"[agent_loop] Parsed submission: {:?}",
|
||||||
|
std::any::type_name_of_val(&submission)
|
||||||
|
);
|
||||||
|
|
||||||
// Hook: BeforeInbound — allow hooks to modify or reject user input
|
// Hook: BeforeInbound — allow hooks to modify or reject user input
|
||||||
if let Submission::UserInput { ref content } = submission {
|
if let Submission::UserInput { ref content } = submission {
|
||||||
@@ -719,7 +723,14 @@ impl Agent {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
Submission::SystemCommand { command, args } => {
|
Submission::SystemCommand { command, args } => {
|
||||||
self.handle_system_command(&command, &args).await
|
tracing::debug!(
|
||||||
|
"[agent_loop] SystemCommand: command={}, channel={}",
|
||||||
|
command,
|
||||||
|
message.channel
|
||||||
|
);
|
||||||
|
// Authorization checks (including restart channel check) are enforced in handle_system_command
|
||||||
|
self.handle_system_command(&command, &args, &message.channel)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
Submission::Undo => self.process_undo(session, thread_id).await,
|
Submission::Undo => self.process_undo(session, thread_id).await,
|
||||||
Submission::Redo => self.process_redo(session, thread_id).await,
|
Submission::Redo => self.process_redo(session, thread_id).await,
|
||||||
|
|||||||
+70
-2
@@ -68,7 +68,10 @@ impl Agent {
|
|||||||
self.handle_help_job(&message.user_id, &job_id).await?
|
self.handle_help_job(&message.user_id, &job_id).await?
|
||||||
}
|
}
|
||||||
MessageIntent::Command { command, args } => {
|
MessageIntent::Command { command, args } => {
|
||||||
match self.handle_command(&command, &args).await? {
|
match self
|
||||||
|
.handle_command(&command, &args, &message.channel)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
||||||
}
|
}
|
||||||
@@ -466,6 +469,7 @@ impl Agent {
|
|||||||
&self,
|
&self,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
|
channel: &str,
|
||||||
) -> Result<SubmissionResult, Error> {
|
) -> Result<SubmissionResult, Error> {
|
||||||
match command {
|
match command {
|
||||||
"help" => Ok(SubmissionResult::response(concat!(
|
"help" => Ok(SubmissionResult::response(concat!(
|
||||||
@@ -501,12 +505,75 @@ impl Agent {
|
|||||||
" /heartbeat Run heartbeat check\n",
|
" /heartbeat Run heartbeat check\n",
|
||||||
" /summarize Summarize current thread\n",
|
" /summarize Summarize current thread\n",
|
||||||
" /suggest Suggest next steps\n",
|
" /suggest Suggest next steps\n",
|
||||||
|
" /restart Gracefully restart the process\n",
|
||||||
"\n",
|
"\n",
|
||||||
" /quit Exit",
|
" /quit Exit",
|
||||||
))),
|
))),
|
||||||
|
|
||||||
"ping" => Ok(SubmissionResult::response("pong!")),
|
"ping" => Ok(SubmissionResult::response("pong!")),
|
||||||
|
|
||||||
|
"restart" => {
|
||||||
|
tracing::info!("[commands::restart] Restart command received");
|
||||||
|
// Channel authorization check: restart is only available via web interface
|
||||||
|
if channel != "gateway" {
|
||||||
|
tracing::warn!(
|
||||||
|
"[commands::restart] Restart rejected: not from gateway channel (from: {})",
|
||||||
|
channel
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(
|
||||||
|
"Restart is only available through the web interface with explicit user confirmation. \
|
||||||
|
Use the Restart button in the UI."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Environment check: restart is only available in Docker containers
|
||||||
|
let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
|
||||||
|
.map(|v| v.to_lowercase() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
tracing::debug!("[commands::restart] IRONCLAW_IN_DOCKER={}", in_docker);
|
||||||
|
|
||||||
|
if !in_docker {
|
||||||
|
tracing::warn!(
|
||||||
|
"[commands::restart] Restart rejected: not in Docker environment"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(
|
||||||
|
"Restart is not available in this environment. \
|
||||||
|
The IRONCLAW_IN_DOCKER environment variable must be set to 'true' for Docker deployments."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute restart tool directly (don't dispatch as a job for LLM planning)
|
||||||
|
// This ensures the tool runs immediately without LLM involvement
|
||||||
|
use crate::tools::Tool;
|
||||||
|
let tool = crate::tools::builtin::RestartTool;
|
||||||
|
let params = serde_json::json!({});
|
||||||
|
|
||||||
|
// Create a minimal JobContext for the tool
|
||||||
|
let dummy_ctx =
|
||||||
|
crate::context::JobContext::with_user("system", "Restart", "Graceful restart");
|
||||||
|
|
||||||
|
match tool.execute(params, &dummy_ctx).await {
|
||||||
|
Ok(output) => {
|
||||||
|
tracing::info!("[commands::restart] RestartTool executed successfully");
|
||||||
|
// Extract text from the ToolOutput result
|
||||||
|
let response = match output.result {
|
||||||
|
serde_json::Value::String(s) => s,
|
||||||
|
_ => output.result.to_string(),
|
||||||
|
};
|
||||||
|
Ok(SubmissionResult::response(response))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"[commands::restart] RestartTool execution failed: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(SubmissionResult::error(format!("Restart failed: {}", e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"version" => Ok(SubmissionResult::response(format!(
|
"version" => Ok(SubmissionResult::response(format!(
|
||||||
"{} v{}",
|
"{} v{}",
|
||||||
env!("CARGO_PKG_NAME"),
|
env!("CARGO_PKG_NAME"),
|
||||||
@@ -744,10 +811,11 @@ impl Agent {
|
|||||||
&self,
|
&self,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
|
channel: &str,
|
||||||
) -> Result<Option<String>, Error> {
|
) -> Result<Option<String>, Error> {
|
||||||
// System commands are now handled directly via Submission::SystemCommand,
|
// System commands are now handled directly via Submission::SystemCommand,
|
||||||
// but the router may still send us unknown /commands.
|
// but the router may still send us unknown /commands.
|
||||||
match self.handle_system_command(command, args).await? {
|
match self.handle_system_command(command, args, channel).await? {
|
||||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||||
SubmissionResult::Ok { message } => Ok(message),
|
SubmissionResult::Ok { message } => Ok(message),
|
||||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ impl SubmissionParser {
|
|||||||
pub fn parse(content: &str) -> Submission {
|
pub fn parse(content: &str) -> Submission {
|
||||||
let trimmed = content.trim();
|
let trimmed = content.trim();
|
||||||
let lower = trimmed.to_lowercase();
|
let lower = trimmed.to_lowercase();
|
||||||
|
tracing::debug!("[SubmissionParser::parse] Parsing input: {:?}", trimmed);
|
||||||
|
|
||||||
// Control commands (exact match or prefix)
|
// Control commands (exact match or prefix)
|
||||||
if lower == "/undo" {
|
if lower == "/undo" {
|
||||||
@@ -91,6 +92,13 @@ impl SubmissionParser {
|
|||||||
args: vec![],
|
args: vec![],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if lower == "/restart" {
|
||||||
|
tracing::debug!("[SubmissionParser::parse] Recognized /restart command");
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "restart".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
if lower.starts_with("/model") {
|
if lower.starts_with("/model") {
|
||||||
let args: Vec<String> = trimmed
|
let args: Vec<String> = trimmed
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
|
|||||||
@@ -606,6 +606,12 @@ async fn chat_send_handler(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Json(req): Json<SendMessageRequest>,
|
Json(req): Json<SendMessageRequest>,
|
||||||
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
|
||||||
|
tracing::debug!(
|
||||||
|
"[chat_send_handler] Received message: content={:?}, thread_id={:?}",
|
||||||
|
req.content,
|
||||||
|
req.thread_id
|
||||||
|
);
|
||||||
|
|
||||||
if !state.chat_rate_limiter.check() {
|
if !state.chat_rate_limiter.check() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
@@ -621,6 +627,11 @@ async fn chat_send_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msg_id = msg.id;
|
let msg_id = msg.id;
|
||||||
|
tracing::debug!(
|
||||||
|
"[chat_send_handler] Created message id={}, content={:?}",
|
||||||
|
msg_id,
|
||||||
|
req.content
|
||||||
|
);
|
||||||
|
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
let tx = tx_guard.as_ref().ok_or((
|
||||||
@@ -628,6 +639,7 @@ async fn chat_send_handler(
|
|||||||
"Channel not started".to_string(),
|
"Channel not started".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
|
tracing::debug!("[chat_send_handler] Sending message through channel");
|
||||||
tx.send(msg).await.map_err(|_| {
|
tx.send(msg).await.map_err(|_| {
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -635,6 +647,8 @@ async fn chat_send_handler(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("[chat_send_handler] Message sent successfully, returning 202 ACCEPTED");
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(SendMessageResponse {
|
Json(SendMessageResponse {
|
||||||
@@ -2300,11 +2314,16 @@ async fn gateway_status_handler(
|
|||||||
(None, None, None)
|
(None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let restart_enabled = std::env::var("IRONCLAW_IN_DOCKER")
|
||||||
|
.map(|v| v.to_lowercase() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
Json(GatewayStatusResponse {
|
Json(GatewayStatusResponse {
|
||||||
sse_connections,
|
sse_connections,
|
||||||
ws_connections,
|
ws_connections,
|
||||||
total_connections: sse_connections + ws_connections,
|
total_connections: sse_connections + ws_connections,
|
||||||
uptime_secs,
|
uptime_secs,
|
||||||
|
restart_enabled,
|
||||||
daily_cost,
|
daily_cost,
|
||||||
actions_this_hour,
|
actions_this_hour,
|
||||||
model_usage,
|
model_usage,
|
||||||
@@ -2325,6 +2344,7 @@ struct GatewayStatusResponse {
|
|||||||
ws_connections: u64,
|
ws_connections: u64,
|
||||||
total_connections: u64,
|
total_connections: u64,
|
||||||
uptime_secs: u64,
|
uptime_secs: u64,
|
||||||
|
restart_enabled: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
daily_cost: Option<String>,
|
daily_cost: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -133,6 +133,110 @@ function apiFetch(path, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Restart Feature ---
|
||||||
|
|
||||||
|
let isRestarting = false; // Track if we're currently restarting
|
||||||
|
let restartEnabled = false; // Track if restart is available in this deployment
|
||||||
|
|
||||||
|
function triggerRestart() {
|
||||||
|
if (!currentThreadId) {
|
||||||
|
alert('Please start a conversation first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the confirmation modal
|
||||||
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
||||||
|
confirmModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestart() {
|
||||||
|
if (!currentThreadId) {
|
||||||
|
alert('Please start a conversation first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide confirmation modal
|
||||||
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
||||||
|
confirmModal.style.display = 'none';
|
||||||
|
|
||||||
|
const restartBtn = document.getElementById('restart-btn');
|
||||||
|
const restartIcon = document.getElementById('restart-icon');
|
||||||
|
|
||||||
|
// Mark as restarting
|
||||||
|
isRestarting = true;
|
||||||
|
restartBtn.disabled = true;
|
||||||
|
if (restartIcon) restartIcon.classList.add('spinning');
|
||||||
|
|
||||||
|
// Show progress modal
|
||||||
|
const loaderEl = document.getElementById('restart-loader');
|
||||||
|
loaderEl.style.display = 'flex';
|
||||||
|
|
||||||
|
// Send restart command via chat
|
||||||
|
console.log('[confirmRestart] Sending /restart command to server');
|
||||||
|
apiFetch('/api/chat/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
content: '/restart',
|
||||||
|
thread_id: currentThreadId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
console.log('[confirmRestart] API call succeeded, response:', response);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[confirmRestart] Restart request failed:', err);
|
||||||
|
addMessage('system', 'Restart failed: ' + err.message);
|
||||||
|
isRestarting = false;
|
||||||
|
restartBtn.disabled = false;
|
||||||
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
||||||
|
loaderEl.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRestart() {
|
||||||
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
||||||
|
confirmModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryShowRestartModal() {
|
||||||
|
// Defensive callback for when restart is detected in messages.
|
||||||
|
if (!isRestarting) {
|
||||||
|
isRestarting = true;
|
||||||
|
const restartBtn = document.getElementById('restart-btn');
|
||||||
|
const restartIcon = document.getElementById('restart-icon');
|
||||||
|
restartBtn.disabled = true;
|
||||||
|
if (restartIcon) restartIcon.classList.add('spinning');
|
||||||
|
|
||||||
|
// Show progress modal
|
||||||
|
const loaderEl = document.getElementById('restart-loader');
|
||||||
|
loaderEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRestartButtonVisibility() {
|
||||||
|
const restartBtn = document.getElementById('restart-btn');
|
||||||
|
if (restartBtn) {
|
||||||
|
restartBtn.style.display = restartEnabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGatewayStatusPolling() {
|
||||||
|
fetchGatewayStatus();
|
||||||
|
// Poll every 5 seconds
|
||||||
|
setInterval(fetchGatewayStatus, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchGatewayStatus() {
|
||||||
|
apiFetch('/api/gateway/status')
|
||||||
|
.then((data) => {
|
||||||
|
restartEnabled = data.restart_enabled || false;
|
||||||
|
updateRestartButtonVisibility();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[gateway status] Failed to fetch:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- SSE ---
|
// --- SSE ---
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
@@ -143,6 +247,18 @@ function connectSSE() {
|
|||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||||
document.getElementById('sse-status').textContent = 'Connected';
|
document.getElementById('sse-status').textContent = 'Connected';
|
||||||
|
|
||||||
|
// If we were restarting, close the modal and reset button now that server is back
|
||||||
|
if (isRestarting) {
|
||||||
|
const loaderEl = document.getElementById('restart-loader');
|
||||||
|
if (loaderEl) loaderEl.style.display = 'none';
|
||||||
|
const restartBtn = document.getElementById('restart-btn');
|
||||||
|
const restartIcon = document.getElementById('restart-icon');
|
||||||
|
if (restartBtn) restartBtn.disabled = false;
|
||||||
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
||||||
|
isRestarting = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (sseHasConnectedBefore && currentThreadId) {
|
if (sseHasConnectedBefore && currentThreadId) {
|
||||||
finalizeActivityGroup();
|
finalizeActivityGroup();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
@@ -163,6 +279,11 @@ function connectSSE() {
|
|||||||
enableChatInput();
|
enableChatInput();
|
||||||
// Refresh thread list so new titles appear after first message
|
// Refresh thread list so new titles appear after first message
|
||||||
loadThreads();
|
loadThreads();
|
||||||
|
|
||||||
|
// Show restart modal if the response indicates restart was initiated
|
||||||
|
if (data.content && data.content.toLowerCase().includes('restart initiated')) {
|
||||||
|
setTimeout(() => tryShowRestartModal(), 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('thinking', (e) => {
|
eventSource.addEventListener('thinking', (e) => {
|
||||||
@@ -181,6 +302,11 @@ function connectSSE() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (!isCurrentThread(data.thread_id)) return;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
completeToolCard(data.name, data.success, data.error, data.parameters);
|
completeToolCard(data.name, data.success, data.error, data.parameters);
|
||||||
|
|
||||||
|
// Show restart modal only when the restart tool succeeds
|
||||||
|
if (data.name.toLowerCase() === 'restart' && data.success) {
|
||||||
|
setTimeout(() => tryShowRestartModal(), 500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('tool_result', (e) => {
|
eventSource.addEventListener('tool_result', (e) => {
|
||||||
|
|||||||
@@ -33,6 +33,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Confirmation Modal -->
|
||||||
|
<div id="restart-confirm-modal" class="restart-modal" style="display: none;">
|
||||||
|
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
|
||||||
|
<div class="restart-modal-content">
|
||||||
|
<div class="restart-modal-header">
|
||||||
|
<h2>Restart IronClaw Instance</h2>
|
||||||
|
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="restart-modal-body">
|
||||||
|
<p class="restart-modal-description">
|
||||||
|
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
|
||||||
|
</p>
|
||||||
|
<div class="restart-modal-warning">
|
||||||
|
<span class="restart-modal-warning-icon">⚠️</span>
|
||||||
|
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="restart-modal-footer">
|
||||||
|
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
|
||||||
|
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Progress Modal -->
|
||||||
|
<div id="restart-loader" class="restart-loader" style="display: none;">
|
||||||
|
<div class="restart-loader-overlay"></div>
|
||||||
|
<div class="restart-loader-content">
|
||||||
|
<div class="restart-spinner"></div>
|
||||||
|
<div class="restart-loader-text">
|
||||||
|
<p class="restart-title">Restarting IronClaw</p>
|
||||||
|
<p class="restart-subtitle">Please wait while the process restarts...</p>
|
||||||
|
</div>
|
||||||
|
<div class="restart-progress-bar">
|
||||||
|
<div class="restart-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="restart-modal-info">
|
||||||
|
Check the Logs tab for details after the restart completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main App (hidden until authenticated) -->
|
<!-- Main App (hidden until authenticated) -->
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- Tab Bar -->
|
<!-- Tab Bar -->
|
||||||
@@ -57,6 +99,14 @@
|
|||||||
<span id="sse-status">Connected</span>
|
<span id="sse-status">Connected</span>
|
||||||
<div class="gateway-popover" id="gateway-popover"></div>
|
<div class="gateway-popover" id="gateway-popover"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process">
|
||||||
|
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M23 4v6h-6"></path>
|
||||||
|
<path d="M1 20v-6h6"></path>
|
||||||
|
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Restart</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Tab -->
|
<!-- Chat Tab -->
|
||||||
|
|||||||
@@ -259,6 +259,284 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Restart Button */
|
||||||
|
.restart-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border: 1px solid;
|
||||||
|
border-color: #00d894;
|
||||||
|
color: #00d894;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms, background-color 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-btn:hover:not(:disabled) {
|
||||||
|
background-color: rgba(0, 216, 148, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-btn:disabled {
|
||||||
|
border-color: #333;
|
||||||
|
color: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-btn:disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-btn svg.spinning {
|
||||||
|
animation: spin-icon 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin-icon {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restart Loader Overlay */
|
||||||
|
.restart-loader {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-loader-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-loader-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10000;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-spinner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-loader-text {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-title {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restart Modal (Confirmation) */
|
||||||
|
.restart-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10000;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-header h2 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-close {
|
||||||
|
color: #888;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms, background-color 150ms;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-close:hover {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-description {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-warning {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background-color: #1e1400;
|
||||||
|
border: 1px solid #3a2a00;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-warning p {
|
||||||
|
color: #facc15;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-btn.cancel {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-btn.cancel:hover {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-btn.confirm {
|
||||||
|
background-color: #00D894;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-btn.confirm:hover {
|
||||||
|
background-color: #00be82;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar for Restart */
|
||||||
|
.restart-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.375rem;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #00D894;
|
||||||
|
width: 40%;
|
||||||
|
animation: indeterminate 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
margin-left: 60%;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-info a {
|
||||||
|
color: #00D894;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.tee-popover {
|
.tee-popover {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod json;
|
|||||||
mod memory;
|
mod memory;
|
||||||
mod message;
|
mod message;
|
||||||
pub mod path_utils;
|
pub mod path_utils;
|
||||||
|
mod restart;
|
||||||
pub mod routine;
|
pub mod routine;
|
||||||
pub mod secrets_tools;
|
pub mod secrets_tools;
|
||||||
pub(crate) mod shell;
|
pub(crate) mod shell;
|
||||||
@@ -28,6 +29,7 @@ pub use job::{
|
|||||||
pub use json::JsonTool;
|
pub use json::JsonTool;
|
||||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||||
pub use message::MessageTool;
|
pub use message::MessageTool;
|
||||||
|
pub use restart::RestartTool;
|
||||||
pub use routine::{
|
pub use routine::{
|
||||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
//! Restart tool for graceful process restart.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! IronClaw runs inside a Docker container with an entrypoint loop that monitors exit codes:
|
||||||
|
//! - **Exit code 0** (clean): Reset failure counter, wait `IRONCLAW_RESTART_DELAY` (default 5s), restart
|
||||||
|
//! - **Exit code ≠ 0** (failure): Increment failure counter, exit after `IRONCLAW_MAX_FAILURES` (default 10)
|
||||||
|
//!
|
||||||
|
//! This tool triggers a restart by calling `std::process::exit(0)` after a brief delay, allowing
|
||||||
|
//! the HTTP response to be flushed before the process terminates. The entrypoint loop then
|
||||||
|
//! detects the clean exit and automatically restarts the process.
|
||||||
|
//!
|
||||||
|
//! ## Security
|
||||||
|
//!
|
||||||
|
//! - **Approval Model:** User approval happens at the command level via web modal confirmation,
|
||||||
|
//! not at tool execution level. This allows approved commands to execute in autonomous jobs.
|
||||||
|
//! - **Web-Only Access:** The `/restart` command only works via the web gateway (enforced in commands.rs)
|
||||||
|
//! - **Parameter Validation:** Delay clamped to 1-30 seconds
|
||||||
|
//!
|
||||||
|
//! ## Known Limitations
|
||||||
|
//!
|
||||||
|
//! - Hard exit without graceful shutdown (no destructor cleanup, no RwLock drains)
|
||||||
|
//! - In-flight jobs are paused during restart and resumed by the entrypoint
|
||||||
|
//! - Future: Implement graceful shutdown with CancellationToken for proper resource cleanup
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::context::JobContext;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
|
/// Tool for triggering a graceful process restart via exit code 0.
|
||||||
|
///
|
||||||
|
/// This tool signals the Docker entrypoint loop to restart the process by exiting cleanly
|
||||||
|
/// (exit code 0). User approval happens at the command level (via the web modal confirmation),
|
||||||
|
/// not at tool execution level. The `/restart` command is only callable via the web gateway
|
||||||
|
/// interface to prevent unauthorized restarts.
|
||||||
|
pub struct RestartTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for RestartTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"restart"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Restart the IronClaw agent process. The process exits cleanly (code 0) and the \
|
||||||
|
container entrypoint loop restarts it automatically within a few seconds."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"delay_secs": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Seconds to wait before exiting (default: 2, min: 1, max: 30)",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
tracing::info!("[RestartTool::execute] Restart tool invoked");
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Check if running inside a Docker container via IRONCLAW_IN_DOCKER env var.
|
||||||
|
// The Docker entrypoint sets this to "true". For local development, it's unset or "false".
|
||||||
|
// The entrypoint restart loop only works inside a Docker container (ironclaw-worker).
|
||||||
|
let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
|
||||||
|
.map(|v| v.to_lowercase() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
tracing::debug!("[RestartTool::execute] IRONCLAW_IN_DOCKER={}", in_docker);
|
||||||
|
|
||||||
|
if !in_docker {
|
||||||
|
tracing::error!("[RestartTool::execute] Not in Docker, rejecting restart");
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"Restart is only available when running inside the Docker container. \
|
||||||
|
For local development, please restart IronClaw manually."
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract delay_secs parameter, defaulting to 2 seconds
|
||||||
|
let delay = params
|
||||||
|
.get("delay_secs")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(2)
|
||||||
|
// Validate delay against schema bounds (1-30 seconds)
|
||||||
|
.clamp(1, 30);
|
||||||
|
tracing::info!("[RestartTool::execute] Delay set to {} seconds", delay);
|
||||||
|
|
||||||
|
// Spawn a background task so the response is flushed before exit.
|
||||||
|
// We use std::process::exit(0) to trigger a Docker container restart:
|
||||||
|
//
|
||||||
|
// - The ironclaw-worker Docker container runs an entrypoint loop that monitors
|
||||||
|
// the exit code of the `ironclaw run` process:
|
||||||
|
// * Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY
|
||||||
|
// (default 5s), then restart the process
|
||||||
|
// * Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES
|
||||||
|
// (default 10 failures)
|
||||||
|
//
|
||||||
|
// - std::process::exit(0) is a hard exit (no destructors, no graceful shutdown).
|
||||||
|
// This is intentional because:
|
||||||
|
// 1. The HTTP response must be sent before exit (hence tokio::spawn + delay)
|
||||||
|
// 2. In-flight jobs are paused/resumed by the entrypoint loop
|
||||||
|
// 3. Database connections are pooled and reopened on restart
|
||||||
|
// 4. The brief delay allows the response to flush before termination
|
||||||
|
//
|
||||||
|
// - Future improvement: implement graceful shutdown with CancellationToken
|
||||||
|
// to properly drain Axum, close DB connections, and checkpoint jobs.
|
||||||
|
// Check if restart is disabled (e.g., in tests). This allows tests to verify
|
||||||
|
// parameter parsing and output without actually terminating the process.
|
||||||
|
let restart_disabled = std::env::var("IRONCLAW_DISABLE_RESTART")
|
||||||
|
.map(|v| {
|
||||||
|
let v = v.to_lowercase();
|
||||||
|
v == "1" || v == "true"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[RestartTool::execute] Spawning background task to exit in {} seconds (disabled={})",
|
||||||
|
delay,
|
||||||
|
restart_disabled
|
||||||
|
);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tracing::info!("[RestartTool] Sleeping for {} seconds before exit", delay);
|
||||||
|
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||||
|
if !restart_disabled {
|
||||||
|
tracing::warn!("[RestartTool] Calling std::process::exit(0) NOW");
|
||||||
|
std::process::exit(0);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"[RestartTool] Exit disabled (IRONCLAW_DISABLE_RESTART set), skipping std::process::exit(0)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let msg = format!(
|
||||||
|
"Restarting in {delay} second(s). The process will exit cleanly and the \
|
||||||
|
entrypoint restart loop will bring IronClaw back online."
|
||||||
|
);
|
||||||
|
tracing::info!("[RestartTool::execute] Returning success response: {}", msg);
|
||||||
|
Ok(ToolOutput::text(msg, start.elapsed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Approval is handled at the command level (/restart via web modal confirmation),
|
||||||
|
// not at the tool execution level. By the time the tool executes, the user has already
|
||||||
|
// confirmed via the web interface. So we don't require approval here.
|
||||||
|
// This allows the tool to execute in autonomous jobs created from approved commands.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Helper to simulate Docker environment for testing
|
||||||
|
fn enable_docker_env() {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("IRONCLAW_IN_DOCKER", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_approval_handled_at_command_level() {
|
||||||
|
// Approval is handled at the /restart command level (web modal confirmation),
|
||||||
|
// not at tool execution. Tool execution approval is for user-interactive approvals
|
||||||
|
// that happen during job execution. The restart confirmation modal provides that gate.
|
||||||
|
let tool = RestartTool;
|
||||||
|
let approval = tool.requires_approval(&serde_json::json!({}));
|
||||||
|
// Default (Never) allows tool to execute in autonomous jobs created from approved commands
|
||||||
|
assert!(matches!(approval, ApprovalRequirement::Never));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_name() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
assert_eq!(tool.name(), "restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_parameters_schema() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
|
||||||
|
// Verify schema has delay_secs property with bounds
|
||||||
|
let props = schema.get("properties").unwrap();
|
||||||
|
assert!(props.get("delay_secs").is_some());
|
||||||
|
|
||||||
|
let delay_schema = props.get("delay_secs").unwrap();
|
||||||
|
assert_eq!(delay_schema.get("minimum").unwrap().as_u64().unwrap(), 1);
|
||||||
|
assert_eq!(delay_schema.get("maximum").unwrap().as_u64().unwrap(), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_requires_sanitization() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
assert!(!tool.requires_sanitization());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_delay_parameter_validation() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Test with valid delay
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 5}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().expect("result should be a string");
|
||||||
|
assert!(text.contains("Restarting in 5 second(s)"));
|
||||||
|
|
||||||
|
// Test with no delay parameter (should use default 2)
|
||||||
|
let result = tool.execute(serde_json::json!({}), &ctx).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().expect("result should be a string");
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_delay_clamping() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Test with too small delay (should clamp to 1)
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 0}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().expect("result should be a string");
|
||||||
|
assert!(text.contains("Restarting in 1 second(s)"));
|
||||||
|
|
||||||
|
// Test with too large delay (should clamp to 30)
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 100}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().expect("result should be a string");
|
||||||
|
assert!(text.contains("Restarting in 30 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_description() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
let desc = tool.description();
|
||||||
|
assert!(desc.contains("Restart"));
|
||||||
|
assert!(desc.contains("IronClaw"));
|
||||||
|
assert!(desc.contains("exits cleanly"));
|
||||||
|
assert!(desc.contains("code 0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_schema_completeness() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
|
||||||
|
// Verify schema structure
|
||||||
|
assert_eq!(schema.get("type").unwrap().as_str().unwrap(), "object");
|
||||||
|
|
||||||
|
let props = schema.get("properties").unwrap();
|
||||||
|
assert!(props.is_object());
|
||||||
|
|
||||||
|
let delay_schema = props.get("delay_secs").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
delay_schema.get("type").unwrap().as_str().unwrap(),
|
||||||
|
"integer"
|
||||||
|
);
|
||||||
|
assert!(delay_schema.get("description").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_boundary_values() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Test minimum boundary (exactly 1)
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 1}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 1 second(s)"));
|
||||||
|
|
||||||
|
// Test maximum boundary (exactly 30)
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 30}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 30 second(s)"));
|
||||||
|
|
||||||
|
// Test middle value
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 15}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 15 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_invalid_parameter_types() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// String instead of integer - should use default
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": "5"}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)")); // Falls back to default
|
||||||
|
|
||||||
|
// Null value - should use default
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": null}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)"));
|
||||||
|
|
||||||
|
// Float value - should use default (as_u64 fails on floats)
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 5.5}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_output_structure() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": 5}), &ctx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
|
||||||
|
// Verify ToolOutput structure
|
||||||
|
assert!(output.result.is_string());
|
||||||
|
assert!(output.duration.as_secs() == 0); // Should be nearly instant
|
||||||
|
assert!(output.cost.is_none()); // No cost tracking for restart
|
||||||
|
assert!(output.raw.is_none()); // No raw output stored
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_extra_parameters_ignored() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Extra parameters should be ignored
|
||||||
|
let result = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"delay_secs": 5,
|
||||||
|
"extra_field": "should be ignored",
|
||||||
|
"another": 123
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 5 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_negative_numbers() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Negative number should clamp to 1
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": -5}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
// as_u64() on negative number returns None, so falls to default 2
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_very_large_numbers() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Very large number should clamp to 30
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"delay_secs": u64::MAX}), &ctx)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 30 second(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_restart_tool_empty_object() {
|
||||||
|
enable_docker_env();
|
||||||
|
let tool = RestartTool;
|
||||||
|
let ctx = crate::context::JobContext::new("test", "test restart");
|
||||||
|
|
||||||
|
// Empty object params should use all defaults
|
||||||
|
let result = tool.execute(serde_json::json!({}), &ctx).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
let text = output.result.as_str().unwrap();
|
||||||
|
assert!(text.contains("Restarting in 2 second(s)"));
|
||||||
|
assert!(text.contains("exit cleanly"));
|
||||||
|
assert!(text.contains("entrypoint restart loop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_approval_consistent_regardless_of_params() {
|
||||||
|
let tool = RestartTool;
|
||||||
|
|
||||||
|
// Approval requirement should be the same regardless of params
|
||||||
|
let approval1 = tool.requires_approval(&serde_json::json!({"delay_secs": 5}));
|
||||||
|
let approval2 = tool.requires_approval(&serde_json::json!({"delay_secs": 100}));
|
||||||
|
let approval3 = tool.requires_approval(&serde_json::json!({}));
|
||||||
|
|
||||||
|
// All should return the default (Never) since approval happens at command level
|
||||||
|
assert!(matches!(approval1, ApprovalRequirement::Never));
|
||||||
|
assert!(matches!(approval2, ApprovalRequirement::Never));
|
||||||
|
assert!(matches!(approval3, ApprovalRequirement::Never));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_tool_requires_docker_environment() {
|
||||||
|
// Test that restart is rejected when not in Docker (IRONCLAW_IN_DOCKER not set or false)
|
||||||
|
// Uses sync test to avoid async/env var ordering issues with test parallelization.
|
||||||
|
let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
|
||||||
|
.map(|v| v.to_lowercase() == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Verify logic: when not in Docker, env var should be false/unset
|
||||||
|
if !in_docker {
|
||||||
|
// Simulating what the tool would do when IRONCLAW_IN_DOCKER is not set
|
||||||
|
assert!(
|
||||||
|
!in_docker,
|
||||||
|
"Test environment should have IRONCLAW_IN_DOCKER unset or false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -68,6 +68,8 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
|||||||
"skill_install",
|
"skill_install",
|
||||||
"skill_remove",
|
"skill_remove",
|
||||||
"message",
|
"message",
|
||||||
|
"web_fetch",
|
||||||
|
"restart",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Registry of available tools.
|
/// Registry of available tools.
|
||||||
@@ -155,7 +157,8 @@ impl ToolRegistry {
|
|||||||
|
|
||||||
/// Get a tool by name.
|
/// Get a tool by name.
|
||||||
pub async fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
pub async fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
||||||
self.tools.read().await.get(name).cloned()
|
let tools = self.tools.read().await;
|
||||||
|
tools.get(name).map(Arc::clone)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a tool exists.
|
/// Check if a tool exists.
|
||||||
@@ -209,11 +212,12 @@ impl ToolRegistry {
|
|||||||
let tools = self.tools.read().await;
|
let tools = self.tools.read().await;
|
||||||
names
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|name| tools.get(*name))
|
.filter_map(|name| {
|
||||||
.map(|tool| ToolDefinition {
|
tools.get(*name).map(|tool| ToolDefinition {
|
||||||
name: tool.name().to_string(),
|
name: tool.name().to_string(),
|
||||||
description: tool.description().to_string(),
|
description: tool.description().to_string(),
|
||||||
parameters: tool.parameters_schema(),
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user