mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 <[email protected]> * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 <[email protected]> * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 <[email protected]> * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * refactor: unify three agentic loops into single AgenticLoop engine (#654) Replace three independent copy-pasted agentic loops (dispatcher, worker, container runtime) with a single shared engine in `agentic_loop.rs` that all consumers customize via the `LoopDelegate` trait. Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines): - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points - Tool intent nudge logic consolidated (was duplicated in 3 files) - Iteration limit + force-text behavior preserved Phase 2 — Three delegate implementations: - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost guard, context compaction, skill attenuation, interruption - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair - `ContainerDelegate` (worker/container.rs): sequential tool exec, HTTP-proxied LLM, container-safe tools, credential injection Phase 3 — File moves and cleanup: - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs` - Rename `src/worker/runtime.rs` → `src/worker/container.rs` - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs` - Update `scheduler.rs` imports to new worker location Shared helpers (`src/tools/execute.rs`): - `execute_tool_with_safety()` replaces 4 copies of validate → timeout → execute → serialize - `process_tool_result()` replaces 3 copies of sanitize → wrap → ChatMessage (also used by thread_ops.rs approval resume paths) Net result: -2,408 lines, zero duplicated loop logic, single code path for tool intent nudge and completion detection. Closes #654 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback from Copilot 1. scheduler.rs: Replace `unwrap_or` fallback with proper error propagation when parsing tool output JSON — surfaces bugs instead of silently changing the output type. 2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in `check_signals()` to avoid holding a lock across an async I/O call (prevents `await_holding_lock` lint). 3. worker/job.rs: Restore consecutive rate-limit counter (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks the job stuck with "Persistent rate limiting" instead of silently burning through max_iterations. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: incorporate staging changes — token budget tracking + mark_failed Merge staging's changes into the refactored JobDelegate: - Add token budget tracking in call_llm (update_context/add_tokens) - mark_stuck → mark_failed for iteration cap and rate-limit exhaustion (aligns with staging's #788 fix) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address zmanian's PR review — eliminate type erasure, clean up Address all 6 review points from zmanian on PR #800: 1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates type erasure and downcast, resolves clippy large_enum_variant. 2. Remove dead max_tool_iterations field from ChatDelegate struct. 3. Add on_tool_intent_nudge() hook to LoopDelegate trait with implementations in Job and Container delegates for observability. 4. Fix SSE events in job worker to emit raw sanitized content instead of XML-wrapped <tool_output> tags. 5. Remove 4 duplicate completion tests from job.rs that were already covered by the shared util module. 6. Avoid logging full tool results — use result_size_bytes in debug logs (execute.rs, job.rs). Also updates path references in CLAUDE.md, COVERAGE_PLAN.md, and add-sse-event.md command. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(doctor): expand diagnostics from 7 to 16 health checks * test: add unit tests for agentic_loop and execute shared modules Add 16 tests covering the two new critical shared modules: agentic_loop.rs (10 tests): - Text response exits loop immediately - Tool call → text response continuation - LoopSignal::Stop exits before LLM call - LoopSignal::InjectMessage adds user message to context - Max iterations terminates with LoopOutcome::MaxIterations - Tool intent nudge fires twice then caps - before_llm_call early exit bypasses LLM - truncate_for_preview: short string, long string, multibyte safety execute.rs (6 tests): - execute_tool_with_safety success path - Missing tool returns ToolError::NotFound - Tool execution failure propagates - Per-tool timeout enforcement (50ms) - process_tool_result XML wrapping on success - process_tool_result error formatting All 2,777 unit tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address code review — 9 issues across agentic loop, job worker, container CRITICAL fixes: - Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of Ok(Text("")), stopping the loop immediately with no ghost iteration. Below-threshold retries still use Text("") with an explicit empty-string guard in handle_text_response to skip injection. - check_signals drains the entire message channel before returning, prioritizing Stop over UserMessage. Previously returned early on first UserMessage, silently dropping any queued Stop or additional messages. - check_signals now detects all non-progressing job states (Cancelled, Failed, Stuck, Completed, Submitted, Accepted) instead of only Cancelled and Failed. HIGH fixes: - Error path in process_tool_result_job applies truncate_for_preview to bound error strings in SSE/DB events (was unbounded). - Document Send+Sync lifetime constraint on LoopDelegate trait. - Test mock before_llm_call refactored from double-lock to single lock acquisition, eliminating deadlock risk on refactor. MEDIUM fixes: - CompletionReport includes actual iteration count via shared Arc<Mutex<u32>> tracker (was hardcoded 0). - process_tool_result_job return type changed from Result<bool> to Result<()> — the bool was always false (dead API). - Deduplicate truncate in container.rs; now uses truncate_for_preview from agentic_loop. Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Henry Park <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Umesh Kumar Singh <[email protected]> Co-authored-by: reidliu41 <[email protected]>
This commit is contained in:
co-authored by
Henry Park
Claude Sonnet 4.6
Illia Polosukhin
Umesh Kumar Singh
reidliu41
parent
ebb22094a5
commit
88f4894a18
+541
-5
@@ -7,6 +7,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Run all diagnostic checks and print results.
|
||||
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
@@ -15,14 +16,35 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
|
||||
let mut passed = 0u32;
|
||||
let mut failed = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
|
||||
// ── Configuration checks ──────────────────────────────────
|
||||
// Load settings once for checks that need them.
|
||||
let settings = Settings::load();
|
||||
|
||||
// ── Settings & core config ─────────────────────────────────
|
||||
|
||||
check(
|
||||
"Settings file",
|
||||
check_settings_file(),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"NEAR AI session",
|
||||
check_nearai_session().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"LLM configuration",
|
||||
check_llm_config(&settings),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -30,6 +52,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
check_database().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -37,15 +60,75 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
check_workspace_dir(),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
// ── Subsystem configuration checks ─────────────────────────
|
||||
|
||||
check(
|
||||
"Embeddings",
|
||||
check_embeddings(&settings),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"Routines config",
|
||||
check_routines_config(),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"Gateway config",
|
||||
check_gateway_config(&settings),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"MCP servers",
|
||||
check_mcp_config().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"Skills",
|
||||
check_skills().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"Secrets",
|
||||
check_secrets(&settings),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
"Service",
|
||||
check_service_installed(),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
// ── External binary checks ────────────────────────────────
|
||||
|
||||
check(
|
||||
"Docker",
|
||||
check_binary("docker", &["--version"]),
|
||||
"Docker daemon",
|
||||
check_docker_daemon().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -53,6 +136,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
check_binary("cloudflared", &["--version"]),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -60,6 +144,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
check_binary("ngrok", &["version"]),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -67,12 +152,13 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
check_binary("tailscale", &["version"]),
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
);
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────
|
||||
|
||||
println!();
|
||||
println!(" {passed} passed, {failed} failed");
|
||||
println!(" {passed} passed, {failed} failed, {skipped} skipped");
|
||||
|
||||
if failed > 0 {
|
||||
println!("\n Some checks failed. This is normal if you don't use those features.");
|
||||
@@ -83,7 +169,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
|
||||
// ── Individual checks ───────────────────────────────────────
|
||||
|
||||
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
|
||||
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
|
||||
match result {
|
||||
CheckResult::Pass(detail) => {
|
||||
*passed += 1;
|
||||
@@ -94,6 +180,7 @@ fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
|
||||
println!(" [FAIL] {name}: {detail}");
|
||||
}
|
||||
CheckResult::Skip(reason) => {
|
||||
*skipped += 1;
|
||||
println!(" [skip] {name}: {reason}");
|
||||
}
|
||||
}
|
||||
@@ -105,6 +192,29 @@ enum CheckResult {
|
||||
Skip(String),
|
||||
}
|
||||
|
||||
// ── Settings file ───────────────────────────────────────────
|
||||
|
||||
fn check_settings_file() -> CheckResult {
|
||||
let path = Settings::default_path();
|
||||
if !path.exists() {
|
||||
return CheckResult::Pass("no settings file (defaults will be used)".into());
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(_) => CheckResult::Pass(format!("valid ({})", path.display())),
|
||||
Err(e) => CheckResult::Fail(format!(
|
||||
"settings.json is malformed: {}. Fix or delete {}",
|
||||
e,
|
||||
path.display()
|
||||
)),
|
||||
},
|
||||
Err(e) => CheckResult::Fail(format!("cannot read {}: {}", path.display(), e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── NEAR AI session ─────────────────────────────────────────
|
||||
|
||||
async fn check_nearai_session() -> CheckResult {
|
||||
// Check if session file exists
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
@@ -129,6 +239,27 @@ async fn check_nearai_session() -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM configuration ──────────────────────────────────────
|
||||
|
||||
fn check_llm_config(settings: &Settings) -> CheckResult {
|
||||
match crate::llm::LlmConfig::resolve(settings) {
|
||||
Ok(config) => {
|
||||
// Show the model for the active backend, not always nearai.model.
|
||||
let model = if let Some(ref bedrock) = config.bedrock {
|
||||
&bedrock.model
|
||||
} else if let Some(ref provider) = config.provider {
|
||||
&provider.model
|
||||
} else {
|
||||
&config.nearai.model
|
||||
};
|
||||
CheckResult::Pass(format!("backend={}, model={}", config.backend, model))
|
||||
}
|
||||
Err(e) => CheckResult::Fail(format!("LLM config error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Database ────────────────────────────────────────────────
|
||||
|
||||
async fn check_database() -> CheckResult {
|
||||
let backend = std::env::var("DATABASE_BACKEND")
|
||||
.ok()
|
||||
@@ -192,6 +323,8 @@ async fn try_pg_connect() -> Result<(), String> {
|
||||
Err("postgres feature not compiled in".into())
|
||||
}
|
||||
|
||||
// ── Workspace directory ─────────────────────────────────────
|
||||
|
||||
fn check_workspace_dir() -> CheckResult {
|
||||
let dir = ironclaw_base_dir();
|
||||
|
||||
@@ -206,6 +339,222 @@ fn check_workspace_dir() -> CheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Embeddings ──────────────────────────────────────────────
|
||||
|
||||
fn check_embeddings(settings: &Settings) -> CheckResult {
|
||||
match crate::config::EmbeddingsConfig::resolve(settings) {
|
||||
Ok(config) => {
|
||||
if !config.enabled {
|
||||
return CheckResult::Skip("disabled (set EMBEDDING_ENABLED=true)".into());
|
||||
}
|
||||
let has_creds = match config.provider.as_str() {
|
||||
"openai" => config.openai_api_key().is_some(),
|
||||
"nearai" => {
|
||||
// NearAiEmbeddings uses SessionManager::get_token() which
|
||||
// only returns session tokens, NOT NEARAI_API_KEY
|
||||
// (src/workspace/embeddings.rs:309, src/llm/session.rs:132).
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
session_path.exists()
|
||||
&& std::fs::read_to_string(&session_path)
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
"ollama" => true, // local, no creds needed
|
||||
_ => config.openai_api_key().is_some(),
|
||||
};
|
||||
if has_creds {
|
||||
CheckResult::Pass(format!(
|
||||
"provider={}, model={}",
|
||||
config.provider, config.model
|
||||
))
|
||||
} else {
|
||||
let hint = match config.provider.as_str() {
|
||||
"nearai" => "run `ironclaw onboard` to create a session",
|
||||
_ => "set OPENAI_API_KEY",
|
||||
};
|
||||
CheckResult::Fail(format!(
|
||||
"provider={} but credentials missing ({})",
|
||||
config.provider, hint
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::Fail(format!("config error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routines config ─────────────────────────────────────────
|
||||
|
||||
fn check_routines_config() -> CheckResult {
|
||||
match crate::config::RoutineConfig::resolve() {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
CheckResult::Pass(format!(
|
||||
"enabled (interval={}s, max_concurrent={})",
|
||||
config.cron_check_interval_secs, config.max_concurrent_routines
|
||||
))
|
||||
} else {
|
||||
CheckResult::Skip("disabled".into())
|
||||
}
|
||||
}
|
||||
Err(e) => CheckResult::Fail(format!("config error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway config ──────────────────────────────────────────
|
||||
|
||||
fn check_gateway_config(settings: &Settings) -> CheckResult {
|
||||
// Use the same resolve() path as runtime so invalid env values
|
||||
// (e.g. GATEWAY_PORT=abc) are caught here too.
|
||||
match crate::config::ChannelsConfig::resolve(settings) {
|
||||
Ok(channels) => match channels.gateway {
|
||||
Some(gw) => {
|
||||
if gw.auth_token.is_some() {
|
||||
CheckResult::Pass(format!(
|
||||
"enabled at {}:{} (auth token set)",
|
||||
gw.host, gw.port
|
||||
))
|
||||
} else {
|
||||
CheckResult::Pass(format!(
|
||||
"enabled at {}:{} (no auth token — random token will be generated)",
|
||||
gw.host, gw.port
|
||||
))
|
||||
}
|
||||
}
|
||||
None => CheckResult::Skip("disabled (GATEWAY_ENABLED=false)".into()),
|
||||
},
|
||||
Err(e) => CheckResult::Fail(format!("config error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP servers ─────────────────────────────────────────────
|
||||
|
||||
async fn check_mcp_config() -> CheckResult {
|
||||
match crate::tools::mcp::config::load_mcp_servers().await {
|
||||
Ok(file) => {
|
||||
let servers: Vec<_> = file.enabled_servers().collect();
|
||||
if servers.is_empty() {
|
||||
return CheckResult::Skip("no MCP servers configured".into());
|
||||
}
|
||||
|
||||
let mut invalid = Vec::new();
|
||||
for server in &servers {
|
||||
if let Err(e) = server.validate() {
|
||||
invalid.push(format!("{}: {}", server.name, e));
|
||||
}
|
||||
}
|
||||
|
||||
if invalid.is_empty() {
|
||||
CheckResult::Pass(format!("{} server(s) configured, all valid", servers.len()))
|
||||
} else {
|
||||
CheckResult::Fail(format!(
|
||||
"{} server(s), {} invalid: {}",
|
||||
servers.len(),
|
||||
invalid.len(),
|
||||
invalid.join("; ")
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Distinguish no config from corrupted config
|
||||
let msg = e.to_string();
|
||||
if msg.contains("not found") || msg.contains("No such file") {
|
||||
CheckResult::Skip("no MCP config file".into())
|
||||
} else {
|
||||
CheckResult::Fail(format!("config error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skills ──────────────────────────────────────────────────
|
||||
|
||||
async fn check_skills() -> CheckResult {
|
||||
let user_dir = ironclaw_base_dir().join("skills");
|
||||
let installed_dir = ironclaw_base_dir().join("installed_skills");
|
||||
|
||||
let mut registry = crate::skills::SkillRegistry::new(user_dir.clone());
|
||||
registry = registry.with_installed_dir(installed_dir);
|
||||
|
||||
// discover_all() returns loaded skill names (not warnings).
|
||||
let _loaded_names = registry.discover_all().await;
|
||||
|
||||
let count = registry.count();
|
||||
if count == 0 {
|
||||
return CheckResult::Skip("no skills discovered".into());
|
||||
}
|
||||
|
||||
CheckResult::Pass(format!("{count} skill(s) loaded"))
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────
|
||||
|
||||
fn check_secrets(settings: &Settings) -> CheckResult {
|
||||
match settings.secrets_master_key_source {
|
||||
crate::settings::KeySource::Keychain => {
|
||||
CheckResult::Pass("master key source: OS keychain".into())
|
||||
}
|
||||
crate::settings::KeySource::Env => {
|
||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||
CheckResult::Pass("master key source: env var (set)".into())
|
||||
} else {
|
||||
CheckResult::Fail(
|
||||
"master key source: env var but SECRETS_MASTER_KEY not set".into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
crate::settings::KeySource::None => {
|
||||
CheckResult::Skip("secrets not configured (run `ironclaw onboard`)".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────
|
||||
|
||||
fn check_service_installed() -> CheckResult {
|
||||
if cfg!(target_os = "macos") {
|
||||
let plist =
|
||||
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.ironclaw.daemon.plist"));
|
||||
match plist {
|
||||
Some(path) if path.exists() => {
|
||||
CheckResult::Pass(format!("launchd plist installed ({})", path.display()))
|
||||
}
|
||||
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
|
||||
None => CheckResult::Skip("cannot determine home directory".into()),
|
||||
}
|
||||
} else if cfg!(target_os = "linux") {
|
||||
let unit = dirs::home_dir().map(|h| h.join(".config/systemd/user/ironclaw.service"));
|
||||
match unit {
|
||||
Some(path) if path.exists() => {
|
||||
CheckResult::Pass(format!("systemd unit installed ({})", path.display()))
|
||||
}
|
||||
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
|
||||
None => CheckResult::Skip("cannot determine home directory".into()),
|
||||
}
|
||||
} else {
|
||||
CheckResult::Skip("service management not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Docker daemon ───────────────────────────────────────────
|
||||
|
||||
async fn check_docker_daemon() -> CheckResult {
|
||||
let detection = crate::sandbox::check_docker().await;
|
||||
match detection.status {
|
||||
crate::sandbox::DockerStatus::Available => CheckResult::Pass("running".into()),
|
||||
crate::sandbox::DockerStatus::NotInstalled => CheckResult::Skip(format!(
|
||||
"not installed. {}",
|
||||
detection.platform.install_hint()
|
||||
)),
|
||||
crate::sandbox::DockerStatus::NotRunning => CheckResult::Fail(format!(
|
||||
"installed but not running. {}",
|
||||
detection.platform.start_hint()
|
||||
)),
|
||||
crate::sandbox::DockerStatus::Disabled => CheckResult::Skip("sandbox disabled".into()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── External binary ─────────────────────────────────────────
|
||||
|
||||
fn check_binary(name: &str, args: &[&str]) -> CheckResult {
|
||||
match std::process::Command::new(name)
|
||||
.args(args)
|
||||
@@ -273,6 +622,193 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_settings_file_handles_missing() {
|
||||
// Settings::default_path() might or might not exist, but must not panic
|
||||
let result = check_settings_file();
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_llm_config_does_not_panic() {
|
||||
let settings = Settings::default();
|
||||
let result = check_llm_config(&settings);
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_routines_config_does_not_panic() {
|
||||
let result = check_routines_config();
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_gateway_config_does_not_panic() {
|
||||
let settings = Settings::default();
|
||||
let result = check_gateway_config(&settings);
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_embeddings_does_not_panic() {
|
||||
let settings = Settings::default();
|
||||
let result = check_embeddings(&settings);
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_secrets_none_returns_skip() {
|
||||
let settings = Settings::default();
|
||||
match check_secrets(&settings) {
|
||||
CheckResult::Skip(msg) => {
|
||||
assert!(
|
||||
msg.contains("not configured"),
|
||||
"expected 'not configured' in skip message, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Skip for default settings, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_service_installed_does_not_panic() {
|
||||
let result = check_service_installed();
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_docker_daemon_does_not_panic() {
|
||||
let result = check_docker_daemon().await;
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_mcp_config_does_not_panic() {
|
||||
let result = check_mcp_config().await;
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_skills_does_not_panic() {
|
||||
let result = check_skills().await;
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
|
||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
let settings = Settings::default();
|
||||
match check_llm_config(&settings) {
|
||||
CheckResult::Pass(msg) => {
|
||||
assert!(
|
||||
msg.contains("backend=nearai"),
|
||||
"expected nearai backend, got: {msg}"
|
||||
);
|
||||
// Must NOT show a bedrock or registry model when backend is nearai
|
||||
assert!(
|
||||
!msg.contains("anthropic.claude"),
|
||||
"should not show bedrock model for nearai backend: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Pass for default LLM config, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_embeddings_disabled_by_default_returns_skip() {
|
||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
}
|
||||
let settings = Settings::default();
|
||||
match check_embeddings(&settings) {
|
||||
CheckResult::Skip(msg) => {
|
||||
assert!(
|
||||
msg.contains("disabled"),
|
||||
"expected 'disabled' in skip message, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Skip for disabled embeddings, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_routines_enabled_by_default() {
|
||||
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("ROUTINES_ENABLED");
|
||||
}
|
||||
match check_routines_config() {
|
||||
CheckResult::Pass(msg) => {
|
||||
assert!(
|
||||
msg.contains("enabled"),
|
||||
"routines should be enabled by default, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Pass for default routines, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_secrets_env_without_var_returns_fail() {
|
||||
let settings = Settings {
|
||||
secrets_master_key_source: crate::settings::KeySource::Env,
|
||||
..Default::default()
|
||||
};
|
||||
match check_secrets(&settings) {
|
||||
CheckResult::Fail(msg) => {
|
||||
assert!(
|
||||
msg.contains("SECRETS_MASTER_KEY not set"),
|
||||
"expected mention of missing env var, got: {msg}"
|
||||
);
|
||||
}
|
||||
CheckResult::Pass(_) => {
|
||||
// If SECRETS_MASTER_KEY happens to be set in the environment,
|
||||
// Pass is correct — don't fail the test.
|
||||
}
|
||||
other => panic!(
|
||||
"expected Fail or Pass for env key source, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_result(r: &CheckResult) -> String {
|
||||
match r {
|
||||
CheckResult::Pass(s) => format!("Pass({s})"),
|
||||
|
||||
Reference in New Issue
Block a user