Merge pull request #1387 from nearai/staging-promote/ec04354c-23271447493

chore: promote staging to main (2026-03-18 23:07 UTC)
This commit is contained in:
Henry Park
2026-03-19 10:35:49 -07:00
committed by GitHub
39 changed files with 3938 additions and 508 deletions
+41 -6
View File
@@ -43,12 +43,42 @@ jobs:
fi
fi
if [ "$IS_FIX" = false ]; then
echo "Not a fix PR — skipping regression test check."
# --- 1b. Does this PR touch high-risk state machine or resilience code? ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
TOUCHES_HIGH_RISK=false
HIGH_RISK_PATTERNS=(
"src/context/state.rs"
"src/agent/session.rs"
"src/llm/circuit_breaker.rs"
"src/llm/retry.rs"
"src/llm/failover.rs"
"src/agent/self_repair.rs"
"src/agent/agentic_loop.rs"
"src/tools/execute.rs"
"crates/ironclaw_safety/src/"
)
for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
TOUCHES_HIGH_RISK=true
echo "High-risk file matched: $pattern"
break
fi
done
# Skip only if NEITHER condition holds — no double-firing on fix PRs
if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
echo "Not a fix PR and no high-risk files changed — skipping."
exit 0
fi
echo "Fix PR detected."
if [ "$IS_FIX" = true ]; then
echo "Fix PR detected."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "High-risk state machine or resilience code modified."
fi
# --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
@@ -63,8 +93,6 @@ jobs:
fi
# --- 3. Exempt static-only / docs-only changes ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping."
exit 0
@@ -110,5 +138,12 @@ jobs:
fi
# --- 5. No tests found ---
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
if [ "$IS_FIX" = true ]; then
echo "::warning::This PR looks like a bug fix but contains no test changes."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
fi
echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
exit 1
+222 -19
View File
@@ -360,6 +360,8 @@ enum TelegramStatusAction {
}
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
/// Telegram's hard limit for message text length.
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
@@ -371,6 +373,73 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}
/// Split a long message into chunks that fit within Telegram's 4096-char limit.
///
/// Tries to split at the most natural boundary available (in priority order):
/// 1. Double newline (paragraph break)
/// 2. Single newline
/// 3. Sentence end (`. `, `! `, `? `)
/// 4. Word boundary (space)
/// 5. Hard cut at the limit (last resort for pathological input)
fn split_message(text: &str) -> Vec<String> {
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
return vec![text.to_string()];
}
let mut chunks: Vec<String> = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
// Count chars to find the byte offset for our window.
let window_bytes = remaining
.char_indices()
.take(TELEGRAM_MAX_MESSAGE_LEN)
.last()
.map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
.unwrap_or(remaining.len());
if window_bytes >= remaining.len() {
// Remainder fits entirely.
chunks.push(remaining.to_string());
break;
}
let window = &remaining[..window_bytes];
// 1. Double newline — best paragraph boundary
let split_at = window.rfind("\n\n")
// 2. Single newline
.or_else(|| window.rfind('\n'))
// 3. Sentence-ending punctuation followed by space.
// Note: this only detects ASCII punctuation (. ! ?), not CJK
// sentence-ending marks (。!?). CJK text falls through to
// word-boundary or hard-cut splitting.
.or_else(|| {
let bytes = window.as_bytes();
// Search backwards for '. ', '! ', '? '
(1..bytes.len()).rev().find(|&i| {
matches!(bytes[i - 1], b'.' | b'!' | b'?') && bytes[i] == b' '
})
})
// 4. Word boundary (last space)
.or_else(|| window.rfind(' '))
// 5. Hard cut
.unwrap_or(window_bytes);
// Avoid empty chunks (e.g. text starting with \n\n).
let split_at = if split_at == 0 { window_bytes } else { split_at };
// Trim whitespace at chunk boundaries for clean Telegram display.
// Note: this drops leading/trailing spaces at split points, which is
// acceptable for chat messages but means the concatenation of chunks
// may not exactly equal the original text when split at spaces.
chunks.push(remaining[..split_at].trim_end().to_string());
remaining = remaining[split_at..].trim_start();
}
chunks
}
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
let message = update.message.trim();
if message.is_empty() {
@@ -1242,26 +1311,64 @@ fn send_response(
return Ok(());
}
// Try Markdown, fall back to plain text on parse errors
match send_message(
chat_id,
&response.content,
reply_to_message_id,
Some("Markdown"),
message_thread_id,
) {
Ok(_) => Ok(()),
Err(SendError::ParseEntities(_)) => send_message(
chat_id,
&response.content,
reply_to_message_id,
None,
message_thread_id,
)
.map(|_| ())
.map_err(|e| format!("Plain-text retry also failed: {}", e)),
Err(e) => Err(e.to_string()),
// Split large messages into chunks that fit Telegram's limit.
let chunks = split_message(&response.content);
let total = chunks.len();
// The first chunk replies to the original message; subsequent chunks
// reply to the previously sent chunk so they form a visual thread.
let mut reply_to = reply_to_message_id;
for (i, chunk) in chunks.into_iter().enumerate() {
// Try Markdown, fall back to plain text on parse errors
let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
let msg_id = match result {
Ok(id) => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent message chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(SendError::ParseEntities(detail)) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Markdown parse failed on chunk {}/{} ({}), retrying as plain text",
i + 1,
total,
detail
),
);
let id = send_message(chat_id, &chunk, reply_to, None, message_thread_id)
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent plain-text chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(e) => return Err(e.to_string()),
};
// Each subsequent chunk threads off the previous sent message.
reply_to = Some(msg_id);
}
Ok(())
}
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
@@ -2043,6 +2150,102 @@ export!(TelegramChannel);
mod tests {
use super::*;
#[test]
fn test_split_message_short() {
let text = "Hello, world!";
let chunks = split_message(text);
assert_eq!(chunks, vec![text]);
}
#[test]
fn test_split_message_paragraph_boundary() {
let para_a = "A".repeat(3000);
let para_b = "B".repeat(3000);
let text = format!("{}\n\n{}", para_a, para_b);
let chunks = split_message(&text);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], para_a);
assert_eq!(chunks[1], para_b);
}
#[test]
fn test_split_message_word_boundary() {
// Build a string well over the limit with no newlines.
let words: Vec<String> = (0..1000).map(|i| format!("word{:04}", i)).collect();
let text = words.join(" ");
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined chunks must equal the original text exactly.
let rejoined = chunks.join(" ");
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_each_chunk_fits() {
// Stress-test: 20 000 chars of mixed text.
let text: String = (0..500)
.map(|i| format!("Sentence number {}. ", i))
.collect();
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
}
#[test]
fn test_split_message_sentence_boundary() {
// Build text that exceeds the limit, with sentence boundaries inside.
let sentence = "This is a test sentence. ";
let repeat_count = TELEGRAM_MAX_MESSAGE_LEN / sentence.len() + 5;
let text: String = sentence.repeat(repeat_count);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1);
// First chunk should end at a sentence boundary (trimmed)
let first = &chunks[0];
assert!(
first.ends_with('.'),
"First chunk should end at a sentence boundary, got: ...{}",
&first[first.len().saturating_sub(20)..]
);
}
#[test]
fn test_split_message_hard_cut_no_spaces() {
// Pathological input: a single huge "word" with no spaces or newlines.
let text = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN * 2 + 100);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined must preserve all characters
let rejoined: String = chunks.concat();
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_multibyte_chars() {
// Emoji are 4 bytes each. Ensure we don't panic or split mid-character.
let emoji = "\u{1F600}"; // 😀
let text: String = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN + 100);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
// Every char should be a complete emoji
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
}
}
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.2.4",
"version": "0.2.5",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
+11 -2
View File
@@ -146,6 +146,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// The main agent that coordinates all components.
@@ -340,11 +342,18 @@ impl Agent {
let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding
let repair = Arc::new(DefaultSelfRepair::new(
let mut self_repair = DefaultSelfRepair::new(
self.context_manager.clone(),
self.config.stuck_threshold,
self.config.max_repair_attempts,
));
);
if let Some(ref store) = self.deps.store {
self_repair = self_repair.with_store(Arc::clone(store));
}
if let Some(ref builder) = self.deps.builder {
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
}
let repair = Arc::new(self_repair);
let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
+3
View File
@@ -1197,6 +1197,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -2037,6 +2038,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -2155,6 +2157,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
+389 -43
View File
@@ -10,6 +10,7 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -25,17 +26,17 @@ use crate::agent::routine::{
};
use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::context::{JobContext, JobState};
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
};
use crate::workspace::Workspace;
use ironclaw_safety::SafetyLayer;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
@@ -60,6 +61,10 @@ pub struct RoutineEngine {
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
/// Timestamp when this engine instance was created. Used by
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
/// process) from actively-watched runs (from this process).
boot_time: chrono::DateTime<Utc>,
}
impl RoutineEngine {
@@ -85,6 +90,7 @@ impl RoutineEngine {
scheduler,
tools,
safety,
boot_time: Utc::now(),
}
}
@@ -145,6 +151,15 @@ impl RoutineEngine {
/// message content) so callers never need to clone a full `IncomingMessage`.
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no message matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::Message { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -161,16 +176,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
return 0;
}
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
};
for matcher in cache.iter() {
@@ -235,6 +243,15 @@ impl RoutineEngine {
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no system-event matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::System { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -251,19 +268,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!(
"Failed to batch-load concurrent counts for system events: {}",
e
);
return 0;
}
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
};
for matcher in cache.iter() {
@@ -337,6 +344,23 @@ impl RoutineEngine {
fired
}
/// Batch-load concurrent run counts for a set of routine IDs.
///
/// Returns `None` on database error (already logged).
async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option<HashMap<Uuid, i64>> {
match self
.store
.count_running_routine_runs_batch(routine_ids)
.await
{
Ok(counts) => Some(counts),
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
None
}
}
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -371,6 +395,230 @@ impl RoutineEngine {
}
}
/// Reconcile orphaned full_job routine runs with their linked job outcomes.
///
/// Called on each cron tick. Finds routine runs that are still `running`
/// with a linked `job_id`, checks the job state, and finalizes the run
/// when the job reaches a completed or terminal state.
///
/// Only processes runs started **before** this engine's boot time, so it
/// never races with `FullJobWatcher` instances from the current process.
/// This makes it safe to call on every tick as a crash-recovery mechanism.
pub async fn sync_dispatched_runs(&self) {
let runs = match self.store.list_dispatched_routine_runs().await {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to list dispatched routine runs: {}", e);
return;
}
};
// Only process runs from a previous process instance. Runs started
// after boot_time are actively watched by a FullJobWatcher in this
// process and should not be finalized here.
let orphaned: Vec<_> = runs
.into_iter()
.filter(|r| r.started_at < self.boot_time)
.collect();
if orphaned.is_empty() {
return;
}
tracing::info!(
"Recovering {} orphaned dispatched routine runs",
orphaned.len()
);
for run in orphaned {
let job_id = match run.job_id {
Some(id) => id,
None => continue, // Should not happen (query filters), but guard anyway
};
// Fetch the linked job
let job = match self.store.get_job(job_id).await {
Ok(Some(j)) => j,
Ok(None) => {
// Orphaned: job record was deleted or never persisted
tracing::warn!(
run_id = %run.id,
job_id = %job_id,
"Linked job not found, marking routine run as failed"
);
self.complete_dispatched_run(
&run,
RunStatus::Failed,
&format!("Linked job {job_id} not found (orphaned)"),
)
.await;
continue;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
job_id = %job_id,
"Failed to fetch linked job: {}", e
);
continue;
}
};
// Map job state to final run status
let final_status = match job.state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
// Pending, InProgress, Stuck — still running
_ => None,
};
let status = match final_status {
Some(s) => s,
None => continue, // Job still active, check again next tick
};
// Build summary
let summary = if status == RunStatus::Failed {
match self.store.get_agent_job_failure_reason(job_id).await {
Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
_ => format!("Job {job_id} {}", job.state),
}
} else {
format!("Job {job_id} completed successfully")
};
self.complete_dispatched_run(&run, status, &summary).await;
}
}
/// Finalize a dispatched routine run: update DB, update routine runtime,
/// persist to conversation thread, and send notification.
async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
// Complete the run record in DB
if let Err(e) = self
.store
.complete_routine_run(run.id, status, Some(summary), None)
.await
{
tracing::error!(
run_id = %run.id,
"Failed to complete dispatched routine run: {}", e
);
return;
}
tracing::info!(
run_id = %run.id,
status = %status,
"Finalized dispatched routine run"
);
// Load the routine to update consecutive_failures and send notification
let routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
run_id = %run.id,
routine_id = %run.routine_id,
"Routine not found for dispatched run finalization"
);
return;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
"Failed to load routine for dispatched run: {}", e
);
return;
}
};
// Update runtime fields. In crash recovery, execute_routine() never
// reached its normal runtime update, so we must advance all fields here.
let new_failures = if status == RunStatus::Failed {
routine.consecutive_failures + 1
} else {
0
};
let now = Utc::now();
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
if let Err(e) = self
.store
.update_routine_runtime(
routine.id,
now,
next_fire,
routine.run_count + 1,
new_failures,
&routine.state,
)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to update routine runtime after dispatched run: {}", e
);
}
// Persist result to the routine's conversation thread
let thread_id = match self
.store
.get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
.await
{
Ok(conv_id) => {
let msg = format!("[dispatched] {}: {}", status, summary);
if let Err(e) = self
.store
.add_conversation_message(conv_id, "assistant", &msg)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to persist dispatched run message: {}", e
);
}
Some(conv_id.to_string())
}
Err(e) => {
tracing::error!(
routine = %routine.name,
"Failed to get routine conversation: {}", e
);
None
}
};
// Send notification
send_notification(
&self.notify_tx,
&routine.notify,
&routine.user_id,
&routine.name,
status,
Some(summary),
thread_id.as_deref(),
)
.await;
// Note: we do NOT decrement running_count here. In normal flow,
// execute_routine() handles that after FullJobWatcher returns.
// This sync path only runs for crash recovery (process restarted),
// where running_count was already reset to 0.
}
/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
@@ -548,7 +796,11 @@ impl FullJobWatcher {
// if the job is already done (e.g. fast-failing jobs).
match self.store.get_job(self.job_id).await {
Ok(Some(job_ctx)) => {
if !job_ctx.state.is_active() {
// Use is_parallel_blocking (Pending/InProgress/Stuck) instead
// of is_active (!is_terminal) because routine jobs typically
// stop at Completed — which is NOT terminal but IS finished
// from an execution standpoint.
if !job_ctx.state.is_parallel_blocking() {
break Self::map_job_state(&job_ctx.state);
}
}
@@ -816,13 +1068,16 @@ async fn execute_full_job(
reason: format!("failed to dispatch job: {e}"),
})?;
// Link the routine run to the dispatched job
if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
tracing::error!(
routine = %routine.name,
"Failed to link run to job: {}", e
);
}
// Link the routine run to the dispatched job.
// This MUST succeed — if it fails, sync_dispatched_runs() will never find
// this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
// with running_count permanently elevated.
ctx.store
.link_routine_run_to_job(run.id, job_id)
.await
.map_err(|e| RoutineError::Database {
reason: format!("failed to link run to job: {e}"),
})?;
tracing::info!(
routine = %routine.name,
@@ -1041,8 +1296,8 @@ fn handle_text_response(
};
}
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
// Check for the "nothing to do" sentinel (exact match on trimmed content).
if content == "ROUTINE_OK" {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
@@ -1408,14 +1663,22 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Run one check immediately so routines due at startup don't wait
// an extra full polling interval.
// Recover orphaned runs from a previous process crash before
// dispatching any new work, so we don't confuse fresh dispatches
// with crash orphans.
engine.sync_dispatched_runs().await;
// Run one cron check immediately so routines due at startup don't
// wait an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
// Sync first: only processes runs from before boot_time, so it
// never races with FullJobWatcher instances from this process.
engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
}
})
@@ -1582,20 +1845,21 @@ mod tests {
#[test]
fn test_routine_sentinel_detection_exact_match() {
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
// After trim(), whitespace is removed
// Sentinel detection uses exact match on trimmed content to avoid
// false positives from substrings like "NOT_ROUTINE_OK".
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", true),
("ROUTINE_OK is done", true),
("done ROUTINE_OK", true),
("something ROUTINE_OK something", false), // substring no longer matches
("ROUTINE_OK is done", false), // substring no longer matches
("done ROUTINE_OK", false), // substring no longer matches
("NOT_ROUTINE_OK", false), // exact match prevents this
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
let matches = trimmed == "ROUTINE_OK";
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
@@ -1709,4 +1973,86 @@ mod tests {
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
}
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
#[test]
fn test_full_job_watcher_state_mapping() {
use crate::context::JobState;
// Failed/Cancelled → RunStatus::Failed
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Failed),
RunStatus::Failed
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Cancelled),
RunStatus::Failed
);
// All other non-active states → RunStatus::Ok
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Completed),
RunStatus::Ok
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Accepted),
RunStatus::Ok
);
}
/// Verify that job state to run status mapping covers all expected cases.
#[test]
fn test_job_state_to_run_status_mapping() {
use crate::context::JobState;
// Success states
for state in [JobState::Completed, JobState::Submitted, JobState::Accepted] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Ok),
"{:?} should map to RunStatus::Ok",
state
);
}
// Failure states
for state in [JobState::Failed, JobState::Cancelled] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Failed),
"{:?} should map to RunStatus::Failed",
state
);
}
// Active states (should not finalize)
for state in [JobState::Pending, JobState::InProgress, JobState::Stuck] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status, None,
"{:?} should not finalize the routine run",
state
);
}
}
}
+259 -17
View File
@@ -66,14 +66,10 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>,
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
#[allow(dead_code)]
stuck_threshold: Duration,
max_repair_attempts: u32,
store: Option<Arc<dyn Database>>,
builder: Option<Arc<dyn SoftwareBuilder>>,
// TODO: use for tool hot-reload after repair
#[allow(dead_code)]
tools: Option<Arc<ToolRegistry>>,
}
@@ -95,15 +91,13 @@ impl DefaultSelfRepair {
}
/// Add a Store for tool failure tracking.
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
self.store = Some(store);
self
}
/// Add a Builder and ToolRegistry for automatic tool repair.
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
pub(crate) fn with_builder(
pub fn with_builder(
mut self,
builder: Arc<dyn SoftwareBuilder>,
tools: Arc<ToolRegistry>,
@@ -124,18 +118,30 @@ impl SelfRepair for DefaultSelfRepair {
if let Ok(ctx) = self.context_manager.get_context(job_id).await
&& ctx.state == JobState::Stuck
{
let stuck_duration = ctx
.started_at
.map(|start| {
let now = Utc::now();
let duration = now.signed_duration_since(start);
// Measure stuck_duration from the most recent Stuck transition,
// not from started_at (which reflects when the job first ran).
let stuck_since = ctx
.transitions
.iter()
.rev()
.find(|t| t.to == JobState::Stuck)
.map(|t| t.timestamp);
let stuck_duration = stuck_since
.map(|ts| {
let duration = Utc::now().signed_duration_since(ts);
Duration::from_secs(duration.num_seconds().max(0) as u64)
})
.unwrap_or_default();
// Only report jobs that have been stuck long enough
if stuck_duration < self.stuck_threshold {
continue;
}
stuck_jobs.push(StuckJob {
job_id,
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
last_activity: stuck_since.unwrap_or(ctx.created_at),
stuck_duration,
last_error: None,
repair_attempts: ctx.repair_attempts,
@@ -273,9 +279,8 @@ impl SelfRepair for DefaultSelfRepair {
tracing::warn!("Failed to mark tool as repaired: {}", e);
}
// Log if the tool was auto-registered
if result.registered {
tracing::info!("Repaired tool '{}' auto-registered", tool.name);
tracing::info!("Repaired tool '{}' auto-registered by builder", tool.name);
}
Ok(RepairResult::Success {
@@ -417,7 +422,8 @@ mod tests {
.unwrap()
.unwrap();
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
// Use zero threshold so the just-stuck job is detected immediately.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id);
@@ -483,6 +489,98 @@ mod tests {
);
}
#[tokio::test]
async fn detect_stuck_jobs_filters_by_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a very large threshold (1 hour). Job just became stuck, so
// stuck_duration < threshold. It should be filtered out.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(3600), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should be filtered by 1h threshold"
);
}
#[tokio::test]
async fn detect_stuck_jobs_includes_when_over_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a zero threshold -- any stuck duration should be included.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1, "Job should be detected with zero threshold");
assert_eq!(stuck[0].job_id, job_id);
}
/// Regression: stuck_duration must be measured from the Stuck transition,
/// not from started_at. A job that ran for 2 hours before becoming stuck
/// should NOT immediately exceed a 5-minute threshold.
#[tokio::test]
async fn stuck_duration_measured_from_stuck_transition_not_started_at() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Long runner", "desc").await.unwrap();
// Transition to InProgress (sets started_at to now).
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
// Backdate started_at to 2 hours ago to simulate a long-running job.
cm.update_context(job_id, |ctx| {
ctx.started_at = Some(Utc::now() - chrono::Duration::hours(2));
Ok::<(), crate::error::Error>(())
})
.await
.unwrap()
.unwrap();
// Now transition to Stuck (stuck transition timestamp is ~now).
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("wedged".into()))
})
.await
.unwrap()
.unwrap();
// With a 5-minute threshold, the job JUST became stuck — should NOT be detected.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(300), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should not exceed 5min threshold, \
but stuck_duration was computed from started_at (2h ago)"
);
}
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
@@ -515,4 +613,148 @@ mod tests {
result
);
}
/// Mock SoftwareBuilder that returns a successful build result.
struct MockBuilder {
build_count: std::sync::atomic::AtomicU32,
}
impl MockBuilder {
fn new() -> Self {
Self {
build_count: std::sync::atomic::AtomicU32::new(0),
}
}
fn builds(&self) -> u32 {
self.build_count.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl crate::tools::SoftwareBuilder for MockBuilder {
async fn analyze(
&self,
_description: &str,
) -> Result<crate::tools::BuildRequirement, crate::error::ToolError> {
Ok(crate::tools::BuildRequirement {
name: "mock-tool".to_string(),
description: "mock".to_string(),
software_type: crate::tools::SoftwareType::WasmTool,
language: crate::tools::Language::Rust,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
})
}
async fn build(
&self,
requirement: &crate::tools::BuildRequirement,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
self.build_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(crate::tools::BuildResult {
build_id: Uuid::new_v4(),
requirement: requirement.clone(),
artifact_path: std::path::PathBuf::from("/tmp/mock.wasm"),
logs: vec![],
success: true,
error: None,
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 1,
validation_warnings: vec![],
tests_passed: 1,
tests_failed: 0,
registered: true,
})
}
async fn repair(
&self,
_result: &crate::tools::BuildResult,
_error: &str,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
unimplemented!("not needed for this test")
}
}
/// E2E test: stuck job detected -> repaired -> transitions back to InProgress,
/// and broken tool detected -> builder invoked -> tool marked repaired.
#[cfg(feature = "libsql")]
#[tokio::test]
async fn e2e_stuck_job_repair_and_tool_rebuild() {
// --- Setup ---
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("E2E stuck job", "desc").await.unwrap();
// Transition job: Pending -> InProgress -> Stuck
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("deadlocked".to_string()))
})
.await
.unwrap()
.unwrap();
// Create a mock builder and a real test database (for store)
let builder = Arc::new(MockBuilder::new());
let tools = Arc::new(ToolRegistry::new());
let (db, _tmp_dir) = crate::testing::test_db().await;
// Create self-repair with zero threshold (detect immediately),
// wired with store, builder, and tools.
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
.with_store(Arc::clone(&db))
.with_builder(
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
tools,
);
// --- Phase 1: Detect and repair stuck job ---
let stuck_jobs = repair.detect_stuck_jobs().await;
assert_eq!(stuck_jobs.len(), 1, "Should detect the stuck job");
assert_eq!(stuck_jobs[0].job_id, job_id);
let result = repair.repair_stuck_job(&stuck_jobs[0]).await.unwrap();
assert!(
matches!(result, RepairResult::Success { .. }),
"Job repair should succeed: {:?}",
result
);
// Verify job transitioned back to InProgress
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(
ctx.state,
JobState::InProgress,
"Job should be back to InProgress after repair"
);
// --- Phase 2: Repair a broken tool via builder ---
let broken = BrokenTool {
name: "broken-wasm-tool".to_string(),
failure_count: 10,
last_error: Some("panic in tool execution".to_string()),
first_failure: Utc::now() - chrono::Duration::hours(1),
last_failure: Utc::now(),
last_build_result: None,
repair_attempts: 0,
};
let tool_result = repair.repair_broken_tool(&broken).await.unwrap();
assert!(
matches!(tool_result, RepairResult::Success { .. }),
"Tool repair should succeed with mock builder: {:?}",
tool_result
);
// Verify builder was actually invoked
assert_eq!(builder.builds(), 1, "Builder should have been called once");
}
}
+12 -6
View File
@@ -56,6 +56,7 @@ pub struct AppComponents {
pub session: Arc<SessionManager>,
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
pub dev_loaded_tool_names: Vec<String>,
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// Options that control optional init phases.
@@ -280,6 +281,7 @@ impl AppBuilder {
Arc<ToolRegistry>,
Option<Arc<dyn EmbeddingProvider>>,
Option<Arc<Workspace>>,
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
),
anyhow::Error,
> {
@@ -367,16 +369,19 @@ impl AppBuilder {
}
// Register builder tool if enabled
if self.config.builder.enabled
let builder = if self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
tools
let b = tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::debug!("Builder mode enabled");
}
tracing::info!("Builder mode enabled");
Some(b)
} else {
None
};
Ok((safety, tools, embeddings, workspace))
Ok((safety, tools, embeddings, workspace, builder))
}
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -699,7 +704,7 @@ impl AppBuilder {
} else {
self.init_llm().await?
};
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
@@ -819,6 +824,7 @@ impl AppBuilder {
session: self.session,
catalog_entries,
dev_loaded_tool_names,
builder,
})
}
}
+8
View File
@@ -102,6 +102,7 @@ impl GatewayChannel {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -139,6 +140,7 @@ impl GatewayChannel {
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -250,6 +252,12 @@ impl GatewayChannel {
self
}
/// Inject the active (resolved) configuration snapshot for the status endpoint.
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
self.rebuild_state(|s| s.active_config = config);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
+17
View File
@@ -126,6 +126,14 @@ impl RateLimiter {
}
}
/// Snapshot of the active (resolved) configuration exposed to the frontend.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct ActiveConfigSnapshot {
pub llm_backend: String,
pub llm_model: String,
pub enabled_channels: Vec<String>,
}
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
@@ -177,6 +185,8 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
}
/// Start the gateway HTTP server.
@@ -2669,6 +2679,9 @@ async fn gateway_status_handler(
daily_cost,
actions_this_hour,
model_usage,
llm_backend: state.active_config.llm_backend.clone(),
llm_model: state.active_config.llm_model.clone(),
enabled_channels: state.active_config.enabled_channels.clone(),
})
}
@@ -2694,6 +2707,9 @@ struct GatewayStatusResponse {
actions_this_hour: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option<Vec<ModelUsageEntry>>,
llm_backend: String,
llm_model: String,
enabled_channels: Vec<String>,
}
#[cfg(test)]
@@ -2890,6 +2906,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
})
}
File diff suppressed because it is too large Load Diff
+170 -10
View File
@@ -29,9 +29,15 @@ I18n.register('en', {
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
'settings.inference': 'Inference',
'settings.agent': 'Agent',
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -131,10 +137,10 @@ I18n.register('en', {
// Extensions Tab
'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available WASM Extensions',
'extensions.installWasm': 'Install WASM Extension',
'extensions.available': 'Available Extensions',
'extensions.installWasm': 'Install Extension',
'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional WASM extensions available',
'extensions.noAvailable': 'No additional extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
@@ -156,13 +162,8 @@ I18n.register('en', {
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
// Registered Tools
'tools.registered': 'Registered Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.empty': 'No tools registered',
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
@@ -302,6 +303,7 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -328,6 +330,8 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
'ext.inactive': 'Inactive',
'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -355,4 +359,160 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
// Settings toolbar
'settings.export': 'Export',
'settings.import': 'Import',
'settings.searchPlaceholder': 'Search settings...',
'settings.exportSuccess': 'Settings exported',
'settings.exportFailed': 'Export failed: {message}',
'settings.importSuccess': 'Settings imported successfully',
'settings.importFailed': 'Import failed: {message}',
'settings.restartRequired': 'Restart required for changes to take effect.',
'settings.restartNow': 'Restart Now',
'settings.noMatchingSettings': 'No settings matching "{query}"',
'settings.noSettings': 'No settings found',
'settings.saved': 'Saved',
'settings.on': 'On',
'settings.off': 'Off',
'settings.envValue': 'env: {value}',
'settings.envDefault': 'env default',
'settings.useEnvDefault': 'use env default',
// Settings groups
'cfg.group.llm': 'LLM Provider',
'cfg.group.embeddings': 'Embeddings',
'cfg.group.agent': 'Agent',
'cfg.group.heartbeat': 'Heartbeat',
'cfg.group.sandbox': 'Sandbox',
'cfg.group.routines': 'Routines',
'cfg.group.safety': 'Safety',
'cfg.group.skills': 'Skills',
'cfg.group.search': 'Search',
'cfg.group.tunnel': 'Tunnel',
'cfg.group.gateway': 'Gateway',
// Inference settings
'cfg.llm_backend.label': 'Backend',
'cfg.llm_backend.desc': 'LLM inference provider',
'cfg.selected_model.label': 'Model',
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
'cfg.bedrock_region.label': 'Bedrock Region',
'cfg.bedrock_region.desc': 'AWS region for Bedrock',
'cfg.bedrock_cross_region.label': 'Cross-Region',
'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
'cfg.bedrock_profile.label': 'AWS Profile',
'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
'cfg.embeddings_enabled.label': 'Enabled',
'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
'cfg.embeddings_provider.label': 'Provider',
'cfg.embeddings_provider.desc': 'Embeddings API provider',
'cfg.embeddings_model.label': 'Model',
'cfg.embeddings_model.desc': 'Embedding model name',
// Agent settings
'cfg.agent_name.label': 'Name',
'cfg.agent_name.desc': 'Agent display name',
'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
'cfg.agent_job_timeout.label': 'Job Timeout',
'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
'cfg.agent_use_planning.label': 'Planning',
'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
'cfg.agent_auto_approve.label': 'Auto-approve Tools',
'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
'cfg.agent_timezone.label': 'Timezone',
'cfg.agent_timezone.desc': 'Default timezone (IANA)',
'cfg.agent_session_idle.label': 'Session Idle Timeout',
'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
'cfg.agent_max_repair.label': 'Max Repair Attempts',
'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
'cfg.agent_max_cost.label': 'Max Daily Cost',
'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
'cfg.agent_max_actions.label': 'Max Actions/Hour',
'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
'cfg.agent_allow_local.label': 'Allow Local Tools',
'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
// Heartbeat settings
'cfg.heartbeat_enabled.label': 'Enabled',
'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
'cfg.heartbeat_interval.label': 'Interval',
'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
'cfg.heartbeat_notify_channel.label': 'Notify Channel',
'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
'cfg.heartbeat_notify_user.label': 'Notify User',
'cfg.heartbeat_notify_user.desc': 'User ID to notify',
'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
'cfg.heartbeat_timezone.label': 'Timezone',
'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
// Sandbox settings
'cfg.sandbox_enabled.label': 'Enabled',
'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
'cfg.sandbox_policy.label': 'Policy',
'cfg.sandbox_policy.desc': 'Sandbox security policy',
'cfg.sandbox_timeout.label': 'Timeout',
'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
'cfg.sandbox_memory.label': 'Memory Limit',
'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
'cfg.sandbox_image.label': 'Docker Image',
'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
// Routines settings
'cfg.routines_max_concurrent.label': 'Max Concurrent',
'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
'cfg.routines_cooldown.label': 'Default Cooldown',
'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
// Safety settings
'cfg.safety_max_output.label': 'Max Output Length',
'cfg.safety_max_output.desc': 'Maximum output tokens per response',
'cfg.safety_injection_check.label': 'Injection Check',
'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
// Skills settings
'cfg.skills_max_active.label': 'Max Active Skills',
'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
'cfg.skills_max_tokens.label': 'Max Context Tokens',
'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
// Search settings
'cfg.search_fusion.label': 'Fusion Strategy',
'cfg.search_fusion.desc': 'Hybrid search ranking method',
// Networking settings
'cfg.tunnel_provider.label': 'Provider',
'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
'cfg.tunnel_public_url.label': 'Public URL',
'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
'cfg.gateway_rate_limit.label': 'Rate Limit',
'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
'cfg.gateway_max_connections.label': 'Max Connections',
'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
// Channels subtab
'channels.builtin': 'Built-in Channels',
'channels.messaging': 'Messaging Channels',
'channels.webGateway': 'Web Gateway',
'channels.webGatewayDesc': 'Browser-based chat interface',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
'channels.cli': 'CLI',
'channels.cliDesc': 'Terminal UI with Ratatui',
'channels.repl': 'REPL',
'channels.replDesc': 'Simple read-eval-print loop for testing',
'channels.configureVia': 'Configure via {env}',
'channels.runWith': 'Run with: {cmd}',
});
+170 -10
View File
@@ -29,9 +29,15 @@ I18n.register('zh-CN', {
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
'settings.inference': '推理',
'settings.agent': '代理',
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -131,10 +137,10 @@ I18n.register('zh-CN', {
// 扩展标签页
'extensions.installed': '已安装扩展',
'extensions.available': '可用 WASM 扩展',
'extensions.installWasm': '安装 WASM 扩展',
'extensions.available': '可用扩展',
'extensions.installWasm': '安装扩展',
'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
'extensions.noAvailable': '没有其他可用扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
@@ -156,13 +162,8 @@ I18n.register('zh-CN', {
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
@@ -302,6 +303,7 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -328,6 +330,8 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
'ext.inactive': '未激活',
'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -354,4 +358,160 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
// 设置工具栏
'settings.export': '导出',
'settings.import': '导入',
'settings.searchPlaceholder': '搜索设置...',
'settings.exportSuccess': '设置已导出',
'settings.exportFailed': '导出失败: {message}',
'settings.importSuccess': '设置导入成功',
'settings.importFailed': '导入失败: {message}',
'settings.restartRequired': '需要重启才能使更改生效。',
'settings.restartNow': '立即重启',
'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
'settings.noSettings': '未找到设置',
'settings.saved': '已保存',
'settings.on': '开启',
'settings.off': '关闭',
'settings.envValue': '环境变量: {value}',
'settings.envDefault': '使用环境变量默认值',
'settings.useEnvDefault': '使用环境变量默认值',
// 设置分组
'cfg.group.llm': 'LLM 提供商',
'cfg.group.embeddings': '嵌入向量',
'cfg.group.agent': '代理',
'cfg.group.heartbeat': '心跳',
'cfg.group.sandbox': '沙箱',
'cfg.group.routines': '定时任务',
'cfg.group.safety': '安全',
'cfg.group.skills': '技能',
'cfg.group.search': '搜索',
'cfg.group.tunnel': '隧道',
'cfg.group.gateway': '网关',
// 推理设置
'cfg.llm_backend.label': '后端',
'cfg.llm_backend.desc': 'LLM 推理提供商',
'cfg.selected_model.label': '模型',
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
'cfg.bedrock_region.label': 'Bedrock 区域',
'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
'cfg.bedrock_cross_region.label': '跨区域',
'cfg.bedrock_cross_region.desc': '启用跨区域推理',
'cfg.bedrock_profile.label': 'AWS 配置文件',
'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
'cfg.embeddings_enabled.label': '启用',
'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
'cfg.embeddings_provider.label': '提供商',
'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
'cfg.embeddings_model.label': '模型',
'cfg.embeddings_model.desc': '嵌入向量模型名称',
// 代理设置
'cfg.agent_name.label': '名称',
'cfg.agent_name.desc': '代理显示名称',
'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
'cfg.agent_job_timeout.label': '任务超时',
'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
'cfg.agent_use_planning.label': '规划',
'cfg.agent_use_planning.desc': '执行前启用多步规划',
'cfg.agent_auto_approve.label': '自动批准工具',
'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
'cfg.agent_timezone.label': '时区',
'cfg.agent_timezone.desc': '默认时区(IANA',
'cfg.agent_session_idle.label': '会话空闲超时',
'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
'cfg.agent_stuck_threshold.label': '卡住阈值',
'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
'cfg.agent_max_repair.label': '最大修复尝试次数',
'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
'cfg.agent_max_cost.label': '每日最大费用',
'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
'cfg.agent_max_actions.label': '每小时最大操作数',
'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
'cfg.agent_allow_local.label': '允许本地工具',
'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
// 心跳设置
'cfg.heartbeat_enabled.label': '启用',
'cfg.heartbeat_enabled.desc': '运行定期后台检查',
'cfg.heartbeat_interval.label': '间隔',
'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800',
'cfg.heartbeat_notify_channel.label': '通知频道',
'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
'cfg.heartbeat_notify_user.label': '通知用户',
'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
'cfg.heartbeat_quiet_start.label': '静默时段开始',
'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23',
'cfg.heartbeat_quiet_end.label': '静默时段结束',
'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23',
'cfg.heartbeat_timezone.label': '时区',
'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA',
// 沙箱设置
'cfg.sandbox_enabled.label': '启用',
'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
'cfg.sandbox_policy.label': '策略',
'cfg.sandbox_policy.desc': '沙箱安全策略',
'cfg.sandbox_timeout.label': '超时',
'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
'cfg.sandbox_memory.label': '内存限制',
'cfg.sandbox_memory.desc': '容器内存限制(MB',
'cfg.sandbox_image.label': 'Docker 镜像',
'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
// 定时任务设置
'cfg.routines_max_concurrent.label': '最大并发数',
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
'cfg.routines_cooldown.label': '默认冷却时间',
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
// 安全设置
'cfg.safety_max_output.label': '最大输出长度',
'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
'cfg.safety_injection_check.label': '注入检查',
'cfg.safety_injection_check.desc': '启用提示注入检测',
// 技能设置
'cfg.skills_max_active.label': '最大活跃技能数',
'cfg.skills_max_active.desc': '同时活跃的最大技能数',
'cfg.skills_max_tokens.label': '最大上下文令牌数',
'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
// 搜索设置
'cfg.search_fusion.label': '融合策略',
'cfg.search_fusion.desc': '混合搜索排名方法',
// 网络设置
'cfg.tunnel_provider.label': '提供商',
'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
'cfg.tunnel_public_url.label': '公网 URL',
'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
'cfg.gateway_rate_limit.label': '速率限制',
'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
'cfg.gateway_max_connections.label': '最大连接数',
'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
// 频道子标签
'channels.builtin': '内置频道',
'channels.messaging': '消息频道',
'channels.webGateway': 'Web 网关',
'channels.webGatewayDesc': '基于浏览器的聊天界面',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
'channels.cli': 'CLI',
'channels.cliDesc': '使用 Ratatui 的终端 UI',
'channels.repl': 'REPL',
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
'channels.configureVia': '通过 {env} 配置',
'channels.runWith': '运行命令: {cmd}',
});
+111 -64
View File
@@ -95,8 +95,7 @@
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
<div class="spacer"></div>
<!-- Language Switcher -->
@@ -271,77 +270,125 @@
</div>
</div>
<!-- Extensions Tab -->
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<!-- Settings Tab -->
<div class="tab-panel" id="tab-settings">
<div class="settings-layout">
<div class="settings-sidebar">
<button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
<button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
<button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
<button class="settings-subtab" data-settings-subtab="networking" data-i18n="settings.networking">Networking</button>
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="settings-content">
<div class="settings-toolbar">
<div class="settings-search">
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
</div>
<button id="settings-export-btn" class="settings-toolbar-btn" data-i18n="settings.export">Export</button>
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
<div class="settings-subpanel active" id="settings-inference">
<div class="extensions-container" id="settings-inference-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="settings-subpanel" id="settings-agent">
<div class="extensions-container" id="settings-agent-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
<div class="settings-subpanel" id="settings-channels">
<div class="extensions-container" id="settings-channels-content">
<div class="empty-state" data-i18n="common.loading">Loading channels...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-networking">
<div class="extensions-container" id="settings-networking-content">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-mcp">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search for skills...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
</div>
<!-- Skills Tab -->
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="modal">
<h3 id="confirm-modal-title"></h3>
<p id="confirm-modal-message"></p>
<div class="modal-actions">
<button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
<button id="confirm-modal-btn" class="btn-danger">Confirm</button>
</div>
</div>
</div>
+545 -61
View File
@@ -18,6 +18,12 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
--text-muted: #71717a;
--bg-hover: rgba(255, 255, 255, 0.03);
--danger-soft: rgba(230, 76, 76, 0.15);
--warning-soft: rgba(245, 166, 35, 0.15);
--transition-fast: 150ms ease;
--transition-base: 0.2s ease;
}
* {
@@ -332,10 +338,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
background-color: #1a1a1a;
border: 1px solid #333;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -352,7 +358,7 @@ body {
}
.restart-title {
color: #e0e0e0;
color: var(--text);
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -388,10 +394,10 @@ body {
.restart-modal-content {
position: relative;
z-index: 10000;
background-color: #1a1a1a;
border: 1px solid #333;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -403,11 +409,11 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #2a2a2a;
border-bottom: 1px solid var(--border);
}
.restart-modal-header h2 {
color: #e0e0e0;
color: var(--text);
font-size: 0.95rem;
margin: 0;
}
@@ -426,8 +432,8 @@ body {
}
.restart-modal-close:hover {
color: #ccc;
background-color: #2a2a2a;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
}
.restart-modal-body {
@@ -435,21 +441,21 @@ body {
}
.restart-modal-description {
color: #aaa;
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
.restart-modal-warning {
margin-top: 1rem;
background-color: #1e1400;
border: 1px solid #3a2a00;
background-color: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
color: #facc15;
color: var(--warning);
font-size: 0.8rem;
margin: 0;
}
@@ -460,7 +466,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid #2a2a2a;
border-top: 1px solid var(--border);
}
.restart-modal-btn {
@@ -473,28 +479,28 @@ body {
}
.restart-modal-btn.cancel {
color: #ccc;
color: var(--text-secondary);
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
background-color: #2a2a2a;
background-color: var(--bg-tertiary);
}
.restart-modal-btn.confirm {
background-color: #00D894;
color: #111;
background-color: var(--accent);
color: #09090b;
}
.restart-modal-btn.confirm:hover {
background-color: #00be82;
background-color: var(--accent-hover);
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
background-color: #2a2a2a;
background-color: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
}
@@ -502,7 +508,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
background-color: #00D894;
background-color: var(--accent);
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -523,14 +529,14 @@ body {
}
.restart-modal-info {
color: #666;
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
color: #00D894;
color: var(--accent);
text-decoration: none;
}
@@ -2522,17 +2528,21 @@ body {
}
.extensions-section h3 {
font-size: 15px;
font-size: 11px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.extensions-section h4 {
font-size: 13px;
font-size: 11px;
font-weight: 600;
margin: 16px 0 8px;
color: var(--text-secondary);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.extensions-list {
@@ -2544,12 +2554,29 @@ body {
.ext-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s, transform 0.2s;
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
}
.ext-card.state-active {
border-left-color: var(--success);
}
.ext-card.state-inactive {
border-left-color: var(--text-muted);
}
.ext-card.state-error {
border-left-color: var(--danger);
}
.ext-card.state-pairing {
border-left-color: var(--warning);
}
.ext-card:hover {
@@ -2592,6 +2619,11 @@ body {
color: var(--warning);
}
.ext-kind.kind-builtin {
background: rgba(161, 161, 170, 0.15);
color: var(--text-secondary);
}
.ext-version {
font-size: 11px;
color: var(--text-muted);
@@ -2767,13 +2799,20 @@ body {
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
transition: all var(--transition-fast);
}
.btn-ext:hover {
background: var(--border);
transform: translateY(-1px);
}
.btn-ext:active {
transform: scale(0.97);
}
.btn-ext.activate {
@@ -2873,6 +2912,7 @@ body {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
@@ -2893,7 +2933,7 @@ body {
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text-primary);
color: var(--text);
}
.configure-hint {
@@ -3036,31 +3076,6 @@ body {
justify-content: flex-end;
}
.tools-table {
width: 100%;
border-collapse: collapse;
}
.tools-table th,
.tools-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tools-table th {
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.tools-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3714,10 +3729,14 @@ mark {
gap: 8px;
align-items: center;
flex-wrap: wrap;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.ext-install-form input {
padding: 6px 10px;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -3759,6 +3778,10 @@ mark {
gap: 8px;
align-items: center;
margin-bottom: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.skill-search-box input {
@@ -3795,10 +3818,10 @@ mark {
}
.skill-trust {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
font-size: 11px;
padding: 3px 8px;
border-radius: 9999px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@@ -3942,6 +3965,27 @@ mark {
border-bottom: 1px solid var(--border);
}
/* Settings layout: horizontal subtabs on mobile */
.settings-layout { flex-direction: column; }
.settings-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 0;
}
.settings-subtab {
border-left: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
padding: 8px 16px;
}
.settings-subtab.active {
border-left-color: transparent;
border-bottom-color: var(--accent);
}
/* Extension install form */
.ext-install-form {
flex-direction: column;
@@ -3968,6 +4012,238 @@ mark {
}
}
/* --- Settings Tab Layout --- */
.settings-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.settings-sidebar {
width: 180px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
padding: 12px 0;
flex-shrink: 0;
}
.settings-subtab {
display: block;
width: 100%;
padding: 10px 20px;
background: none;
border: none;
border-left: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-align: left;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.settings-subtab:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.settings-subtab.active {
color: var(--accent);
border-left-color: var(--accent);
background: var(--bg-tertiary);
}
.settings-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-subpanel {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
opacity: 0;
}
.settings-subpanel.active {
display: flex;
animation: settingsFadeIn 0.2s ease forwards;
}
@keyframes settingsFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Settings form styles (General subtab) */
.settings-group {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin: 0 -12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-radius: 6px;
gap: 16px;
max-height: 80px;
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
opacity: 1;
}
.settings-row:hover {
background: var(--bg-hover);
}
.settings-row.hidden {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
border-bottom: none;
}
.settings-row.search-hidden {
display: none;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
font-size: 13px;
color: var(--text);
font-weight: 500;
flex-shrink: 0;
min-width: 180px;
}
.settings-input {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
}
.settings-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-saved-indicator {
font-size: 11px;
color: var(--success);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.settings-saved-indicator.visible {
opacity: 1;
transform: translateY(0);
}
.settings-description {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.restart-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
margin: 8px 16px;
animation: settingsFadeIn 0.25s ease forwards;
}
.restart-banner-text {
flex: 1;
}
.restart-banner-btn {
padding: 4px 12px;
background: var(--warning);
color: #09090b;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
transition: opacity var(--transition-fast);
}
.restart-banner-btn:hover {
opacity: 0.85;
}
.settings-label-wrap {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-width: 180px;
}
.settings-select {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
cursor: pointer;
}
.settings-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Slash command autocomplete dropdown */
.slash-autocomplete {
position: relative;
@@ -4156,3 +4432,211 @@ mark {
padding: 4px 8px;
background: var(--bg-secondary);
}
/* Settings toolbar (search + import/export) */
.settings-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.settings-search {
flex: 1;
}
.settings-search input {
width: 100%;
padding: 6px 10px 6px 32px;
background: var(--bg);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
}
.settings-search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-toolbar-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.settings-toolbar-btn:hover {
background: var(--bg-secondary);
color: var(--text);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.settings-toolbar-btn:active {
transform: scale(0.98);
}
/* Confirmation modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: modalFadeIn 0.15s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0;
max-width: 420px;
width: 90%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: modalSlideIn 0.2s ease;
}
.modal h3 {
margin: 0;
padding: 16px 20px;
font-size: 16px;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.modal p {
margin: 0;
padding: 16px 20px;
font-size: 13px;
color: var(--text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.btn-secondary {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover {
background: var(--bg);
}
.btn-danger {
padding: 8px 16px;
background: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: white;
cursor: pointer;
font-size: 13px;
}
.btn-danger:hover {
opacity: 0.9;
}
/* Mobile settings responsiveness */
@media (max-width: 768px) {
.settings-row {
flex-direction: column;
align-items: stretch;
max-height: 140px;
}
.settings-label-wrap {
min-width: unset;
}
.settings-input, .settings-select {
width: 100%;
}
.settings-toolbar {
flex-wrap: wrap;
}
.settings-search {
min-width: 150px;
}
}
/* Loading skeletons */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
gap: 16px;
}
.skeleton-bar {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Settings search empty state */
.settings-search-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
+1
View File
@@ -87,6 +87,7 @@ impl TestGatewayBuilder {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
})
}
+1
View File
@@ -521,6 +521,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
}
}
}
-7
View File
@@ -258,13 +258,6 @@ impl JobContext {
new_state: JobState,
reason: Option<String>,
) -> Result<(), String> {
debug_assert!(
self.state.can_transition_to(new_state),
"BUG: invalid job state transition {} -> {} for job {}",
self.state,
new_state,
self.job_id
);
if !self.state.can_transition_to(new_state) {
return Err(format!(
"Cannot transition from {} to {}",
+24
View File
@@ -476,4 +476,28 @@ impl RoutineStore for LibSqlBackend {
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
&format!(
"SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
ROUTINE_RUN_COLUMNS
),
params![],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let mut runs = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
runs.push(row_to_routine_run_libsql(&row)?);
}
Ok(runs)
}
}
+3
View File
@@ -525,6 +525,9 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
/// List routine runs that were dispatched as full_job but have not yet
/// been finalized (status='running' with a linked job_id).
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
}
#[async_trait]
+4
View File
@@ -503,6 +503,10 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
self.store.list_dispatched_routine_runs().await
}
}
// ==================== ToolFailureStore ====================
+12
View File
@@ -1348,6 +1348,18 @@ impl Store {
.await?;
Ok(())
}
/// List routine runs dispatched as full_job that have not yet been finalized.
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
&[],
)
.await?;
rows.iter().map(row_to_routine_run).collect()
}
}
#[cfg(feature = "postgres")]
+22
View File
@@ -323,6 +323,17 @@ async fn async_main() -> anyhow::Result<()> {
}));
// Load WASM channels and register their webhook routes.
// Ensure the channels directory exists so the WASM runtime initializes even when
// no channels are installed yet — hot-activation needs the runtime to be available.
if config.channels.wasm_channels_enabled
&& let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir)
{
tracing::warn!(
path = %config.channels.wasm_channels_dir.display(),
error = %e,
"Failed to create WASM channels directory"
);
}
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
&config,
@@ -511,6 +522,16 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_skill_catalog(Arc::clone(sc));
}
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
{
let active_model = components.llm.model_name().to_string();
let mut enabled = channel_names.clone();
enabled.push("gateway".into());
gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot {
llm_backend: config.llm.backend.to_string(),
llm_model: active_model,
enabled_channels: enabled,
});
}
if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
@@ -727,6 +748,7 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
builder: components.builder,
};
let mut agent = Agent::new(
+432
View File
@@ -0,0 +1,432 @@
//! Fault injection framework for testing retry, failover, and circuit breaker behavior.
//!
//! Provides [`FaultInjector`] which can be attached to [`StubLlm`](super::StubLlm) to
//! produce configurable error sequences, random failures, and delays.
//!
//! # Example
//!
//! ```rust,no_run
//! use ironclaw::testing::fault_injection::*;
//!
//! // Fail twice with transient errors, then succeed
//! let injector = FaultInjector::sequence([
//! FaultAction::Fail(FaultType::RequestFailed),
//! FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
//! FaultAction::Succeed,
//! ]);
//! ```
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use crate::llm::error::LlmError;
/// The type of fault to inject.
#[derive(Debug, Clone)]
pub enum FaultType {
/// Transient request failure (retryable).
RequestFailed,
/// Rate limited with optional retry-after duration.
RateLimited { retry_after: Option<Duration> },
/// Authentication failure (non-retryable).
AuthFailed,
/// Invalid response from provider (retryable).
InvalidResponse,
/// I/O error (retryable).
IoError,
/// Context length exceeded (non-retryable).
ContextLengthExceeded,
/// Session expired (transient for circuit breaker, not retryable).
SessionExpired,
}
impl FaultType {
/// Convert to the corresponding `LlmError`.
pub fn to_llm_error(&self, provider: &str) -> LlmError {
match self {
FaultType::RequestFailed => LlmError::RequestFailed {
provider: provider.to_string(),
reason: "injected fault: request failed".to_string(),
},
FaultType::RateLimited { retry_after } => LlmError::RateLimited {
provider: provider.to_string(),
retry_after: *retry_after,
},
FaultType::AuthFailed => LlmError::AuthFailed {
provider: provider.to_string(),
},
FaultType::InvalidResponse => LlmError::InvalidResponse {
provider: provider.to_string(),
reason: "injected fault: invalid response".to_string(),
},
FaultType::IoError => LlmError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"injected fault: connection reset",
)),
FaultType::ContextLengthExceeded => LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
},
FaultType::SessionExpired => LlmError::SessionExpired {
provider: provider.to_string(),
},
}
}
}
/// Action to take on a given call.
#[derive(Debug, Clone)]
pub enum FaultAction {
/// Return a successful response.
Succeed,
/// Return an error of the given type.
Fail(FaultType),
/// Sleep for the given duration, then succeed.
Delay(Duration),
}
/// How the fault sequence is consumed.
#[derive(Debug, Clone)]
pub enum FaultMode {
/// Play the sequence once, then succeed for all subsequent calls.
SequenceOnce,
/// Loop the sequence forever.
SequenceLoop,
/// Fail randomly at the given rate (0.0 = never, 1.0 = always) with
/// the specified fault type. Uses a seeded RNG for reproducibility.
/// The seed is stored so that [`FaultInjector::reset()`] can re-initialize
/// the RNG for test reproducibility.
Random {
error_rate: f64,
fault: FaultType,
seed: u64,
},
}
/// A configurable fault injector for [`StubLlm`](super::StubLlm).
///
/// Thread-safe: uses atomic call counter and mutex-protected RNG.
pub struct FaultInjector {
actions: Vec<FaultAction>,
mode: FaultMode,
call_index: AtomicU32,
/// Seeded RNG for Random mode, behind Mutex for Sync.
rng_state: Mutex<u64>,
}
impl std::fmt::Debug for FaultInjector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FaultInjector")
.field("call_index", &self.call_index.load(Ordering::Relaxed))
.field("mode", &self.mode)
.finish()
}
}
impl FaultInjector {
/// Create a fault injector that plays actions once, then succeeds.
pub fn sequence(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceOnce,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector that loops the action sequence forever.
pub fn sequence_loop(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceLoop,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector with random failures at the given rate.
///
/// # Panics
///
/// Panics if `error_rate` is not in `0.0..=1.0` or is NaN.
///
/// The seed is guarded against zero, which is a fixed point for xorshift.
pub fn random(error_rate: f64, fault: FaultType, seed: u64) -> Self {
assert!(
!error_rate.is_nan() && (0.0..=1.0).contains(&error_rate),
"error_rate must be in 0.0..=1.0 and not NaN, got {error_rate}"
);
let seed = if seed == 0 { 1 } else { seed };
Self {
actions: Vec::new(),
mode: FaultMode::Random {
error_rate,
fault,
seed,
},
call_index: AtomicU32::new(0),
rng_state: Mutex::new(seed),
}
}
/// Get the action for the next call.
pub fn next_action(&self) -> FaultAction {
let index = self.call_index.fetch_add(1, Ordering::Relaxed) as usize;
match &self.mode {
FaultMode::SequenceOnce => {
if index < self.actions.len() {
self.actions[index].clone()
} else {
FaultAction::Succeed
}
}
FaultMode::SequenceLoop => {
if self.actions.is_empty() {
FaultAction::Succeed
} else {
self.actions[index % self.actions.len()].clone()
}
}
FaultMode::Random {
error_rate, fault, ..
} => {
// Simple xorshift64 PRNG for reproducible randomness.
let random_val = {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
(*state as f64) / (u64::MAX as f64)
};
if random_val <= *error_rate {
FaultAction::Fail(fault.clone())
} else {
FaultAction::Succeed
}
}
}
}
/// Get the total number of calls made.
pub fn call_count(&self) -> u32 {
self.call_index.load(Ordering::Relaxed)
}
/// Reset the injector to its initial state.
///
/// For `Random` mode, re-initializes the RNG from the stored seed,
/// which is useful for test reproducibility.
/// For all modes, resets the call counter to zero.
pub fn reset(&self) {
self.call_index.store(0, Ordering::Relaxed);
if let FaultMode::Random { seed, .. } = &self.mode {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state = *seed;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sequence_once_plays_then_succeeds() {
let injector = FaultInjector::sequence([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]);
// First two calls should fail
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RequestFailed)
));
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RateLimited { .. })
));
// Third call is explicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
// Beyond sequence: implicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert_eq!(injector.call_count(), 5);
}
#[test]
fn sequence_loop_repeats() {
let injector = FaultInjector::sequence_loop([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Succeed,
]);
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
#[test]
fn random_mode_is_deterministic_with_seed() {
let injector1 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let injector2 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let results1: Vec<bool> = (0..20)
.map(|_| matches!(injector1.next_action(), FaultAction::Fail(_)))
.collect();
let results2: Vec<bool> = (0..20)
.map(|_| matches!(injector2.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(results1, results2, "Same seed should produce same sequence");
}
#[test]
fn fault_type_produces_correct_llm_errors() {
let provider = "test-provider";
assert!(matches!(
FaultType::RequestFailed.to_llm_error(provider),
LlmError::RequestFailed { .. }
));
assert!(matches!(
FaultType::RateLimited {
retry_after: Some(Duration::from_secs(5))
}
.to_llm_error(provider),
LlmError::RateLimited { .. }
));
assert!(matches!(
FaultType::AuthFailed.to_llm_error(provider),
LlmError::AuthFailed { .. }
));
assert!(matches!(
FaultType::InvalidResponse.to_llm_error(provider),
LlmError::InvalidResponse { .. }
));
assert!(matches!(
FaultType::IoError.to_llm_error(provider),
LlmError::Io(_)
));
assert!(matches!(
FaultType::ContextLengthExceeded.to_llm_error(provider),
LlmError::ContextLengthExceeded { .. }
));
assert!(matches!(
FaultType::SessionExpired.to_llm_error(provider),
LlmError::SessionExpired { .. }
));
}
#[test]
fn delay_action_exists() {
let injector = FaultInjector::sequence([FaultAction::Delay(Duration::from_millis(100))]);
assert!(matches!(injector.next_action(), FaultAction::Delay(_)));
}
#[test]
fn random_seed_zero_does_not_always_fail() {
// seed=0 is a fixed point for xorshift; the constructor guards it to 1.
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 0);
let failures = (0..100)
.filter(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.count();
assert!(failures < 100, "seed=0 must not produce stuck RNG");
}
#[test]
fn empty_sequence_always_succeeds() {
let injector = FaultInjector::sequence([]);
for _ in 0..10 {
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
#[test]
fn reset_restores_random_rng_from_stored_seed() {
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let run1: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
injector.reset();
assert_eq!(injector.call_count(), 0);
let run2: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(run1, run2, "reset() should reproduce the same sequence");
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_error_rate_above_one() {
FaultInjector::random(1.5, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_negative_error_rate() {
FaultInjector::random(-0.1, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0 and not NaN")]
fn random_rejects_nan_error_rate() {
FaultInjector::random(f64::NAN, FaultType::RequestFailed, 42);
}
#[test]
fn error_rate_one_always_fails() {
let injector = FaultInjector::random(1.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Fail(_)),
"error_rate=1.0 must always produce failures"
);
}
}
#[test]
fn error_rate_zero_never_fails() {
let injector = FaultInjector::random(0.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Succeed),
"error_rate=0.0 must never produce failures"
);
}
}
#[tokio::test]
async fn delay_action_pauses_execution() {
tokio::time::pause();
let injector = FaultInjector::sequence([
FaultAction::Delay(Duration::from_secs(10)),
FaultAction::Succeed,
]);
// First action is a delay
let action = injector.next_action();
assert!(matches!(action, FaultAction::Delay(d) if d == Duration::from_secs(10)));
// Simulate what StubLlm does: sleep then succeed
if let FaultAction::Delay(d) = action {
let start = tokio::time::Instant::now();
tokio::time::sleep(d).await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(10),
"delay should have paused for at least 10s, got {elapsed:?}"
);
}
// Next action succeeds
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
+66 -4
View File
@@ -19,9 +19,11 @@
//! ```
pub mod credentials;
pub mod fault_injection;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
@@ -84,6 +86,9 @@ pub struct StubLlm {
call_count: AtomicU32,
should_fail: AtomicBool,
error_kind: StubErrorKind,
/// Optional fault injector for fine-grained failure control.
/// When set, takes precedence over the `should_fail` / `error_kind` fields.
fault_injector: Option<Arc<fault_injection::FaultInjector>>,
}
impl StubLlm {
@@ -95,6 +100,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(false),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -106,6 +112,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -117,6 +124,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::NonTransient,
fault_injector: None,
}
}
@@ -131,11 +139,39 @@ impl StubLlm {
self.call_count.load(Ordering::Relaxed)
}
/// Attach a fault injector for fine-grained failure control.
///
/// When set, the injector's `next_action()` is consulted on every call,
/// taking precedence over the `should_fail` / `error_kind` fields.
pub fn with_fault_injector(mut self, injector: Arc<fault_injection::FaultInjector>) -> Self {
self.fault_injector = Some(injector);
self
}
/// Toggle whether calls should fail at runtime.
pub fn set_failing(&self, fail: bool) {
self.should_fail.store(fail, Ordering::Relaxed);
}
/// Check the fault injector or should_fail flag, returning an error if
/// the call should fail, or None if it should succeed.
async fn check_faults(&self) -> Option<LlmError> {
if let Some(ref injector) = self.fault_injector {
match injector.next_action() {
fault_injection::FaultAction::Fail(fault) => {
return Some(fault.to_llm_error(&self.model_name));
}
fault_injection::FaultAction::Delay(duration) => {
tokio::time::sleep(duration).await;
}
fault_injection::FaultAction::Succeed => {}
}
} else if self.should_fail.load(Ordering::Relaxed) {
return Some(self.make_error());
}
None
}
fn make_error(&self) -> LlmError {
match self.error_kind {
StubErrorKind::Transient => LlmError::RequestFailed {
@@ -168,8 +204,8 @@ impl LlmProvider for StubLlm {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
if let Some(err) = self.check_faults().await {
return Err(err);
}
Ok(CompletionResponse {
content: self.response.clone(),
@@ -186,8 +222,8 @@ impl LlmProvider for StubLlm {
_request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
if let Some(err) = self.check_faults().await {
return Err(err);
}
Ok(ToolCompletionResponse {
content: Some(self.response.clone()),
@@ -456,6 +492,7 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
TestHarness {
@@ -1508,4 +1545,29 @@ mod tests {
.await
.expect("update actuals");
}
#[tokio::test]
async fn stub_llm_fault_injector_sequence() {
use crate::llm::LlmProvider;
use crate::testing::fault_injection::{FaultAction, FaultInjector, FaultType};
let injector = Arc::new(FaultInjector::sequence([
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]));
let stub = StubLlm::new("hello").with_fault_injector(injector);
let req = crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user("hi")]);
// First call should fail with RateLimited
let result = stub.complete(req.clone()).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), LlmError::RateLimited { .. }));
// Second call should succeed
let result = stub.complete(req).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello");
}
}
+5 -3
View File
@@ -10,7 +10,7 @@
//! - `event_emit` - Emit a structured system event to `system_event`-triggered routines
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
@@ -624,7 +624,8 @@ pub(crate) fn routine_create_parameters_schema() -> Value {
}
fn routine_create_discovery_schema() -> Value {
routine_create_schema(true)
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| routine_create_schema(true)).clone()
}
pub(crate) fn routine_update_parameters_schema() -> Value {
@@ -1007,7 +1008,8 @@ pub(crate) fn event_emit_parameters_schema() -> Value {
}
fn event_emit_discovery_schema() -> Value {
event_emit_schema(true)
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| event_emit_schema(true)).clone()
}
fn parse_event_emit_args(params: &Value) -> Result<(String, String, Value), ToolError> {
+11 -7
View File
@@ -22,10 +22,6 @@ pub async fn execute_tool_with_safety(
params: &serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
debug_assert!(
!tool_name.is_empty(),
"BUG: execute_tool_with_safety called with empty tool_name"
);
let tool = tools
.get(tool_name)
.await
@@ -297,8 +293,8 @@ mod tests {
#[tokio::test]
async fn test_execute_empty_tool_name_returns_not_found() {
// Regression: execute_tool_with_safety must reject empty tool names before
// even attempting a registry lookup (the debug_assert guards this invariant).
// Regression: execute_tool_with_safety must reject empty tool names
// gracefully via ToolError::NotFound (not a panic).
let registry = registry_with(vec![]).await;
let safety = test_safety();
@@ -311,7 +307,15 @@ mod tests {
)
.await;
assert!(result.is_err(), "Empty tool name should return an error"); // safety: test-only assertion
assert!(
matches!(
result,
Err(crate::error::Error::Tool(
crate::error::ToolError::NotFound { .. }
))
),
"Empty tool name should return ToolError::NotFound, got: {result:?}"
);
}
#[tokio::test]
+8 -5
View File
@@ -13,7 +13,9 @@ use crate::orchestrator::job_manager::ContainerJobManager;
use crate::secrets::SecretsStore;
use crate::skills::catalog::SkillCatalog;
use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -576,22 +578,23 @@ impl ToolRegistry {
self: &Arc<Self>,
llm: Arc<dyn LlmProvider>,
config: Option<BuilderConfig>,
) {
) -> Arc<dyn SoftwareBuilder> {
// First register dev tools needed by the builder
self.register_dev_tools();
// Create the builder (arg order: config, llm, tools)
let builder = Arc::new(LlmSoftwareBuilder::new(
let builder: Arc<dyn SoftwareBuilder> = Arc::new(LlmSoftwareBuilder::new(
config.unwrap_or_default(),
llm,
Arc::clone(self),
));
// Register the build_software tool
self.register(Arc::new(BuildSoftwareTool::new(builder)))
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
.await;
tracing::debug!("Registered software builder tool");
tracing::info!("Registered software builder tool");
builder
}
/// Register a WASM tool from bytes.
+360
View File
@@ -0,0 +1,360 @@
//! Integration tests for dispatched routine run tracking (#1317).
//!
//! Verifies:
//! 1. list_dispatched_routine_runs returns only running runs with linked jobs
//! 2. Completed jobs cause linked routine runs to be finalized as Ok
//! 3. Failed jobs cause linked routine runs to be finalized as Failed
//! 4. Active (InProgress) jobs are not finalized
//! 5. Orphaned runs (job_id set but no job record) are handled
#[cfg(feature = "libsql")]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use ironclaw::agent::routine::{
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::context::{JobContext, JobState};
use ironclaw::db::Database;
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
}
fn make_routine(id: Uuid) -> Routine {
Routine {
id,
name: format!("test-routine-{}", id),
description: "Test routine".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "Test job".to_string(),
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: Default::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_run(routine_id: Uuid, job_id: Option<Uuid>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id,
created_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// Test 1: list_dispatched_routine_runs returns only running runs with jobs
// -----------------------------------------------------------------------
#[tokio::test]
async fn list_dispatched_returns_only_running_with_job_id() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create jobs first (FK constraint requires job records to exist)
let job1 = JobContext::new("Job 1", "Dispatched job");
db.save_job(&job1).await.expect("save job1");
let job2 = JobContext::new("Job 2", "Completed job");
db.save_job(&job2).await.expect("save job2");
// Create a running run WITH job_id (dispatched full_job)
let dispatched_run = make_run(routine_id, Some(job1.job_id));
db.create_routine_run(&dispatched_run)
.await
.expect("create dispatched run");
// Create a running run WITHOUT job_id (lightweight in-progress)
let lightweight_run = make_run(routine_id, None);
db.create_routine_run(&lightweight_run)
.await
.expect("create lightweight run");
// Create a completed run WITH job_id (already finalized)
let mut completed_run = make_run(routine_id, Some(job2.job_id));
completed_run.status = RunStatus::Ok;
completed_run.completed_at = Some(Utc::now());
db.create_routine_run(&completed_run)
.await
.expect("create completed run");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1, "Should return only the dispatched run");
assert_eq!(dispatched[0].id, dispatched_run.id);
assert_eq!(dispatched[0].job_id, Some(job1.job_id));
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 2: Completed job linked to run can be detected
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_completed_job_can_be_finalized() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create and save a job in Completed state
let mut job = JobContext::new("Test job", "Test description");
job.state = JobState::Completed;
db.save_job(&job).await.expect("save job");
// Create a dispatched run linked to that job
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify the run is listed as dispatched
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify we can fetch the linked job and see it's completed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Completed);
// Simulate sync: complete the run
db.complete_routine_run(run.id, RunStatus::Ok, Some("Job completed"), None)
.await
.expect("complete run");
// Run should no longer appear in dispatched list
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 3: Failed job causes run to be finalized as Failed
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_failed_job() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Failing job", "Will fail");
job.state = JobState::Failed;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is failed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Failed);
// Simulate sync: complete the run as failed
db.complete_routine_run(run.id, RunStatus::Failed, Some("Job failed"), None)
.await
.expect("complete run as failed");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(dispatched.is_empty(), "Failed run should be finalized");
}
// -----------------------------------------------------------------------
// Test 4: Active (InProgress) job leaves run as running
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_active_job_stays_running() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Active job", "Still running");
job.state = JobState::InProgress;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is still active
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert!(!fetched_job.state.is_terminal());
// Run should still be in dispatched list (not finalized)
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(
dispatched.len(),
1,
"Run with active job should remain dispatched"
);
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 5: Orphaned run (job_id set but job record missing)
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_orphan_detection() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create a real job so the FK constraint is satisfied
let job = JobContext::new("Will be orphaned", "Test orphan detection");
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// The run appears in dispatched list
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify orphan detection: a random UUID returns None from get_job
let nonexistent_id = Uuid::new_v4();
let missing = db
.get_job(nonexistent_id)
.await
.expect("get_job should not error");
assert!(
missing.is_none(),
"get_job for nonexistent ID should return None"
);
// Simulate sync handling of an orphaned run: mark as failed
db.complete_routine_run(
run.id,
RunStatus::Failed,
Some(&format!("Linked job {} not found (orphaned)", job.job_id)),
None,
)
.await
.expect("complete orphaned run");
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 6: link_routine_run_to_job then list shows linked run
// -----------------------------------------------------------------------
#[tokio::test]
async fn link_and_list_dispatched_run() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create job record (FK constraint)
let job = JobContext::new("Linked job", "Test linking");
db.save_job(&job).await.expect("save job");
// Create a running run without job_id initially
let run = make_run(routine_id, None);
db.create_routine_run(&run).await.expect("create run");
// Should not appear in dispatched list yet
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(
dispatched.is_empty(),
"Run without job_id should not be dispatched"
);
// Link the run to the job
db.link_routine_run_to_job(run.id, job.job_id)
.await
.expect("link run to job");
// Now it should appear
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after link");
assert_eq!(
dispatched_after.len(),
1,
"Linked run should appear in dispatched list"
);
assert_eq!(dispatched_after[0].job_id, Some(job.job_id));
}
}
+11 -4
View File
@@ -45,12 +45,13 @@ SEL = {
"approval_always_btn": ".approval-actions button.always",
"approval_deny_btn": ".approval-actions button.deny",
"approval_resolved": ".approval-resolved",
# Extensions tab sections
# Settings subtabs
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
"settings_subpanel": "#settings-{subtab}",
# Extensions section
"extensions_list": "#extensions-list",
"available_wasm_list": "#available-wasm-list",
"mcp_servers_list": "#mcp-servers-list",
"tools_tbody": "#tools-tbody",
"tools_empty": "#tools-empty",
# Extensions tab cards
"ext_card_installed": "#extensions-list .ext-card",
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
@@ -92,6 +93,12 @@ SEL = {
"ext_stepper": ".ext-stepper",
"stepper_step": ".stepper-step",
"stepper_circle": ".stepper-circle",
# Confirm modal (custom, replaces window.confirm)
"confirm_modal": "#confirm-modal",
"confirm_modal_btn": "#confirm-modal-btn",
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
# Channels subtab cards
"channels_ext_card": "#settings-channels-content .ext-card",
# Toast notifications
"toast": ".toast",
"toast_success": ".toast.toast-success",
@@ -106,7 +113,7 @@ SEL = {
"routines_empty": "#routines-empty",
}
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
TABS = ["chat", "memory", "jobs", "routines", "settings"]
# Auth token used across all tests
AUTH_TOKEN = "e2e-test-token"
+71 -133
View File
@@ -87,23 +87,21 @@ _REGISTRY_MCP = {
"installed": False,
}
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
# ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page):
"""Click the Extensions tab and wait for the panel to appear.
"""Navigate to Settings > Extensions subtab and wait for content.
Waits for loadExtensions() to finish rendering by polling for the first
content signal (empty-state div or an installed card) rather than sleeping.
"""
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
state="visible", timeout=5000
)
# loadExtensions() fires three parallel fetches then renders. Wait for the
# loadExtensions() fires parallel fetches then renders. Wait for the
# first concrete DOM signal instead of a hard sleep so the test is
# deterministic even under CI load.
await page.locator(
@@ -111,19 +109,39 @@ async def go_to_extensions(page):
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
"""Intercept the three extension list APIs with fixture data.
async def go_to_channels(page):
"""Navigate to Settings > Channels subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
Must be called BEFORE navigating to the extensions tab.
async def go_to_mcp(page):
"""Navigate to Settings > MCP subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, registry=None):
"""Intercept the extension list APIs with fixture data.
Must be called BEFORE navigating to the extensions subtab.
"""
ext_body = json.dumps({"extensions": installed or []})
tools_body = json.dumps({"tools": tools or []})
registry_body = json.dumps({"entries": registry or []})
# Playwright evaluates route handlers in LIFO order (last-registered fires
# first). Register the broad handler first so it is checked last; the
# specific /tools and /registry handlers are registered after and therefore
# checked first — no continue_() fallthrough needed.
# specific /registry handler is registered after and therefore checked
# first — no continue_() fallthrough needed.
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -133,13 +151,9 @@ async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
await page.route("**/api/extensions*", handle_ext_list)
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body=tools_body)
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
@@ -151,46 +165,17 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
# ─── Group A: Structural / empty state ────────────────────────────────────────
async def test_extensions_empty_tab_layout(page):
"""Extensions tab with no data shows all three sections with correct empty-state messages."""
await mock_ext_apis(page, tools=[])
"""Extensions subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page)
await go_to_extensions(page)
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
assert await panel.is_visible()
ext_list = page.locator(SEL["extensions_list"])
assert await ext_list.is_visible()
assert "No extensions installed" in await ext_list.text_content()
wasm_list = page.locator(SEL["available_wasm_list"])
assert await wasm_list.is_visible()
assert "No additional WASM extensions available" in await wasm_list.text_content()
mcp_list = page.locator(SEL["mcp_servers_list"])
assert await mcp_list.is_visible()
assert "No MCP servers available" in await mcp_list.text_content()
# Tools table should be empty
tbody = page.locator(SEL["tools_tbody"])
rows = await tbody.locator("tr").count()
empty_visible = await page.locator(SEL["tools_empty"]).is_visible()
assert empty_visible or rows == 0, "Expected tools table to be empty"
async def test_extensions_tools_table_populated(page):
"""Two mock tools produce two rows in the tools table."""
await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2])
await go_to_extensions(page)
tbody = page.locator(SEL["tools_tbody"])
rows = tbody.locator("tr")
await rows.first.wait_for(state="visible", timeout=5000)
assert await rows.count() == 2
text = await tbody.text_content()
assert "echo" in text
assert "time" in text
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
@@ -248,9 +233,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
async def test_installed_mcp_server_active(page):
"""Active MCP server shows 'Active' label and no Activate button."""
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_active_label"]).count() == 1
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
@@ -260,9 +245,9 @@ async def test_installed_mcp_server_active(page):
async def test_installed_mcp_server_inactive_shows_activate(page):
"""Inactive MCP server shows Activate button."""
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
@@ -270,7 +255,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
async def test_mcp_server_in_registry_not_installed(page):
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -285,7 +270,7 @@ async def test_mcp_server_installed_auth_dot(page):
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -299,8 +284,9 @@ async def test_mcp_server_installed_auth_dot(page):
async def _load_wasm_channel(page, activation_status, activation_error=None):
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
await mock_ext_apis(page, installed=[ext])
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await go_to_channels(page)
# Find the WASM channel card specifically (not built-in channel cards)
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first
await card.wait_for(state="visible", timeout=5000)
return card
@@ -446,9 +432,9 @@ async def test_install_wasm_channel_triggers_configure(page):
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
await page.route("**/api/extensions/install", handle_channel_install)
await go_to_extensions(page)
await go_to_channels(page)
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first
await install_btn.wait_for(state="visible", timeout=5000)
await install_btn.click()
@@ -523,13 +509,14 @@ async def test_remove_installed_extension_confirmed(page):
# Override for subsequent calls
await page.route("**/api/extensions*", handle_ext_empty)
# Auto-accept confirm dialog
await page.evaluate("window.confirm = () => true")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Confirm via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_btn"]).click()
# Card should disappear
await page.wait_for_function(
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
@@ -543,13 +530,14 @@ async def test_remove_cancelled_keeps_card(page):
await mock_ext_apis(page, installed=[_WASM_TOOL])
await go_to_extensions(page)
# Reject the confirm dialog
await page.evaluate("window.confirm = () => false")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Cancel via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_cancel"]).click()
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
@@ -973,14 +961,10 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
@@ -989,6 +973,9 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
# Inject a counter to confirm refreshCurrentSettingsTab is called
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
await page.evaluate("""
handleAuthCompleted({
extension_name: 'gmail',
@@ -999,14 +986,11 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await wait_for_toast(page, "OAuth flow expired. Please try again.")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
assert (
await page.locator(
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
).count()
>= 1
)
await page.wait_for_timeout(600)
# Wait for the refresh to complete
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
# Give the async fetch time to complete
await page.wait_for_timeout(1000)
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
@@ -1026,9 +1010,9 @@ async def test_activate_mcp_server_success(page):
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
@@ -1051,9 +1035,9 @@ async def test_activate_awaiting_token_opens_configure(page):
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1070,9 +1054,9 @@ async def test_activate_failure_shows_error_toast(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1088,9 +1072,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1106,7 +1090,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
async def test_extensions_tab_reloads_on_revisit(page):
"""loadExtensions() is called again when re-navigating to the extensions tab."""
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
call_count = []
async def counting_handler(route):
@@ -1121,14 +1105,10 @@ async def test_extensions_tab_reloads_on_revisit(page):
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
# First visit
@@ -1148,48 +1128,6 @@ async def test_extensions_tab_reloads_on_revisit(page):
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
async def test_auth_completed_sse_triggers_extensions_reload(page):
"""auth_completed SSE event while on the extensions tab triggers a reload."""
reload_count = []
async def counting_handler(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
reload_count.append(1)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": []}),
)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
count_before = len(reload_count)
# Simulate auth_completed via the shared handler.
await page.evaluate("""
handleAuthCompleted({
extension_name: 'reload-ext',
success: true,
message: 'Reloaded.',
});
""")
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed"
# ─── Regression tests ─────────────────────────────────────────────────────────
# Each test below is a regression for a specific bug found after the initial
# test suite was written. The bug description is in the docstring.
@@ -1267,9 +1205,9 @@ async def test_oauth_url_injection_blocked(page):
)
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
+18 -7
View File
@@ -4,11 +4,18 @@ import pytest
from helpers import SEL
async def go_to_skills(page):
"""Navigate to Settings > Skills subtab."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="skills")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="skills")).wait_for(
state="visible", timeout=5000
)
async def test_skills_tab_visible(page):
"""Skills tab shows the search interface."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
await panel.wait_for(state="visible", timeout=5000)
"""Skills subtab shows the search interface."""
await go_to_skills(page)
search_input = page.locator(SEL["skill_search_input"])
assert await search_input.is_visible(), "Skills search input not visible"
@@ -16,7 +23,7 @@ async def test_skills_tab_visible(page):
async def test_skills_search(page):
"""Search ClawHub for skills and verify results appear."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
await go_to_skills(page)
search_input = page.locator(SEL["skill_search_input"])
await search_input.fill("markdown")
@@ -35,7 +42,7 @@ async def test_skills_search(page):
async def test_skills_install_and_remove(page):
"""Install a skill from search results, then remove it."""
await page.locator(SEL["tab_button"].format(tab="skills")).click()
await go_to_skills(page)
# Search
search_input = page.locator(SEL["skill_search_input"])
@@ -68,10 +75,14 @@ async def test_skills_install_and_remove(page):
installed_count = await installed.count()
assert installed_count >= 1, "Skill should appear in installed list after install"
# Remove the skill (confirm is already overridden)
# Remove the skill via confirm modal
remove_btn = installed.first.locator("button", has_text="Remove")
if await remove_btn.count() > 0:
await remove_btn.click()
# Confirm in the modal
confirm_btn = page.locator(SEL["confirm_modal_btn"])
await confirm_btn.wait_for(state="visible", timeout=5000)
await confirm_btn.click()
# Wait for the card to disappear or list to shrink
await page.wait_for_timeout(3000)
new_count = await page.locator(SEL["skill_installed"]).count()
@@ -33,17 +33,28 @@ _TELEGRAM_ACTIVE = {
}
async def go_to_extensions(page):
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
async def go_to_channels(page):
"""Navigate to Settings → Channels subtab (where wasm_channel extensions live)."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}"
).first.wait_for(state="visible", timeout=8000)
# Wait for the Telegram card specifically (built-in cards render first)
await page.locator(SEL["channels_ext_card"], has_text="Telegram").wait_for(
state="visible", timeout=8000
)
async def mock_extension_lists(page, ext_handler):
async def _default_gateway_status_handler(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"enabled_channels": [], "sse_connections": 0, "ws_connections": 0}),
)
async def mock_extension_lists(page, ext_handler, *, gateway_status_handler=None):
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -69,6 +80,10 @@ async def mock_extension_lists(page, ext_handler):
await page.route("**/api/extensions*", handle_ext_list)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await page.route(
"**/api/gateway/status",
gateway_status_handler or _default_gateway_status_handler,
)
async def wait_for_toast(page, text: str, *, timeout: int = 5000):
@@ -106,9 +121,9 @@ async def test_telegram_setup_modal_shows_bot_token_field(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
await go_to_channels(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["channels_ext_card"], has_text="Telegram")
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
@@ -198,9 +213,9 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
await mock_extension_lists(page, handle_ext_list)
await page.route("**/api/extensions/telegram/setup", handle_setup)
await go_to_extensions(page)
await go_to_channels(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["channels_ext_card"], has_text="Telegram")
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
modal = page.locator(SEL["configure_modal"])
+4 -4
View File
@@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server):
async def test_extensions_tab_shows_registry(page):
"""Extensions tab loads and shows available extensions from registry."""
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
await tab_btn.click()
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
"""Extensions subtab loads and shows available extensions from registry."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
await panel.wait_for(state="visible", timeout=5000)
available_section = page.locator(SEL["available_wasm_list"])
+1
View File
@@ -198,6 +198,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
let gateway = Arc::new(TestChannel::new());
+2
View File
@@ -214,6 +214,7 @@ async fn start_test_server_with_provider(
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -705,6 +706,7 @@ async fn test_no_llm_provider_returns_503() {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
@@ -234,6 +234,7 @@ impl GatewayWorkflowHarness {
cost_guard: Some(Arc::clone(&components.cost_guard)),
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let mut agent = Agent::new(
@@ -256,6 +257,7 @@ impl GatewayWorkflowHarness {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
},
channels,
None,
+1
View File
@@ -642,6 +642,7 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
builder: None,
};
// 7. Create TestChannel and ChannelManager.
+1
View File
@@ -62,6 +62,7 @@ async fn start_test_server() -> (
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();