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_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
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug
|
||||
|
||||
@@ -24,6 +24,15 @@ GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=3000
|
||||
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
|
||||
SANDBOX_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
|
||||
+12
-1
@@ -635,6 +635,10 @@ impl Agent {
|
||||
|
||||
// Parse submission type first
|
||||
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
|
||||
if let Submission::UserInput { ref content } = submission {
|
||||
@@ -719,7 +723,14 @@ impl Agent {
|
||||
.await
|
||||
}
|
||||
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::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?
|
||||
}
|
||||
MessageIntent::Command { command, args } => {
|
||||
match self.handle_command(&command, &args).await? {
|
||||
match self
|
||||
.handle_command(&command, &args, &message.channel)
|
||||
.await?
|
||||
{
|
||||
Some(s) => s,
|
||||
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
||||
}
|
||||
@@ -466,6 +469,7 @@ impl Agent {
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
channel: &str,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match command {
|
||||
"help" => Ok(SubmissionResult::response(concat!(
|
||||
@@ -501,12 +505,75 @@ impl Agent {
|
||||
" /heartbeat Run heartbeat check\n",
|
||||
" /summarize Summarize current thread\n",
|
||||
" /suggest Suggest next steps\n",
|
||||
" /restart Gracefully restart the process\n",
|
||||
"\n",
|
||||
" /quit Exit",
|
||||
))),
|
||||
|
||||
"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!(
|
||||
"{} v{}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
@@ -744,10 +811,11 @@ impl Agent {
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
channel: &str,
|
||||
) -> Result<Option<String>, Error> {
|
||||
// System commands are now handled directly via Submission::SystemCommand,
|
||||
// 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::Ok { message } => Ok(message),
|
||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||
|
||||
@@ -14,6 +14,7 @@ impl SubmissionParser {
|
||||
pub fn parse(content: &str) -> Submission {
|
||||
let trimmed = content.trim();
|
||||
let lower = trimmed.to_lowercase();
|
||||
tracing::debug!("[SubmissionParser::parse] Parsing input: {:?}", trimmed);
|
||||
|
||||
// Control commands (exact match or prefix)
|
||||
if lower == "/undo" {
|
||||
@@ -91,6 +92,13 @@ impl SubmissionParser {
|
||||
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") {
|
||||
let args: Vec<String> = trimmed
|
||||
.split_whitespace()
|
||||
|
||||
@@ -606,6 +606,12 @@ async fn chat_send_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<SendMessageRequest>,
|
||||
) -> 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() {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -621,6 +627,11 @@ async fn chat_send_handler(
|
||||
}
|
||||
|
||||
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 = tx_guard.as_ref().ok_or((
|
||||
@@ -628,6 +639,7 @@ async fn chat_send_handler(
|
||||
"Channel not started".to_string(),
|
||||
))?;
|
||||
|
||||
tracing::debug!("[chat_send_handler] Sending message through channel");
|
||||
tx.send(msg).await.map_err(|_| {
|
||||
(
|
||||
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((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(SendMessageResponse {
|
||||
@@ -2300,11 +2314,16 @@ async fn gateway_status_handler(
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
let restart_enabled = std::env::var("IRONCLAW_IN_DOCKER")
|
||||
.map(|v| v.to_lowercase() == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
Json(GatewayStatusResponse {
|
||||
sse_connections,
|
||||
ws_connections,
|
||||
total_connections: sse_connections + ws_connections,
|
||||
uptime_secs,
|
||||
restart_enabled,
|
||||
daily_cost,
|
||||
actions_this_hour,
|
||||
model_usage,
|
||||
@@ -2325,6 +2344,7 @@ struct GatewayStatusResponse {
|
||||
ws_connections: u64,
|
||||
total_connections: u64,
|
||||
uptime_secs: u64,
|
||||
restart_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
daily_cost: Option<String>,
|
||||
#[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 ---
|
||||
|
||||
function connectSSE() {
|
||||
@@ -143,6 +247,18 @@ function connectSSE() {
|
||||
eventSource.onopen = () => {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
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) {
|
||||
finalizeActivityGroup();
|
||||
loadHistory();
|
||||
@@ -163,6 +279,11 @@ function connectSSE() {
|
||||
enableChatInput();
|
||||
// Refresh thread list so new titles appear after first message
|
||||
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) => {
|
||||
@@ -181,6 +302,11 @@ function connectSSE() {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
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) => {
|
||||
|
||||
@@ -33,6 +33,48 @@
|
||||
</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) -->
|
||||
<div id="app">
|
||||
<!-- Tab Bar -->
|
||||
@@ -57,6 +99,14 @@
|
||||
<span id="sse-status">Connected</span>
|
||||
<div class="gateway-popover" id="gateway-popover"></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>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
|
||||
@@ -259,6 +259,284 @@ body {
|
||||
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 {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -9,6 +9,7 @@ mod json;
|
||||
mod memory;
|
||||
mod message;
|
||||
pub mod path_utils;
|
||||
mod restart;
|
||||
pub mod routine;
|
||||
pub mod secrets_tools;
|
||||
pub(crate) mod shell;
|
||||
@@ -28,6 +29,7 @@ pub use job::{
|
||||
pub use json::JsonTool;
|
||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||
pub use message::MessageTool;
|
||||
pub use restart::RestartTool;
|
||||
pub use routine::{
|
||||
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_remove",
|
||||
"message",
|
||||
"web_fetch",
|
||||
"restart",
|
||||
];
|
||||
|
||||
/// Registry of available tools.
|
||||
@@ -155,7 +157,8 @@ impl ToolRegistry {
|
||||
|
||||
/// Get a tool by name.
|
||||
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.
|
||||
@@ -209,11 +212,12 @@ impl ToolRegistry {
|
||||
let tools = self.tools.read().await;
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|name| tools.get(*name))
|
||||
.map(|tool| ToolDefinition {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
parameters: tool.parameters_schema(),
|
||||
.filter_map(|name| {
|
||||
tools.get(*name).map(|tool| ToolDefinition {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
parameters: tool.parameters_schema(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user