chore: resolve conflicts

This commit is contained in:
italic-jinxin
2026-03-19 10:11:44 +08:00
66 changed files with 6442 additions and 1056 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax ===
# LLM_BACKEND=minimax
# MINIMAX_API_KEY=...
# MINIMAX_MODEL=MiniMax-M2.5
# MINIMAX_MODEL=MiniMax-M2.7
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct ===
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests
run: |
pytest tests/e2e/ -v -x --timeout=120
pytest tests/e2e/ -v --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
+147
View File
@@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
### Added
- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
### Fixed
- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
- resolve merge conflict fallout and missing config fields
- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
### Other
- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
- Merge branch 'main' into fix/resolve-conflicts
- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
Generated
+1 -1
View File
@@ -3436,7 +3436,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.18.0"
version = "0.19.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+330
View File
@@ -0,0 +1,330 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="#フィロソフィー">フィロソフィー</a> •
<a href="#機能">機能</a> •
<a href="#インストール">インストール</a> •
<a href="#設定">設定</a> •
<a href="#セキュリティ">セキュリティ</a> •
<a href="#アーキテクチャ">アーキテクチャ</a>
</p>
---
## フィロソフィー
IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
## 機能
### セキュリティファースト
- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
### 常時利用可能
- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
- **自己修復** - スタックした操作の自動検出と復旧
### 自己拡張
- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
### 永続メモリ
- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
## インストール
### 前提条件
- Rust 1.85+
- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
- NEAR AIアカウント(セットアップウィザードで認証を処理)
## ダウンロードまたはビルド
最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
<details>
<summary>Windowsインストーラーでインストール(Windows</summary>
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
</details>
<details>
<summary>PowerShellスクリプトでインストール(Windows</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Homebrewでインストール(macOS/Linux</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo</summary>
`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
```bash
# リポジトリをクローン
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
# ビルド
cargo build --release
# テストを実行
cargo test
```
**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
</details>
### データベースのセットアップ
```bash
# データベースを作成
createdb ironclaw
# pgvectorを有効化
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
## 設定
セットアップウィザードを実行してIronClawを設定します:
```bash
ironclaw onboard
```
ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL``LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
### 代替LLMプロバイダー
IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
```env
# 例:MiniMax(組み込み、204Kコンテキスト)
LLM_BACKEND=minimax
MINIMAX_API_KEY=...
# 例:OpenAI互換エンドポイント
LLM_BACKEND=openai_compatible
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
## セキュリティ
IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
### WASMサンドボックス
すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
- **リソース制限** - メモリ、CPU、実行時間の制約
```
WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
バリデーター スキャン 注入 実行 スキャン
(リクエスト) (レスポンス)
```
### プロンプトインジェクション防御
外部コンテンツは複数のセキュリティレイヤーを通過します:
- パターンベースのインジェクション試行検出
- コンテンツのサニタイズとエスケープ
- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
- 安全なLLMコンテキスト注入のためのツール出力ラッピング
### データ保護
- すべてのデータはローカルのPostgreSQLデータベースに保存
- AES-256-GCMでシークレットを暗号化
- テレメトリ、分析、データ共有なし
- すべてのツール実行の完全な監査ログ
## アーキテクチャ
```
┌────────────────────────────────────────────────────────────────┐
│ チャネル │
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
│ │ │ │ │(SSE + WS) │ │
│ │ │ │ └──────┬──────┘ │
│ └─────────┴──────────────┴────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ エージェントループ │ インテントルーティング│
│ └────┬──────────┬───┘ │
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ スケジューラー │ │ ルーティン │ │
│ │ (並列ジョブ) │ │ エンジン │ │
│ └──────┬────────┘ │(cron,event,wh) │ │
│ │ └────────┬─────────┘ │
│ ┌─────────────┼────────────────────┘ │
│ │ │ │
│ ┌───▼─────┐ ┌────▼────────────────┐ │
│ │ ローカル │ │ オーケストレーター │ │
│ │ ワーカー │ │ ┌───────────────┐ │ │
│ │(プロセス │ │ │ Docker │ │ │
│ │ 内) │ │ │ サンドボックス│ │ │
│ └───┬─────┘ │ │ コンテナ │ │ │
│ │ │ │ ┌───────────┐ │ │ │
│ │ │ │ │Worker / CC│ │ │ │
│ │ │ │ └───────────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────┬───────────┘ │
│ └──────────────────┤ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ ツールレジストリ │ │
│ │ 組み込み, MCP, WASM │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### コアコンポーネント
| コンポーネント | 目的 |
|---------------|------|
| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
| **ワークスペース** | ハイブリッド検索付き永続メモリ |
| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
## 使い方
```bash
# 初回セットアップ(データベース、認証などを設定)
ironclaw onboard
# インタラクティブREPLを起動
cargo run
# デバッグログ付き
RUST_LOG=ironclaw=debug cargo run
```
## 開発
```bash
# コードフォーマット
cargo fmt
# リント
cargo clippy --all --benches --tests --examples --all-features
# テスト実行
createdb ironclaw_test
cargo test
# 特定のテストを実行
cargo test test_name
```
- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
## OpenClawの系譜
IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
主な違い:
- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
- **PostgreSQL vs SQLite** - 本番環境対応の永続化
- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
## ライセンス
以下のいずれかのライセンスの下で提供されています:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT License ([LICENSE-MIT](LICENSE-MIT))
お好みに応じて選択してください。
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+8 -4
View File
@@ -2,9 +2,13 @@ coverage:
status:
project:
default:
target: auto
threshold: 1%
target: 80%
threshold: 2%
patch:
default:
target: 80%
threshold: 5%
target: 90%
comment:
layout: "reach,diff,flags"
behavior: default
require_changes: true
+2 -2
View File
@@ -15,7 +15,7 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=...
```
Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
To use the China mainland endpoint, set:
+2 -2
View File
@@ -393,8 +393,8 @@
"api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL",
"default_model": "MiniMax-M2.5",
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"default_model": "MiniMax-M2.7",
"description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": {
"kind": "api_key",
"secret_name": "llm_minimax_api_key",
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
}
},
"auth_summary": {
+6 -1
View File
@@ -17,7 +17,12 @@
"capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel"
},
"artifacts": {},
"artifacts": {
"wasm32-wasip2": {
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
}
},
"auth_summary": {
"method": "manual",
"provider": "Feishu / Lark",
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
"sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
}
},
"auth_summary": {
+2 -2
View File
@@ -19,8 +19,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
}
},
"auth_summary": {
+2 -2
View File
@@ -21,8 +21,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
}
},
"auth_summary": {
+2 -2
View File
@@ -17,8 +17,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
}
},
"auth_summary": {
+1
View File
@@ -3,4 +3,5 @@ git_release_enable = false
[[package]]
name = "ironclaw_safety"
publish = false
release = false
@@ -8,15 +8,21 @@ Replace `{{...}}` placeholders before use.
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"cooldown_secs": 30
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
}
```
@@ -28,16 +34,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.comment.created",
"filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -47,15 +59,21 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.synchronize",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -65,16 +83,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "ci.check_run.completed",
"filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -84,11 +108,17 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"cooldown_secs": 120
"request": {
"kind": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *"
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 120
}
}
```
@@ -98,16 +128,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"cooldown_secs": 30
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.closed",
"filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
}
```
@@ -115,7 +151,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
```json
{
"source": "github",
"event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
+30 -34
View File
@@ -161,9 +161,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
/// Optional slot to expose the routine engine to the gateway for manual triggering.
/// Shared routine-engine slot used for internal event matching and for exposing
/// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
}
impl Agent {
@@ -228,16 +229,21 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
routine_engine_slot: None,
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
/// Set the routine engine slot for exposing the engine to the gateway.
/// Replace the routine-engine slot with a shared one so the gateway and
/// agent reference the same engine.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
) {
self.routine_engine_slot = Some(slot);
self.routine_engine_slot = slot;
}
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
self.routine_engine_slot.read().await.clone()
}
// Convenience accessors
@@ -633,9 +639,7 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
if let Some(ref slot) = self.routine_engine_slot {
*slot.write().await = Some(Arc::clone(&engine));
}
*self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,9 +659,6 @@ impl Agent {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +694,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
// Event-triggered routines consume plain user input before it enters
// the normal chat/tool pipeline. This avoids a duplicate turn where
// the main agent responds and the routine also fires on the same
// inbound message.
if !message.is_internal
&& matches!(
SubmissionParser::parse(&message.content),
Submission::UserInput { .. }
)
&& let Some(ref engine) = routine_engine_for_loop
{
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
continue;
}
}
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1032,6 +1010,24 @@ impl Agent {
message.content.len()
);
if !message.is_internal
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
let fired = engine
.check_event_triggers(&message.user_id, &message.channel, content)
.await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
return Ok(Some(String::new()));
}
}
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
+615 -74
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;
@@ -23,19 +24,19 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
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,9 +90,16 @@ impl RoutineEngine {
scheduler,
tools,
safety,
boot_time: Utc::now(),
}
}
/// Expose the running count for integration tests.
#[doc(hidden)]
pub fn running_count_for_test(&self) -> &Arc<AtomicUsize> {
&self.running_count
}
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
@@ -135,10 +147,19 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
/// Called synchronously from the main loop after handle_message(). The actual
/// execution is spawned async so this returns quickly.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
/// Accepts only the three fields needed for matching (user scope, channel,
/// 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
@@ -155,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() {
@@ -173,7 +187,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
if routine.user_id != message.user_id {
if routine.user_id != user_id {
continue;
}
@@ -181,13 +195,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
&& ch != &message.channel
&& ch != channel
{
continue;
}
// Regex match
if !re.is_match(&message.content) {
if !re.is_match(content) {
continue;
}
@@ -210,7 +224,7 @@ impl RoutineEngine {
continue;
}
let detail = truncate(&message.content, 200);
let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
@@ -229,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
@@ -245,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() {
@@ -331,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 {
@@ -365,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).
@@ -508,6 +762,92 @@ impl RoutineEngine {
}
}
/// Watches a dispatched full_job until the linked scheduler job completes.
///
/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
/// a `RunStatus` for the routine run.
struct FullJobWatcher {
store: Arc<dyn Database>,
job_id: Uuid,
routine_name: String,
}
impl FullJobWatcher {
/// Poll interval between DB checks.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
Self {
store,
job_id,
routine_name,
}
}
/// Block until the linked job finishes and return the mapped status + summary.
async fn wait_for_completion(&self) -> (RunStatus, Option<String>) {
let mut polls = 0u32;
let final_status = loop {
// Check job state before sleeping so we finalize promptly
// if the job is already done (e.g. fast-failing jobs).
match self.store.get_job(self.job_id).await {
Ok(Some(job_ctx)) => {
// 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);
}
}
Ok(None) => {
tracing::warn!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job disappeared from DB while polling"
);
break RunStatus::Failed;
}
Err(e) => {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"Error polling full_job state: {}", e
);
break RunStatus::Failed;
}
}
polls += 1;
if polls >= Self::MAX_POLLS {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job timed out after 24 hours, treating as failed"
);
break RunStatus::Failed;
}
tokio::time::sleep(Self::POLL_INTERVAL).await;
};
let summary = format!("Job {} finished ({})", self.job_id, final_status);
(final_status, Some(summary))
}
fn map_job_state(state: &crate::context::JobState) -> RunStatus {
use crate::context::JobState;
match state {
JobState::Failed | JobState::Cancelled => RunStatus::Failed,
_ => RunStatus::Ok, // Completed / Submitted / Accepted
}
}
}
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
@@ -682,8 +1022,10 @@ fn sanitize_routine_name(name: &str) -> String {
///
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to
/// the job, and returns immediately. The job runs independently via the
/// existing Worker/Scheduler with full tool access.
/// the job, then watches it via `FullJobWatcher` until it reaches a
/// non-active state (not Pending/InProgress/Stuck). Returns the final
/// `RunStatus` mapped from the job outcome. This keeps the routine run
/// active for the full job lifetime so concurrency guardrails apply.
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
@@ -726,25 +1068,30 @@ 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,
job_id = %job_id,
max_iterations = max_iterations,
"Dispatched full job for routine"
"Dispatched full job for routine, watching for completion"
);
let summary = format!(
"Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
);
Ok((RunStatus::Ok, Some(summary), None))
// Watch the job until it finishes — keeps the routine run active
// so concurrency guardrails (running_count, routine_runs status)
// remain enforced for the full job lifetime.
let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
let (status, summary) = watcher.wait_for_completion().await;
Ok((status, summary, None))
}
/// Execute a lightweight routine with optional tool support.
@@ -784,23 +1131,12 @@ async fn execute_lightweight(
Err(_) => None,
};
// Build the user-facing prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = &state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
let full_prompt = build_lightweight_prompt(
prompt,
&context_parts,
state_content.as_deref(),
&routine.notify,
use_tools,
);
// Get system prompt
@@ -844,6 +1180,65 @@ async fn execute_lightweight(
}
}
fn build_lightweight_prompt(
prompt: &str,
context_parts: &[String],
state_content: Option<&str>,
notify: &NotifyConfig,
use_tools: bool,
) -> String {
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if notify.on_attention {
full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
full_prompt.push_str(
"If you reply with anything other than ROUTINE_OK, the host will deliver your \
reply as the routine notification. Return the message exactly as it should be sent.\n",
);
if let Some(channel) = notify.channel.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery channel for this routine is `{channel}`.\n"
));
}
if let Some(user) = notify.user.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery target for this routine is `{user}`.\n"
));
}
full_prompt.push_str(
"Do not claim you lack messaging integrations or ask the user to set one up when \
a plain reply is sufficient.\n",
);
}
if !use_tools {
full_prompt.push_str(
"\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
);
}
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
);
full_prompt
}
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
@@ -901,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));
}
@@ -1268,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;
}
})
@@ -1385,22 +1788,78 @@ mod tests {
}
}
#[test]
fn test_build_lightweight_prompt_explains_delivery_and_disabled_tools() {
let notify = NotifyConfig {
channel: Some("telegram".to_string()),
user: Some("default".to_string()),
on_attention: true,
on_failure: true,
on_success: false,
};
let prompt = super::build_lightweight_prompt(
"Send a Telegram reminder message to the user.",
&[],
None,
&notify,
false,
);
assert!(
prompt.contains("the host will deliver your reply as the routine notification"),
"delivery guidance should explain host delivery: {prompt}",
);
assert!(
prompt.contains("configured delivery channel for this routine is `telegram`"),
"delivery guidance should mention telegram channel: {prompt}",
);
assert!(
prompt.contains("Do not claim you lack messaging integrations"),
"delivery guidance should suppress fake setup chatter: {prompt}",
);
assert!(
prompt.contains("Tools are disabled for this routine run"),
"prompt should explain that tools are disabled: {prompt}",
);
}
#[test]
fn test_build_lightweight_prompt_skips_delivery_block_when_attention_notifications_disabled() {
let notify = NotifyConfig {
on_attention: false,
..NotifyConfig::default()
};
let prompt = super::build_lightweight_prompt("Check inbox.", &[], None, &notify, true);
assert!(
!prompt.contains("# Delivery"),
"prompt should not include delivery guidance when attention notifications are off: {prompt}",
);
assert!(
!prompt.contains("Tools are disabled for this routine run"),
"prompt should not claim tools are disabled when they are enabled: {prompt}",
);
}
#[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 {}",
@@ -1514,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
);
}
}
}
+39 -2
View File
@@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData {
tracing::debug!(body = %truncated, "Response body");
}
// Leak detection on response body (best-effort)
if let Ok(body_str) = std::str::from_utf8(&body) {
// Leak detection on response body (best-effort).
//
// Telegram `getUpdates` is special: it is inbound polling data, so
// user-pasted secrets can legitimately appear in the response body.
// Those messages are still checked later by the inbound message
// safety layer before they reach the LLM, so we allow the polling
// response to continue here to avoid poisoning the offset state.
if let Ok(body_str) = std::str::from_utf8(&body)
&& !should_skip_response_leak_scan(&url)
{
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -3122,6 +3130,19 @@ fn extract_host_from_url(url: &str) -> Option<String> {
})
}
fn should_skip_response_leak_scan(url: &str) -> bool {
url::Url::parse(url).is_ok_and(|parsed| {
matches!(parsed.scheme(), "http" | "https")
&& parsed
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
&& parsed
.path_segments()
.and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
.is_some_and(|segment| segment == "getUpdates")
})
}
/// Pre-resolve host credentials for all HTTP capability mappings.
///
/// Called once per callback (in async context, before spawn_blocking) so the
@@ -4386,6 +4407,22 @@ mod tests {
assert_eq!(store.redact_credentials(input), input);
}
#[test]
fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
use super::should_skip_response_leak_scan;
assert!(should_skip_response_leak_scan(
"https://api.telegram.org/bot123/getUpdates?offset=1"
));
assert!(!should_skip_response_leak_scan(
"https://api.telegram.org/bot123/sendMessage"
));
assert!(!should_skip_response_leak_scan(
"https://api.example.com/getUpdates"
));
assert!(!should_skip_response_leak_scan("not a url"));
}
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
+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.
@@ -2932,6 +2942,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(),
})
}
@@ -2957,6 +2970,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)]
@@ -3153,6 +3169,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,10 +29,16 @@ I18n.register('en', {
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.config': 'Config',
'tab.logs': 'Logs',
'settings.inference': 'Inference',
'settings.agent': 'Agent',
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -132,10 +138,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...',
@@ -157,13 +163,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',
@@ -303,6 +304,7 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -329,6 +331,8 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
'ext.inactive': 'Inactive',
'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -395,4 +399,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,10 +29,16 @@ I18n.register('zh-CN', {
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.config': '配置',
'tab.logs': '日志',
'settings.inference': '推理',
'settings.agent': '代理',
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -132,10 +138,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': '安装中...',
@@ -157,13 +163,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': '没有安装技能',
@@ -303,6 +304,7 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -329,6 +331,8 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
'ext.inactive': '未激活',
'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -394,4 +398,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}',
});
+124 -77
View File
@@ -149,9 +149,8 @@
<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="config" data-i18n="tab.config">Config</button>
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
<div class="spacer"></div>
<!-- Language Switcher -->
@@ -326,100 +325,148 @@
</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>
<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>
<!-- Config Tab -->
<div class="tab-panel" id="tab-config">
<div class="extensions-container">
<div class="extensions-section">
<div class="config-section-header">
<h3 data-i18n="config.modelProviders">Model Providers</h3>
<button id="add-provider-btn" class="btn-add-provider" data-i18n="config.addProvider">+ Add Provider</button>
<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="config-notice" id="config-restart-notice" style="display:none">
<span></span>
<span data-i18n="config.restartNotice">Changes take effect after restart.</span>
<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 id="providers-list" class="providers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</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>
</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>
<!-- Config Tab -->
<div class="tab-panel" id="tab-config">
<div class="extensions-container">
<div class="extensions-section">
<div class="config-section-header">
<h3 data-i18n="config.modelProviders">Model Providers</h3>
<button id="add-provider-btn" class="btn-add-provider" data-i18n="config.addProvider">+ Add Provider</button>
</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 class="config-notice" id="config-restart-notice" style="display:none">
<span></span>
<span data-i18n="config.restartNotice">Changes take effect after restart.</span>
</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 id="providers-list" class="providers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
</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>
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
+546 -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;
@@ -4157,6 +4433,215 @@ mark {
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;
}
/* --- Config Tab --- */
.config-section-header {
+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(),
}
}
}
+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")]
+10 -2
View File
@@ -22,6 +22,7 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
@@ -150,6 +151,7 @@ impl AnthropicOAuthProvider {
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response
@@ -766,9 +768,14 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are accepted
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
assert_eq!(duration, Some(std::time::Duration::from_secs(7200)));
assert_eq!(
duration,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic for Anthropic
@@ -779,6 +786,7 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+6
View File
@@ -167,6 +167,12 @@ impl CircuitBreakerProvider {
}
}
CircuitState::Open => {
debug_assert!(
false,
"BUG: record_success() called while circuit breaker is Open — \
check_allowed() was bypassed for provider {}",
self.inner.model_name()
);
// Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
+18 -10
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::{costs, session::SessionManager};
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -252,7 +252,7 @@ impl NearAiChatProvider {
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
@@ -260,9 +260,9 @@ impl NearAiChatProvider {
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
));
return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
@@ -2306,9 +2306,17 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are accepted
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
let huge = parse_retry_after_for_test("18446744073709551615");
assert_eq!(
huge,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic
@@ -2316,13 +2324,13 @@ mod tests {
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
Some(std::time::Duration::from_secs(secs))
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
))
Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
} else {
None
};
+2
View File
@@ -108,6 +108,8 @@ mod tests {
assert!(has_native_thinking("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b"));
assert!(has_native_thinking("MiniMax-M2.7"));
assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
}
#[test]
+23
View File
@@ -19,6 +19,12 @@ use crate::llm::provider::{
ToolCompletionResponse,
};
/// Upper bound for provider-suggested `Retry-After` delays.
///
/// This prevents malicious or malformed headers from turning a retryable
/// response into an effectively unbounded sleep.
pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600;
/// Returns `true` if the `LlmError` is transient and the request should be retried.
///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
@@ -67,6 +73,11 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
Duration::from_millis(delay_ms)
}
/// Clamp a provider-suggested retry delay to a safe maximum.
pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
}
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -421,4 +432,16 @@ mod tests {
panic!("Expected RateLimited error");
}
}
#[test]
fn cap_retry_after_clamps_huge_delays() {
assert_eq!(
cap_retry_after(Duration::from_secs(u64::MAX)),
Duration::from_secs(MAX_RETRY_AFTER_SECS)
);
assert_eq!(
cap_retry_after(Duration::from_secs(0)),
Duration::from_secs(0)
);
}
}
+21
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));
+16 -3
View File
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
print_info(http_webhook_secret_hint());
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
@@ -535,6 +535,10 @@ pub fn generate_webhook_secret() -> String {
generate_secret_with_length(32)
}
fn http_webhook_secret_hint() -> &'static str {
"The secret is stored in the encrypted secrets database and will be loaded automatically on startup."
}
fn validate_e164(account: &str) -> Result<(), String> {
if !account.starts_with('+') {
return Err("E.164 account must start with '+'".to_string());
@@ -1136,8 +1140,9 @@ mod tests {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
validate_cloudflare_token_format, validate_public_https_url,
SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
substitute_validation_placeholders, validate_cloudflare_token_format,
validate_public_https_url,
};
fn test_secrets_context() -> SecretsContext {
@@ -1337,4 +1342,12 @@ mod tests {
.to_string();
assert!(err.contains("DNS resolution failed"));
}
#[test]
fn test_http_webhook_secret_hint_reflects_current_behavior() {
let hint = http_webhook_secret_hint();
assert!(hint.contains("encrypted secrets database"));
assert!(hint.contains("loaded automatically on startup"));
assert!(!hint.contains("ironclaw secret get"));
}
}
+1563 -333
View File
File diff suppressed because it is too large Load Diff
+121 -19
View File
@@ -1,8 +1,9 @@
//! On-demand tool discovery (like CLI `--help`).
//!
//! Two levels of detail:
//! Three levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes)
//! - `include_schema: true`: adds the full typed JSON Schema
//! - `detail: "summary"`: adds curated rules, notes, and examples
//! - `detail: "schema"` / `include_schema: true`: adds the full typed JSON Schema
//!
//! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed.
@@ -13,7 +14,59 @@ use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolInfoDetail {
Names,
Summary,
Schema,
}
impl ToolInfoDetail {
fn parse(params: &serde_json::Value) -> Result<Self, ToolError> {
if params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Ok(Self::Schema);
}
match params.get("detail").and_then(|v| v.as_str()) {
None | Some("names") => Ok(Self::Names),
Some("summary") => Ok(Self::Summary),
Some("schema") => Ok(Self::Schema),
Some(other) => Err(ToolError::InvalidParameters(format!(
"invalid detail '{other}' (expected 'names', 'summary', or 'schema')"
))),
}
}
}
fn schema_param_names(schema: &serde_json::Value) -> Vec<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
fn fallback_summary(schema: &serde_json::Value) -> ToolDiscoverySummary {
ToolDiscoverySummary {
always_required: schema
.get("required")
.and_then(|v| v.as_array())
.map(|required| {
required
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
..ToolDiscoverySummary::default()
}
}
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
@@ -32,8 +85,7 @@ impl Tool for ToolInfoTool {
}
fn description(&self) -> &str {
"Get info about any tool: description and parameter names. \
Set include_schema to true for the full typed parameter schema."
"Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -44,9 +96,15 @@ impl Tool for ToolInfoTool {
"type": "string",
"description": "Name of the tool to get info about"
},
"detail": {
"type": "string",
"enum": ["names", "summary", "schema"],
"description": "Response detail level. 'names' returns parameter names only. 'summary' adds curated rules/examples. 'schema' returns the full discovery schema.",
"default": "names"
},
"include_schema": {
"type": "boolean",
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
"description": "Deprecated compatibility alias for detail='schema'. If true, include the full discovery schema.",
"default": false
}
},
@@ -61,10 +119,7 @@ impl Tool for ToolInfoTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let detail = ToolInfoDetail::parse(&params)?;
let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed(
@@ -77,13 +132,7 @@ impl Tool for ToolInfoTool {
})?;
let schema = tool.discovery_schema();
// Extract just param names from the schema's "properties" keys
let param_names: Vec<&str> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let param_names = schema_param_names(&schema);
let mut info = serde_json::json!({
"name": tool.name(),
@@ -91,8 +140,21 @@ impl Tool for ToolInfoTool {
"parameters": param_names,
});
if include_schema {
info["schema"] = schema;
match detail {
ToolInfoDetail::Names => {}
ToolInfoDetail::Summary => {
let summary = tool
.discovery_summary()
.unwrap_or_else(|| fallback_summary(&schema));
info["summary"] = serde_json::to_value(summary).map_err(|err| {
ToolError::ExecutionFailed(format!(
"failed to serialize discovery summary: {err}"
))
})?;
}
ToolInfoDetail::Schema => {
info["schema"] = schema;
}
}
Ok(ToolOutput::success(info, start.elapsed()))
@@ -135,6 +197,30 @@ mod tests {
assert!(info.get("schema").is_none());
}
#[tokio::test]
async fn test_tool_info_with_summary() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "summary"}),
&ctx,
)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
assert!(info["summary"].is_object());
assert_eq!(
info["summary"]["always_required"],
serde_json::json!(["message"])
);
}
#[tokio::test]
async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new());
@@ -157,6 +243,22 @@ mod tests {
assert!(info["schema"]["properties"].is_object());
}
#[tokio::test]
async fn test_tool_info_invalid_detail() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "verbose"}),
&ctx,
)
.await;
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
}
#[tokio::test]
async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new());
+27
View File
@@ -291,6 +291,33 @@ mod tests {
registry
}
#[tokio::test]
async fn test_execute_empty_tool_name_returns_not_found() {
// 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();
let result = execute_tool_with_safety(
&registry,
&safety,
"",
&serde_json::json!({}),
&test_job_ctx(),
)
.await;
assert!(
matches!(
result,
Err(crate::error::Error::Tool(
crate::error::ToolError::NotFound { .. }
))
),
"Empty tool name should return ToolError::NotFound, got: {result:?}"
);
}
#[tokio::test]
async fn test_execute_success() {
let registry = registry_with(vec![Arc::new(EchoTool)]).await;
+205 -42
View File
@@ -288,6 +288,71 @@ impl McpClient {
Ok(headers)
}
/// Re-run the MCP initialize handshake outside the OnceCell cache.
///
/// This is used for recoverable session-expiry failures when an MCP server
/// reports that the current session ID is no longer valid.
async fn reinitialize_session(&self) -> Result<InitializeResult, ToolError> {
if let Some(ref session_manager) = self.session_manager {
session_manager.terminate(&self.server_name).await;
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self
.transport
.send(&request, &self.build_request_headers().await?)
.await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self
.transport
.send(&notification, &self.build_request_headers().await?)
.await
{
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
}
/// Return true when the error looks like a recoverable MCP session expiry.
fn is_session_expiry_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("session")
&& (lower.contains("400")
|| lower.contains("missing session id")
|| lower.contains("no valid session id"))
}
/// Send a request to the MCP server with auth and session headers.
/// Automatically attempts token refresh on 401 errors (HTTP transports only).
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
@@ -297,13 +362,26 @@ impl McpClient {
return self.transport.send(&request, &headers).await;
}
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh)
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh
// or recoverable session reinitialization).
for attempt in 0..2 {
let headers = self.build_request_headers().await?;
let result = self.transport.send(&request, &headers).await;
match result {
Ok(response) => return Ok(response),
Err(ToolError::ExternalService(ref msg))
if attempt == 0
&& self.session_manager.is_some()
&& Self::is_session_expiry_error(msg) =>
{
tracing::debug!(
"MCP session expired, attempting reinitialize for '{}'",
self.server_name
);
self.reinitialize_session().await?;
continue;
}
Err(ToolError::ExternalService(ref msg))
if msg.contains("401")
|| msg.contains("Unauthorized")
@@ -362,47 +440,7 @@ impl McpClient {
{
return Ok(InitializeResult::default());
}
if let Some(ref session_manager) = self.session_manager {
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self.send_request(request).await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self.send_request(notification).await {
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
self.reinitialize_session().await
})
.await?;
@@ -865,6 +903,54 @@ mod tests {
}
}
/// Mock transport that can return errors and successful responses in a
/// controlled sequence.
struct RetryMockTransport {
supports_http: bool,
outcomes: std::sync::Mutex<std::collections::VecDeque<Result<McpResponse, ToolError>>>,
recorded_headers: std::sync::Mutex<Vec<HashMap<String, String>>>,
}
impl RetryMockTransport {
fn new(supports_http: bool, outcomes: Vec<Result<McpResponse, ToolError>>) -> Self {
Self {
supports_http,
outcomes: std::sync::Mutex::new(outcomes.into()),
recorded_headers: std::sync::Mutex::new(Vec::new()),
}
}
fn recorded_headers(&self) -> Vec<HashMap<String, String>> {
self.recorded_headers.lock().unwrap().clone()
}
}
#[async_trait]
impl McpTransport for RetryMockTransport {
async fn send(
&self,
_request: &McpRequest,
headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
self.recorded_headers.lock().unwrap().push(headers.clone());
let mut outcomes = self.outcomes.lock().unwrap();
if outcomes.is_empty() {
return Err(ToolError::ExternalService(
"No more mock outcomes".to_string(),
));
}
outcomes.pop_front().unwrap()
}
async fn shutdown(&self) -> Result<(), ToolError> {
Ok(())
}
fn supports_http_features(&self) -> bool {
self.supports_http
}
}
#[tokio::test]
async fn test_non_http_transport_skips_401_retry() {
// initialize response, then notification ack (consumed but ignored),
@@ -965,6 +1051,83 @@ mod tests {
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
}
#[tokio::test]
async fn test_http_session_error_triggers_reinitialize_and_retry() {
let init_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let notification_ack = McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
};
let notification_ack2 = notification_ack.clone();
let session_error = Err(ToolError::ExternalService(
"[test] MCP server returned status: 400 - No valid session ID provided".to_string(),
));
let reinit_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(2),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let call_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(3),
result: Some(serde_json::json!({
"content": [{"type": "text", "text": "pong"}],
"is_error": false
})),
error: None,
};
let transport = Arc::new(RetryMockTransport::new(
true,
vec![
Ok(init_response),
Ok(notification_ack),
session_error,
Ok(reinit_response),
Ok(notification_ack2),
Ok(call_response),
],
));
let session_manager = Arc::new(McpSessionManager::new());
let client = McpClient::new_with_transport(
"test-http",
transport.clone(),
Some(session_manager),
None,
"default",
None,
);
client.initialize().await.expect("initial handshake");
let result = client
.call_tool("echo", serde_json::json!({"input": "hello"}))
.await
.expect("call should recover after session expiry");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert_eq!(result.content[0].as_text(), Some("pong"));
let headers = transport.recorded_headers();
assert_eq!(headers.len(), 6);
}
#[test]
fn test_strip_top_level_nulls_removes_null_fields() {
let input = serde_json::json!({
+79 -22
View File
@@ -94,6 +94,15 @@ pub struct ToolRegistry {
}
impl ToolRegistry {
fn tool_definition(tool: &Arc<dyn Tool>) -> ToolDefinition {
let schema = tool.schema();
ToolDefinition {
name: schema.name,
description: schema.description,
parameters: schema.parameters,
}
}
/// Create a new empty registry.
pub fn new() -> Self {
Self {
@@ -206,11 +215,7 @@ impl ToolRegistry {
.read()
.await
.values()
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -221,13 +226,7 @@ impl ToolRegistry {
let tools = self.tools.read().await;
names
.iter()
.filter_map(|name| {
tools.get(*name).map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
})
.filter_map(|name| tools.get(*name).map(Self::tool_definition))
.collect()
}
@@ -282,11 +281,7 @@ impl ToolRegistry {
.await
.values()
.filter(|tool| tool.domain() == domain)
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect()
}
@@ -312,11 +307,7 @@ impl ToolRegistry {
ApprovalRequirement::Never
)
})
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -788,6 +779,7 @@ impl std::fmt::Debug for ToolRegistry {
mod tests {
use super::*;
use crate::tools::registry::EchoTool;
use crate::tools::tool::ToolDiscoverySummary;
#[tokio::test]
async fn test_register_and_get() {
@@ -818,6 +810,71 @@ mod tests {
assert_eq!(defs[0].name, "echo");
}
#[tokio::test]
async fn test_tool_definitions_use_tool_schema() {
struct DiscoveryTool;
#[async_trait::async_trait]
impl Tool for DiscoveryTool {
fn name(&self) -> &str {
"discovery_tool"
}
fn description(&self) -> &str {
"Discovery test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
})
}
fn discovery_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"extra": { "type": "string" }
}
})
}
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
Some(ToolDiscoverySummary {
notes: vec!["extra guidance".into()],
..ToolDiscoverySummary::default()
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
unreachable!()
}
}
let registry = ToolRegistry::new();
registry.register(Arc::new(DiscoveryTool)).await;
let defs = registry.tool_definitions().await;
let def = defs
.iter()
.find(|def| def.name == "discovery_tool")
.expect("tool definition should be present");
assert!(
def.description.contains("tool_info"),
"live tool definition should include schema hint: {}",
def.description
);
assert!(def.parameters.get("extra").is_none());
}
#[tokio::test]
async fn test_builtin_tool_cannot_be_shadowed() {
let registry = ToolRegistry::new();
+1 -9
View File
@@ -605,15 +605,7 @@ mod tests {
),
(
"event_emit",
serde_json::json!({
"type": "object",
"properties": {
"event_source": { "type": "string", "description": "Event source" },
"event_type": { "type": "string", "description": "Event type" },
"payload": { "type": "object", "description": "Event payload", "properties": {} }
},
"required": ["event_source", "event_type"]
}),
crate::tools::builtin::routine::event_emit_parameters_schema(),
),
// Job tools with complex deps
(
+35 -2
View File
@@ -231,6 +231,19 @@ impl ToolSchema {
}
}
/// Curated discovery guidance surfaced by `tool_info(detail: "summary")`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ToolDiscoverySummary {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always_required: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditional_requirements: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<serde_json::Value>,
}
/// Trait for tools that the agent can use.
#[async_trait]
pub trait Tool: Send + Sync {
@@ -347,12 +360,32 @@ pub trait Tool: Send + Sync {
self.parameters_schema()
}
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
///
/// Default: no custom summary; callers may derive a minimal fallback from
/// `discovery_schema()`.
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
let parameters = self.parameters_schema();
let has_discovery_hint =
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
let description = if has_discovery_hint {
format!(
"{} (call tool_info(name: \"{}\", detail: \"summary\") for rules/examples or detail: \"schema\" for the full discovery schema)",
self.description(),
self.name()
)
} else {
self.description().to_string()
};
ToolSchema {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
description,
parameters,
}
}
}
+5
View File
@@ -6,6 +6,8 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::llm::retry::cap_retry_after;
/// Error type for embedding operations.
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
@@ -232,6 +234,7 @@ impl EmbeddingProvider for OpenAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -374,6 +377,7 @@ impl EmbeddingProvider for NearAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -690,6 +694,7 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+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()
+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"])
+176 -21
View File
@@ -142,16 +142,18 @@ mod tests {
match &routine.action {
RoutineAction::Lightweight {
prompt,
context_paths,
use_tools,
max_tool_rounds,
..
} => {
assert!(prompt.contains("Check system status"));
assert_eq!(context_paths, &vec!["context/priorities.md".to_string()]);
assert!(*use_tools, "lightweight routine should keep use_tools=true");
assert_eq!(*max_tool_rounds, 2);
}
other => panic!("expected lightweight action, got {other:?}"),
other => panic!("expected lightweight routine action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
@@ -369,7 +371,132 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: skill_install_routine_webhook_sim
// Test 8: routine_create_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_create_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_create_grouped.json"
))
.expect("failed to load routine_create_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped cron routine with delivery settings")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "weekday-digest")
.await
.expect("get_routine_by_name")
.expect("weekday-digest should exist");
match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(timezone.as_deref(), Some("UTC"));
}
other => panic!("expected cron trigger, got {other:?}"),
}
match &routine.action {
RoutineAction::FullJob {
description,
tool_permissions,
..
} => {
assert!(description.contains("Prepare the morning digest"));
assert_eq!(
tool_permissions,
&vec!["message".to_string(), "http".to_string()]
);
}
other => panic!("expected full_job action, got {other:?}"),
}
assert_eq!(routine.notify.channel.as_deref(), Some("telegram"));
assert_eq!(routine.notify.user.as_deref(), Some("ops-team"));
assert_eq!(routine.guardrails.cooldown.as_secs(), 30);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 9: routine_system_event_emit_grouped
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_system_event_emit_grouped() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_system_event_emit_grouped.json"
))
.expect("failed to load routine_system_event_emit_grouped.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a grouped system-event routine and emit a matching event")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "grouped-gh-issue-watch")
.await
.expect("get_routine_by_name")
.expect("grouped-gh-issue-watch should exist");
match &routine.trigger {
Trigger::SystemEvent {
source,
event_type,
filters,
} => {
assert_eq!(source, "github");
assert_eq!(event_type, "issue.opened");
assert_eq!(
filters.get("repository").map(String::as_str),
Some("nearai/ironclaw")
);
assert_eq!(filters.get("priority").map(String::as_str), Some("p1"));
}
other => panic!("expected system_event trigger, got {other:?}"),
}
let results = rig.tool_results();
let emit_result = results
.iter()
.find(|(n, _)| n == "event_emit")
.expect("event_emit result missing");
let emit_json: serde_json::Value =
serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON");
assert!(
emit_json["fired_routines"].as_u64().unwrap_or(0) > 0,
"event_emit should have fired at least one grouped routine: {:?}",
emit_result.1
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 10: skill_install_routine_webhook_sim
// -----------------------------------------------------------------------
#[tokio::test]
@@ -571,10 +698,11 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test: tool_info_discovery (two-level detail)
// Test: tool_info_discovery (three-level detail)
// -----------------------------------------------------------------------
// Verifies the tool_info built-in returns:
// - Default (no include_schema): name, description, parameter names array
// - `detail: "summary"`: curated summary guidance
// - With include_schema: true: adds full typed JSON Schema
#[tokio::test]
@@ -597,13 +725,13 @@ mod tests {
rig.verify_trace_expects(&trace, &responses);
// tool_info should have been called twice (echo + time), both succeeding.
// tool_info should have been called three times (echo + routine_create + time), all succeeding.
let completed = rig.tool_calls_completed();
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(
tool_info_calls.len(),
2,
"Expected 2 tool_info calls, got {tool_info_calls:?}"
3,
"Expected 3 tool_info calls, got {tool_info_calls:?}"
);
assert!(
tool_info_calls.iter().all(|(_, ok)| *ok),
@@ -613,44 +741,71 @@ mod tests {
// Verify the results contain expected fields.
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
// First call was for "echo" (default, no include_schema) — result should
// contain "echo" and "parameters" as an array of names (not full schema).
let echo_result = info_results
let echo_json = info_json
.iter()
.find(|(_, preview)| preview.contains("echo"))
.find(|info| info["name"] == "echo")
.expect("tool_info result should contain 'echo'");
assert!(
echo_result.1.contains("message"),
echo_json["parameters"]
.as_array()
.is_some_and(|params| params.iter().any(|param| param == "message")),
"echo default result should list 'message' parameter name: {:?}",
echo_result.1
echo_json
);
// Default mode should NOT include the full "schema" key
let echo_json: serde_json::Value = serde_json::from_str(&echo_result.1)
.expect("echo tool_info result should be valid JSON");
assert!(
echo_json.get("schema").is_none(),
"Default tool_info should not include schema field: {:?}",
echo_result.1
echo_json
);
// Second call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_result = info_results
// Second call was for "routine_create" with detail: "summary" — result
// should contain a summary object with rules/examples.
let routine_json = info_json
.iter()
.find(|(_, preview)| preview.contains("time"))
.find(|info| info["name"] == "routine_create")
.expect("tool_info result should contain 'routine_create'");
assert!(
routine_json.get("summary").is_some(),
"detail: summary should include summary field: {:?}",
routine_json
);
assert!(
routine_json["summary"]["conditional_requirements"]
.as_array()
.is_some_and(|rules| rules.iter().any(|rule| {
rule.as_str()
.is_some_and(|rule| rule.contains("request.kind='cron'"))
})),
"routine_create summary should mention cron requirement: {:?}",
routine_json
);
// Third call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_json = info_json
.iter()
.find(|info| info["name"] == "time")
.expect("tool_info result should contain 'time'");
let time_json: serde_json::Value = serde_json::from_str(&time_result.1)
.expect("time tool_info result should be valid JSON");
assert!(
time_json.get("schema").is_some(),
"include_schema: true should include schema field: {:?}",
time_result.1
time_json
);
assert!(
time_json["schema"]["properties"].is_object(),
"schema should have properties: {:?}",
time_result.1
time_json
);
rig.shutdown();
+245 -10
View File
@@ -238,7 +238,13 @@ mod tests {
"default",
"deploy to production now",
);
let fired = engine.check_event_triggers(&matching_msg).await;
let fired = engine
.check_event_triggers(
&matching_msg.user_id,
&matching_msg.channel,
&matching_msg.content,
)
.await;
assert!(
fired >= 1,
"Expected >= 1 routine fired on match, got {fired}"
@@ -255,7 +261,13 @@ mod tests {
"default",
"check the staging environment",
);
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
let fired_neg = engine
.check_event_triggers(
&non_matching_msg.user_id,
&non_matching_msg.channel,
&non_matching_msg.content,
)
.await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
}
@@ -315,7 +327,9 @@ mod tests {
"guest-sender",
"deploy to production now",
);
let guest_fired = engine.check_event_triggers(&guest_msg).await;
let guest_fired = engine
.check_event_triggers(&guest_msg.user_id, &guest_msg.channel, &guest_msg.content)
.await;
assert_eq!(
guest_fired, 0,
"Guest scope must not fire owner event routines"
@@ -338,7 +352,9 @@ mod tests {
"owner-sender",
"deploy to production now",
);
let owner_fired = engine.check_event_triggers(&owner_msg).await;
let owner_fired = engine
.check_event_triggers(&owner_msg.user_id, &owner_msg.channel, &owner_msg.content)
.await;
assert!(
owner_fired >= 1,
"Owner scope should fire matching owner event routine"
@@ -562,7 +578,9 @@ mod tests {
"default",
"test-cooldown trigger",
);
let fired1 = engine.check_event_triggers(&msg).await;
let fired1 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired1 >= 1, "First fire should work");
// Give spawn time, then update last_run_at to simulate recent execution.
@@ -577,7 +595,9 @@ mod tests {
engine.refresh_event_cache().await;
// Second fire should be blocked by cooldown.
let fired2 = engine.check_event_triggers(&msg).await;
let fired2 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(fired2, 0, "Second fire should be blocked by cooldown");
}
@@ -745,7 +765,9 @@ mod tests {
engine.refresh_event_cache().await;
let msg = IncomingMessage::new("test", "default", "DISABLE_ME");
let fired_before = engine.check_event_triggers(&msg).await;
let fired_before = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired_before >= 1, "Expected routine to fire before disable");
// Simulate what routines_toggle_handler now does: update DB, then refresh.
@@ -754,7 +776,9 @@ mod tests {
db.update_routine(&routine).await.expect("update_routine");
engine.refresh_event_cache().await;
let fired_after = engine.check_event_triggers(&msg).await;
let fired_after = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(
fired_after, 0,
"Disabled routine must not fire after cache refresh"
@@ -780,7 +804,10 @@ mod tests {
let msg = IncomingMessage::new("test", "default", "DELETE_ME");
assert!(
engine.check_event_triggers(&msg).await >= 1,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await
>= 1,
"Expected routine to fire before delete"
);
@@ -789,9 +816,217 @@ mod tests {
engine.refresh_event_cache().await;
assert_eq!(
engine.check_event_triggers(&msg).await,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await,
0,
"Deleted routine must not fire after cache refresh"
);
}
// -----------------------------------------------------------------------
// Test: full_job per-routine concurrency blocks second fire (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_max_concurrent_blocks_second_fire_while_first_active() {
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::error::RoutineError;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Stub LLM — fire_manual will be rejected before any LLM call
let trace = LlmTrace::single_turn(
"stub",
"stub",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 10,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(4);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
llm,
ws,
notify_tx,
None, // no scheduler — rejected before dispatch
tools,
safety,
));
// Create a full_job routine with max_concurrent = 1
let routine = Routine {
id: Uuid::new_v4(),
name: "concurrent-guard".to_string(),
description: "test max_concurrent for full_job".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "t".to_string(),
description: "d".to_string(),
max_iterations: 3,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::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(),
};
db.create_routine(&routine).await.expect("create_routine");
// Simulate first full_job run still active: the fix keeps the
// routine_run in Running state while the linked job executes.
let active_run = RoutineRun {
id: Uuid::new_v4(),
routine_id: routine.id,
trigger_type: "cron".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: Utc::now(),
};
db.create_routine_run(&active_run)
.await
.expect("create_routine_run");
// Attempt to fire the same routine again — must be rejected
let result = engine.fire_manual(routine.id, None).await;
assert!(
matches!(result, Err(RoutineError::MaxConcurrent { .. })),
"second fire while first full_job active must be rejected by max_concurrent=1, got: {:?}",
result
);
}
// -----------------------------------------------------------------------
// Test: global running_count tracks live full_job runs (issue #1318)
// -----------------------------------------------------------------------
#[tokio::test]
async fn global_concurrency_counts_live_full_job_runs() {
use std::sync::atomic::Ordering;
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
let trace = LlmTrace::single_turn(
"test-global-limit",
"check",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 50,
output_tokens: 5,
},
expected_tool_results: vec![],
}],
);
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
let tools = Arc::new(ToolRegistry::new());
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
}));
// Configure global limit of 1
let config = RoutineConfig {
max_concurrent_routines: 1,
..RoutineConfig::default()
};
let engine = Arc::new(RoutineEngine::new(
config,
db.clone(),
llm,
ws,
notify_tx,
None,
tools,
safety,
));
// Insert a due cron routine
let mut routine = make_routine(
"global-limit-test",
Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
},
"Check status.",
);
routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1));
db.create_routine(&routine).await.expect("create_routine");
// Simulate one full_job from another routine holding the global slot.
// With the fix, running_count stays elevated for the full job duration.
engine
.running_count_for_test()
.fetch_add(1, Ordering::Relaxed);
// check_cron_triggers should see global limit hit and skip
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let runs = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
runs.is_empty(),
"cron routine must not fire when global limit is reached by live full_job"
);
// Release the global slot
engine
.running_count_for_test()
.fetch_sub(1, Ordering::Relaxed);
// Now the routine should fire
engine.check_cron_triggers().await;
tokio::time::sleep(Duration::from_millis(200)).await;
// Because the first check skipped it, next_fire_at is unchanged —
// the second check should see it as still due and fire it.
let runs_after = db
.list_routine_runs(routine.id, 10)
.await
.expect("list_routine_runs");
assert!(
!runs_after.is_empty(),
"cron routine should fire after global slot is released"
);
}
}
@@ -0,0 +1,66 @@
{
"model_name": "test-routine-create-grouped",
"expects": {
"tools_used": ["routine_create", "routine_list"],
"all_tools_succeeded": true,
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_1",
"name": "routine_create",
"arguments": {
"name": "weekday-digest",
"prompt": "Prepare the morning digest for the ops team.",
"description": "Weekday digest for morning operations",
"request": {
"kind": "cron",
"schedule": "0 0 9 * * MON-FRI",
"timezone": "UTC"
},
"execution": {
"mode": "full_job",
"tool_permissions": ["message", "http"]
},
"delivery": {
"channel": "telegram",
"user": "ops-team"
},
"advanced": {
"cooldown_secs": 30
}
}
}
],
"input_tokens": 130,
"output_tokens": 44
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rl_grouped_1",
"name": "routine_list",
"arguments": {}
}
],
"input_tokens": 190,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
"input_tokens": 250,
"output_tokens": 24
}
}
]
}
@@ -0,0 +1,74 @@
{
"model_name": "test-routine-system-event-emit-grouped",
"expects": {
"tools_used": ["routine_create", "event_emit"],
"all_tools_succeeded": true,
"tool_results_contain": {
"event_emit": "fired_routines"
}
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_grouped_system_1",
"name": "routine_create",
"arguments": {
"name": "grouped-gh-issue-watch",
"prompt": "Summarize the new issue and propose next steps.",
"description": "React to important GitHub issue.opened events",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository": "nearai/ironclaw",
"priority": "p1"
}
},
"execution": {
"mode": "full_job",
"tool_permissions": ["shell"]
}
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ee_grouped_1",
"name": "event_emit",
"arguments": {
"event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "nearai/ironclaw",
"priority": "p1",
"issue_number": 123,
"title": "Support grouped routine create requests"
}
}
}
],
"input_tokens": 180,
"output_tokens": 30
}
},
{
"response": {
"type": "text",
"content": "Created the grouped system-event routine and emitted a matching GitHub event.",
"input_tokens": 230,
"output_tokens": 18
}
}
]
}
+18 -4
View File
@@ -24,6 +24,20 @@
"output_tokens": 20
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_routine_create",
"name": "tool_info",
"arguments": { "name": "routine_create", "detail": "summary" }
}
],
"input_tokens": 160,
"output_tokens": 25
}
},
{
"response": {
"type": "tool_calls",
@@ -34,16 +48,16 @@
"arguments": { "name": "time", "include_schema": true }
}
],
"input_tokens": 200,
"input_tokens": 240,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I found the info for both tools. The echo tool has a 'message' parameter. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 400,
"output_tokens": 40
"content": "I found the info for all three tools. The echo tool has a 'message' parameter. routine_create's summary explains that cron needs request.schedule, message_event needs request.pattern, and system_event needs request.source plus request.event_type. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 520,
"output_tokens": 60
}
}
]
+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(
+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();