feat: Add hooks, workflow, MCP support and core modules
Phase 2: Hooks System - Event-driven execution with HookEvent/HookAction types - HookManager for registration and execution Phase 3: Multi-Agent Workflow - Workflow orchestration with dependency graphs - WorkflowStep with conditions and retry support Phase 4: MCP (Model Context Protocol) Support - McpServer for external tool servers - McpManager for multiple server management Also includes core modules: cache, error_policy, feature_flags, git, logger, plugin, retry, rules 662 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3f6fbeb498
commit
48dfa915c7
27 changed files with 4793 additions and 34 deletions
|
|
@ -1,57 +1,316 @@
|
|||
# .miyabi Directory
|
||||
|
||||
Miyabi CLI設定ディレクトリ
|
||||
Miyabi CLI設定・拡張ディレクトリ
|
||||
|
||||
## 構造
|
||||
## ディレクトリ構造
|
||||
|
||||
```
|
||||
.miyabi/
|
||||
├── config.toml # メイン設定ファイル
|
||||
├── README.md # このファイル
|
||||
├── agents/
|
||||
│ └── specs/ # Agent仕様定義
|
||||
├── commands/ # カスタムコマンド
|
||||
├── commands/ # カスタムスラッシュコマンド
|
||||
├── prompts/ # 再利用可能プロンプト
|
||||
├── templates/ # テンプレート
|
||||
├── sessions/ # 保存されたセッション
|
||||
└── config.toml # 設定ファイル
|
||||
├── templates/ # コード・ドキュメントテンプレート
|
||||
├── sessions/ # 保存されたセッションデータ
|
||||
└── tasks/ # タスク管理データ
|
||||
```
|
||||
|
||||
## 設定ファイル
|
||||
## 設定ファイル (config.toml)
|
||||
|
||||
`config.toml` で設定をカスタマイズ:
|
||||
### API設定
|
||||
|
||||
```toml
|
||||
[api]
|
||||
model = "claude-sonnet-4-20250514"
|
||||
# API Key (環境変数 ANTHROPIC_API_KEY でも設定可能)
|
||||
api_key = "sk-ant-..."
|
||||
|
||||
# 使用モデル
|
||||
model = "claude-sonnet-4-5-20250929"
|
||||
|
||||
# 最大トークン数
|
||||
max_tokens = 8192
|
||||
|
||||
# タイムアウト (秒)
|
||||
timeout_secs = 120
|
||||
|
||||
# リトライ回数
|
||||
max_retries = 3
|
||||
|
||||
# 拡張思考モード
|
||||
thinking = false
|
||||
|
||||
# システムプロンプト
|
||||
system_prompt = "You are a helpful assistant"
|
||||
```
|
||||
|
||||
### UI設定
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
# テーマ: tokyo-night, dracula, monokai, etc.
|
||||
theme = "tokyo-night"
|
||||
|
||||
# Vimキーバインド
|
||||
vim_mode = false
|
||||
|
||||
# サイドバー表示
|
||||
show_sidebar = false
|
||||
|
||||
# ステータスバー表示
|
||||
show_status_bar = true
|
||||
|
||||
# パンくずリスト表示
|
||||
show_breadcrumb = true
|
||||
|
||||
# コードの行番号表示
|
||||
show_line_numbers = true
|
||||
```
|
||||
|
||||
### セッション設定
|
||||
|
||||
```toml
|
||||
[session]
|
||||
# 自動保存
|
||||
auto_save = true
|
||||
|
||||
# 保存間隔 (秒)
|
||||
auto_save_interval = 30
|
||||
|
||||
# 最大セッション数
|
||||
max_sessions = 100
|
||||
```
|
||||
|
||||
### ツール設定
|
||||
|
||||
```toml
|
||||
[tools]
|
||||
# Bashツール有効化
|
||||
enable_bash = true
|
||||
|
||||
# ファイル操作ツール有効化
|
||||
enable_file_tools = true
|
||||
|
||||
# 検索ツール有効化
|
||||
enable_search_tools = true
|
||||
|
||||
# 低リスク操作の自動承認
|
||||
auto_approve_low_risk = false
|
||||
|
||||
# Bashタイムアウト (秒)
|
||||
bash_timeout = 120
|
||||
```
|
||||
|
||||
## カスタムコマンド
|
||||
|
||||
`commands/` 配下に `*.md` ファイルを作成してカスタムコマンドを定義。
|
||||
`commands/` 配下に `.md` ファイルを作成してスラッシュコマンドを定義。
|
||||
|
||||
### 例: /review コマンド
|
||||
|
||||
`commands/review.md`:
|
||||
```markdown
|
||||
# コードレビュー
|
||||
|
||||
以下のコードをレビューしてください:
|
||||
|
||||
1. バグの可能性
|
||||
2. パフォーマンス問題
|
||||
3. セキュリティ脆弱性
|
||||
4. コーディング規約違反
|
||||
|
||||
改善提案も含めてください。
|
||||
```
|
||||
|
||||
使用: TUIで `/review` と入力
|
||||
|
||||
## プロンプトテンプレート
|
||||
|
||||
`prompts/` 配下に再利用可能なプロンプトを保存。
|
||||
|
||||
### 例: Rustコードレビュー
|
||||
|
||||
`prompts/rust-review.md`:
|
||||
```markdown
|
||||
# Rustコードレビューチェックリスト
|
||||
|
||||
- [ ] unwrap/expect の使用箇所
|
||||
- [ ] エラーハンドリング
|
||||
- [ ] 所有権とライフタイム
|
||||
- [ ] unsafe コードの妥当性
|
||||
- [ ] Clippy警告の確認
|
||||
```
|
||||
|
||||
## Agent仕様
|
||||
|
||||
`agents/specs/` にカスタムAgent仕様を定義。
|
||||
|
||||
### 例: コードリファクタリングAgent
|
||||
|
||||
`agents/specs/refactor-agent.yaml`:
|
||||
```yaml
|
||||
name: refactor-agent
|
||||
version: "1.0"
|
||||
description: コードリファクタリング専門Agent
|
||||
|
||||
capabilities:
|
||||
- code_analysis
|
||||
- refactoring
|
||||
- testing
|
||||
|
||||
preferences:
|
||||
style: functional
|
||||
error_handling: result
|
||||
max_iterations: 10
|
||||
```
|
||||
|
||||
## セッション管理
|
||||
|
||||
### コマンドライン操作
|
||||
|
||||
```bash
|
||||
# セッション一覧
|
||||
miyabi sessions
|
||||
|
||||
# 特定セッションで再開
|
||||
miyabi --session <session-id>
|
||||
|
||||
# Markdownエクスポート
|
||||
miyabi sessions -m <session-id>
|
||||
|
||||
# 削除
|
||||
# JSONエクスポート
|
||||
miyabi sessions -e <session-id>
|
||||
|
||||
# セッション削除
|
||||
miyabi sessions -d <session-id>
|
||||
```
|
||||
|
||||
### セッションファイル形式
|
||||
|
||||
セッションは `sessions/` に JSON 形式で保存:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T01:00:00Z",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"messages": [...]
|
||||
}
|
||||
```
|
||||
|
||||
## プロジェクトルール (.miyabirules)
|
||||
|
||||
プロジェクトルートに `.miyabirules` ファイルを配置して、プロジェクト固有のルールを定義:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
|
||||
rules:
|
||||
- name: "no-unwrap"
|
||||
pattern: ".unwrap()"
|
||||
suggestion: "Use ? operator or proper error handling"
|
||||
file_extensions: ["rs"]
|
||||
severity: "warning"
|
||||
enabled: true
|
||||
|
||||
- name: "no-println-debug"
|
||||
pattern: "println!"
|
||||
suggestion: "Use tracing macros for logging"
|
||||
file_extensions: ["rs"]
|
||||
severity: "info"
|
||||
|
||||
agent_preferences:
|
||||
codegen:
|
||||
style: "functional"
|
||||
error_handling: "result"
|
||||
min_score: 80
|
||||
clippy_strict: true
|
||||
|
||||
settings:
|
||||
auto_format: true
|
||||
max_file_size: 1048576
|
||||
```
|
||||
|
||||
## 環境変数
|
||||
|
||||
設定ファイルの値は環境変数でオーバーライド可能:
|
||||
|
||||
```bash
|
||||
# 必須
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# オプション
|
||||
export MIYABI_MODEL="claude-sonnet-4-5-20250929"
|
||||
export MIYABI_MAX_TOKENS="16384"
|
||||
export MIYABI_THINKING="true"
|
||||
export MIYABI_THEME="dracula"
|
||||
```
|
||||
|
||||
## Core Library機能の利用
|
||||
|
||||
### キャッシュ
|
||||
|
||||
LLMレスポンスのキャッシュで高速化:
|
||||
|
||||
```rust
|
||||
use miyabi_core::{create_llm_cache, LLMCacheKey};
|
||||
|
||||
let cache = create_llm_cache(); // 1時間TTL
|
||||
```
|
||||
|
||||
### フィーチャーフラグ
|
||||
|
||||
機能の段階的ロールアウト:
|
||||
|
||||
```rust
|
||||
use miyabi_core::FeatureFlagManager;
|
||||
|
||||
let flags = FeatureFlagManager::new();
|
||||
flags.set_flag("new_ui", true);
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
API障害時の保護:
|
||||
|
||||
```rust
|
||||
use miyabi_core::CircuitBreaker;
|
||||
|
||||
let breaker = CircuitBreaker::default();
|
||||
// 5回失敗で開く、60秒後に半開
|
||||
```
|
||||
|
||||
### プラグイン
|
||||
|
||||
カスタム機能の追加:
|
||||
|
||||
```rust
|
||||
use miyabi_core::{Plugin, PluginManager};
|
||||
|
||||
let manager = PluginManager::new();
|
||||
manager.register(Box::new(MyPlugin))?;
|
||||
```
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### API接続エラー
|
||||
|
||||
1. API Keyの確認: `echo $ANTHROPIC_API_KEY`
|
||||
2. ネットワーク接続確認
|
||||
3. タイムアウト値の増加
|
||||
|
||||
### セッション破損
|
||||
|
||||
1. `sessions/` から該当ファイルを削除
|
||||
2. `miyabi sessions` で確認
|
||||
|
||||
### 設定が反映されない
|
||||
|
||||
1. 設定ファイルの構文確認: `cat config.toml`
|
||||
2. 環境変数の確認
|
||||
3. CLIオプションの優先順位確認
|
||||
|
||||
## 使い方
|
||||
|
||||
```bash
|
||||
|
|
@ -61,6 +320,9 @@ miyabi
|
|||
# バージョン情報
|
||||
miyabi version
|
||||
|
||||
# ステータス確認
|
||||
miyabi status
|
||||
|
||||
# ヘルプ
|
||||
miyabi --help
|
||||
```
|
||||
|
|
|
|||
38
.miyabi/commands/explain.md
Normal file
38
.miyabi/commands/explain.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Code Explanation
|
||||
|
||||
Provide a detailed explanation of the specified code:
|
||||
|
||||
## Analysis Sections
|
||||
|
||||
### 1. Overview
|
||||
- Purpose and responsibility
|
||||
- Where it fits in the architecture
|
||||
- Key dependencies
|
||||
|
||||
### 2. Data Flow
|
||||
- Inputs and outputs
|
||||
- Data transformations
|
||||
- Side effects
|
||||
|
||||
### 3. Key Concepts
|
||||
- Design patterns used
|
||||
- Algorithms implemented
|
||||
- Important abstractions
|
||||
|
||||
### 4. Line-by-Line Breakdown
|
||||
For complex sections, explain:
|
||||
- What each block does
|
||||
- Why it's implemented this way
|
||||
- Any non-obvious behavior
|
||||
|
||||
### 5. Usage Examples
|
||||
```rust
|
||||
// Example of how to use this code
|
||||
```
|
||||
|
||||
### 6. Potential Improvements
|
||||
- Suggestions for enhancement
|
||||
- Known limitations
|
||||
- Future considerations
|
||||
|
||||
Target audience: Developers new to this codebase.
|
||||
20
.miyabi/commands/refactor.md
Normal file
20
.miyabi/commands/refactor.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Code Refactoring
|
||||
|
||||
Refactor the specified code with the following goals:
|
||||
|
||||
1. **Improve Readability** - Clear naming, logical structure, remove complexity
|
||||
2. **Reduce Duplication** - Extract common patterns into reusable functions
|
||||
3. **Enhance Maintainability** - Better separation of concerns, SOLID principles
|
||||
4. **Optimize Performance** - Only if it doesn't sacrifice readability
|
||||
|
||||
Guidelines:
|
||||
- Preserve existing behavior (no functional changes)
|
||||
- Keep existing tests passing
|
||||
- Add comments only where logic isn't self-evident
|
||||
- Prefer small, focused functions over large ones
|
||||
|
||||
Output format:
|
||||
1. Analysis of current issues
|
||||
2. Refactoring plan
|
||||
3. Refactored code with explanations
|
||||
4. Summary of improvements
|
||||
17
.miyabi/commands/review.md
Normal file
17
.miyabi/commands/review.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Code Review
|
||||
|
||||
Please review the provided code for:
|
||||
|
||||
1. **Bugs & Logic Errors** - Identify potential bugs, edge cases, and logical issues
|
||||
2. **Performance** - Spot inefficient algorithms, unnecessary allocations, or N+1 queries
|
||||
3. **Security** - Check for vulnerabilities like injection, XSS, or unsafe operations
|
||||
4. **Code Style** - Ensure consistency with project conventions
|
||||
5. **Error Handling** - Verify proper error propagation and user-friendly messages
|
||||
|
||||
For each issue found, provide:
|
||||
- Location (file:line)
|
||||
- Severity (Critical/High/Medium/Low)
|
||||
- Description
|
||||
- Suggested fix with code example
|
||||
|
||||
End with a summary score (1-10) and overall assessment.
|
||||
33
.miyabi/commands/test.md
Normal file
33
.miyabi/commands/test.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Test Generation
|
||||
|
||||
Generate comprehensive tests for the specified code:
|
||||
|
||||
## Test Types
|
||||
1. **Unit Tests** - Test individual functions in isolation
|
||||
2. **Integration Tests** - Test component interactions
|
||||
3. **Edge Cases** - Boundary conditions, empty inputs, max values
|
||||
4. **Error Cases** - Invalid inputs, failure scenarios
|
||||
|
||||
## Requirements
|
||||
- Use the project's testing framework
|
||||
- Follow Arrange-Act-Assert pattern
|
||||
- Descriptive test names that explain the scenario
|
||||
- Mock external dependencies
|
||||
- Aim for high coverage of critical paths
|
||||
|
||||
## Output Format
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_function_name_scenario() {
|
||||
// Arrange
|
||||
// Act
|
||||
// Assert
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Include explanation of test strategy and coverage goals.
|
||||
41
.miyabi/prompts/rust-best-practices.md
Normal file
41
.miyabi/prompts/rust-best-practices.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Rust Best Practices Checklist
|
||||
|
||||
When writing or reviewing Rust code, ensure:
|
||||
|
||||
## Error Handling
|
||||
- [ ] Use `Result<T, E>` for fallible operations
|
||||
- [ ] Avoid `.unwrap()` and `.expect()` in library code
|
||||
- [ ] Use `?` operator for error propagation
|
||||
- [ ] Create custom error types with `thiserror`
|
||||
- [ ] Provide context with `.context()` or `.with_context()`
|
||||
|
||||
## Memory & Performance
|
||||
- [ ] Prefer `&str` over `String` for function parameters
|
||||
- [ ] Use `Cow<str>` when ownership is conditional
|
||||
- [ ] Avoid unnecessary cloning
|
||||
- [ ] Use iterators instead of index loops
|
||||
- [ ] Consider `Vec::with_capacity()` for known sizes
|
||||
|
||||
## Safety
|
||||
- [ ] Minimize `unsafe` code
|
||||
- [ ] Document safety invariants for `unsafe` blocks
|
||||
- [ ] Validate all external input
|
||||
- [ ] Use `#[must_use]` for important return values
|
||||
|
||||
## Async
|
||||
- [ ] Use `tokio::spawn` for concurrent tasks
|
||||
- [ ] Avoid blocking operations in async context
|
||||
- [ ] Use `tokio::select!` for racing futures
|
||||
- [ ] Handle cancellation properly
|
||||
|
||||
## Testing
|
||||
- [ ] Unit tests in same file with `#[cfg(test)]`
|
||||
- [ ] Integration tests in `tests/` directory
|
||||
- [ ] Use `#[should_panic]` for expected panics
|
||||
- [ ] Test error cases, not just happy paths
|
||||
|
||||
## Documentation
|
||||
- [ ] Doc comments for public items (`///`)
|
||||
- [ ] Examples in doc comments
|
||||
- [ ] Module-level documentation (`//!`)
|
||||
- [ ] Link related items with `[`item`]`
|
||||
76
.miyabirules
Normal file
76
.miyabirules
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Miyabi CLI Project Rules
|
||||
# This file defines project-specific rules and agent preferences
|
||||
|
||||
version: 1
|
||||
|
||||
rules:
|
||||
# Rust best practices
|
||||
- name: "no-unwrap"
|
||||
pattern: ".unwrap()"
|
||||
suggestion: "Use ? operator or proper error handling instead of unwrap()"
|
||||
file_extensions: ["rs"]
|
||||
severity: "warning"
|
||||
enabled: true
|
||||
|
||||
- name: "no-expect"
|
||||
pattern: ".expect("
|
||||
suggestion: "Consider using ? operator or match for better error messages"
|
||||
file_extensions: ["rs"]
|
||||
severity: "info"
|
||||
enabled: true
|
||||
|
||||
- name: "no-println-debug"
|
||||
pattern: "println!"
|
||||
suggestion: "Use tracing macros (info!, debug!, error!) for logging"
|
||||
file_extensions: ["rs"]
|
||||
severity: "info"
|
||||
enabled: true
|
||||
|
||||
- name: "no-dbg"
|
||||
pattern: "dbg!"
|
||||
suggestion: "Remove debug macro before committing"
|
||||
file_extensions: ["rs"]
|
||||
severity: "warning"
|
||||
enabled: true
|
||||
|
||||
- name: "no-todo"
|
||||
pattern: "TODO"
|
||||
suggestion: "Address TODO comments or create GitHub issues"
|
||||
file_extensions: ["rs", "ts", "js", "md"]
|
||||
severity: "info"
|
||||
enabled: true
|
||||
|
||||
- name: "no-fixme"
|
||||
pattern: "FIXME"
|
||||
suggestion: "Address FIXME comments before merging"
|
||||
file_extensions: ["rs", "ts", "js"]
|
||||
severity: "warning"
|
||||
enabled: true
|
||||
|
||||
# Agent-specific preferences
|
||||
agent_preferences:
|
||||
codegen:
|
||||
style: "functional"
|
||||
error_handling: "result"
|
||||
min_score: 80
|
||||
clippy_strict: true
|
||||
|
||||
review:
|
||||
focus_areas:
|
||||
- "error_handling"
|
||||
- "performance"
|
||||
- "security"
|
||||
min_score: 70
|
||||
|
||||
refactor:
|
||||
preserve_tests: true
|
||||
max_changes_per_file: 100
|
||||
|
||||
# Global settings
|
||||
settings:
|
||||
auto_format: true
|
||||
max_file_size: 1048576
|
||||
ignore_patterns:
|
||||
- "target/"
|
||||
- "node_modules/"
|
||||
- ".git/"
|
||||
102
Cargo.lock
generated
102
Cargo.lock
generated
|
|
@ -198,6 +198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
|
|
@ -745,6 +747,21 @@ dependencies = [
|
|||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
|
|
@ -1139,6 +1156,16 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.82"
|
||||
|
|
@ -1161,6 +1188,20 @@ version = "0.2.177"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.17.0+1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.10"
|
||||
|
|
@ -1171,6 +1212,32 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
|
|
@ -1293,16 +1360,19 @@ dependencies = [
|
|||
"chrono",
|
||||
"dirs",
|
||||
"futures",
|
||||
"git2",
|
||||
"glob",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -2007,6 +2077,19 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -2522,6 +2605,16 @@ dependencies = [
|
|||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-serde"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
|
|
@ -2532,12 +2625,15 @@ dependencies = [
|
|||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2593,6 +2689,12 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
|
|||
BIN
MIYABI.md
BIN
MIYABI.md
Binary file not shown.
215
README.md
215
README.md
|
|
@ -166,23 +166,218 @@ Agent mode features:
|
|||
- Iteration limits for safety
|
||||
- JSON output format option
|
||||
|
||||
## Core Library Features
|
||||
|
||||
The `miyabi-core` crate provides powerful utilities for building AI applications.
|
||||
|
||||
### Git Utilities
|
||||
|
||||
```rust
|
||||
use miyabi_core::{find_git_root, get_current_branch, is_in_git_repo};
|
||||
|
||||
// Find repository root
|
||||
let root = find_git_root()?;
|
||||
|
||||
// Get current branch
|
||||
let branch = get_current_branch(&root)?;
|
||||
|
||||
// Check if in git repo
|
||||
if is_in_git_repo(&path) {
|
||||
println!("In a git repository");
|
||||
}
|
||||
```
|
||||
|
||||
### Logger System
|
||||
|
||||
```rust
|
||||
use miyabi_core::{init_logger, LogLevel, LogFormat};
|
||||
|
||||
// Initialize with defaults
|
||||
init_logger();
|
||||
|
||||
// Or with custom config
|
||||
use miyabi_core::LoggerConfig;
|
||||
let config = LoggerConfig {
|
||||
level: LogLevel::Debug,
|
||||
format: LogFormat::Json,
|
||||
..Default::default()
|
||||
};
|
||||
init_logger_with_config(config);
|
||||
```
|
||||
|
||||
### Retry with Backoff
|
||||
|
||||
```rust
|
||||
use miyabi_core::{retry_with_backoff, BackoffRetryConfig};
|
||||
|
||||
let config = BackoffRetryConfig::default();
|
||||
let result = retry_with_backoff(config, || async {
|
||||
// Your operation that might fail
|
||||
make_api_call().await
|
||||
}).await?;
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
```rust
|
||||
use miyabi_core::{CircuitBreaker, CircuitState};
|
||||
|
||||
let breaker = CircuitBreaker::default();
|
||||
|
||||
let result = breaker.call(|| {
|
||||
Box::pin(async {
|
||||
// Your operation
|
||||
Ok::<_, std::io::Error>(())
|
||||
})
|
||||
}).await;
|
||||
|
||||
// Check state
|
||||
match breaker.state().await {
|
||||
CircuitState::Closed => println!("Normal operation"),
|
||||
CircuitState::Open => println!("Circuit open - blocking calls"),
|
||||
CircuitState::HalfOpen => println!("Testing recovery"),
|
||||
}
|
||||
```
|
||||
|
||||
### Rules System (.miyabirules)
|
||||
|
||||
Support for project-specific rules via `.miyabirules` files:
|
||||
|
||||
```yaml
|
||||
# .miyabirules
|
||||
version: 1
|
||||
rules:
|
||||
- name: "no-unwrap"
|
||||
pattern: ".unwrap()"
|
||||
suggestion: "Use ? operator or proper error handling"
|
||||
file_extensions: ["rs"]
|
||||
severity: "warning"
|
||||
|
||||
agent_preferences:
|
||||
codegen:
|
||||
style: "functional"
|
||||
error_handling: "result"
|
||||
```
|
||||
|
||||
```rust
|
||||
use miyabi_core::{RulesLoader, MiyabiRules};
|
||||
|
||||
let loader = RulesLoader::new(project_root);
|
||||
let rules = loader.load_or_default()?;
|
||||
|
||||
// Get rules for a file
|
||||
let applicable = rules.rules_for_file(Path::new("src/main.rs"));
|
||||
```
|
||||
|
||||
### Cache System
|
||||
|
||||
```rust
|
||||
use miyabi_core::{create_llm_cache, create_api_cache, LLMCacheKey};
|
||||
|
||||
// LLM response cache (1 hour TTL)
|
||||
let cache = create_llm_cache();
|
||||
let key = LLMCacheKey::new("prompt", "model", Some(0.7));
|
||||
|
||||
cache.insert(key.clone(), "response".to_string()).await;
|
||||
let cached = cache.get(&key).await;
|
||||
|
||||
// API cache (30 min TTL)
|
||||
let api_cache = create_api_cache();
|
||||
```
|
||||
|
||||
### Plugin System
|
||||
|
||||
```rust
|
||||
use miyabi_core::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult};
|
||||
use anyhow::Result;
|
||||
|
||||
struct MyPlugin;
|
||||
|
||||
impl Plugin for MyPlugin {
|
||||
fn metadata(&self) -> PluginMetadata {
|
||||
PluginMetadata {
|
||||
name: "my-plugin".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Some("My custom plugin".to_string()),
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn init(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
|
||||
Ok(PluginResult {
|
||||
success: true,
|
||||
message: Some("Done".to_string()),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register and execute
|
||||
let manager = PluginManager::new();
|
||||
manager.register(Box::new(MyPlugin))?;
|
||||
let result = manager.execute("my-plugin", &PluginContext::default())?;
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
|
||||
```rust
|
||||
use miyabi_core::{FeatureFlagManager, FeatureFlag};
|
||||
|
||||
let flags = FeatureFlagManager::new();
|
||||
|
||||
// Simple flag
|
||||
flags.set_flag("new_feature", true);
|
||||
|
||||
if flags.is_enabled("new_feature") {
|
||||
// Use new feature
|
||||
}
|
||||
|
||||
// With rollout percentage
|
||||
flags.set_flag_with_options(
|
||||
"beta_feature",
|
||||
true,
|
||||
Some("Beta testing".to_string()),
|
||||
Some(0.5), // 50% rollout
|
||||
);
|
||||
|
||||
// Load from config
|
||||
use std::collections::HashMap;
|
||||
let mut config = HashMap::new();
|
||||
config.insert("flag1".to_string(), true);
|
||||
flags.load_from_map(config);
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
miyabi-cli-standalone/
|
||||
├── crates/
|
||||
│ ├── miyabi-cli/ # CLI entry point
|
||||
│ ├── miyabi-core/ # Core library
|
||||
│ │ ├── agent/ # Agent system
|
||||
│ │ ├── config.rs # Configuration
|
||||
│ │ ├── session.rs # Session management
|
||||
│ ├── miyabi-cli/ # CLI entry point
|
||||
│ ├── miyabi-core/ # Core library
|
||||
│ │ ├── agent.rs # Agent system
|
||||
│ │ ├── anthropic.rs # Anthropic API client
|
||||
│ │ ├── cache.rs # TTL cache system
|
||||
│ │ ├── config.rs # Configuration
|
||||
│ │ ├── error_policy.rs # Circuit breaker
|
||||
│ │ ├── feature_flags.rs # Feature flag management
|
||||
│ │ ├── git.rs # Git utilities
|
||||
│ │ ├── logger.rs # Logging system
|
||||
│ │ ├── plugin.rs # Plugin system
|
||||
│ │ ├── retry.rs # Retry with backoff
|
||||
│ │ ├── rules.rs # .miyabirules support
|
||||
│ │ ├── session.rs # Session management
|
||||
│ │ ├── tools.rs # Built-in tools
|
||||
│ │ └── ...
|
||||
│ └── miyabi-tui/ # TUI implementation
|
||||
│ ├── app.rs # Main application
|
||||
│ ├── views.rs # UI views
|
||||
│ └── miyabi-tui/ # TUI implementation
|
||||
│ ├── app.rs # Main application
|
||||
│ ├── views.rs # UI views
|
||||
│ └── ...
|
||||
├── .miyabi/ # Local config
|
||||
├── Cargo.toml # Workspace config
|
||||
├── .miyabi/ # Local config
|
||||
├── Cargo.toml # Workspace config
|
||||
└── README.md
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
//! Miyabi CLI - Main entry point
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use miyabi_core::{FeatureFlagManager, RulesLoader};
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Global feature flags manager
|
||||
static FEATURE_FLAGS: std::sync::OnceLock<FeatureFlagManager> = std::sync::OnceLock::new();
|
||||
|
||||
/// Get the global feature flags manager
|
||||
pub fn feature_flags() -> &'static FeatureFlagManager {
|
||||
FEATURE_FLAGS.get_or_init(|| {
|
||||
let manager = FeatureFlagManager::new();
|
||||
// Default feature flags
|
||||
manager.set_flag("extended_thinking", true);
|
||||
manager.set_flag("auto_save_sessions", true);
|
||||
manager.set_flag("syntax_highlighting", true);
|
||||
manager.set_flag("vim_mode", false);
|
||||
manager
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "miyabi")]
|
||||
#[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)]
|
||||
|
|
@ -54,6 +71,12 @@ enum Commands {
|
|||
},
|
||||
/// Show version and system information
|
||||
Version,
|
||||
/// Show project rules (.miyabirules)
|
||||
Rules {
|
||||
/// Show detailed rule information
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
},
|
||||
/// Run agent with a prompt (autonomous execution)
|
||||
Agent {
|
||||
/// The prompt to execute
|
||||
|
|
@ -145,11 +168,40 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
Some(Commands::Status) => {
|
||||
use miyabi_core::config::Config;
|
||||
|
||||
let config = Config::load().unwrap_or_default();
|
||||
|
||||
println!("Miyabi Status: Ready");
|
||||
println!(
|
||||
"Config path: {:?}",
|
||||
miyabi_core::config::Config::default_path()
|
||||
);
|
||||
println!();
|
||||
println!("Config: {}", Config::default_path().display());
|
||||
println!("Sessions: {}", config.sessions_dir().display());
|
||||
println!("Model: {}", config.api.model);
|
||||
println!();
|
||||
|
||||
// Load and show rules info
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let loader = RulesLoader::new(cwd);
|
||||
match loader.load() {
|
||||
Ok(Some(rules)) => {
|
||||
println!("Rules: {} rules loaded", rules.rules.len());
|
||||
if !rules.agent_preferences.is_empty() {
|
||||
println!("Agents: {} agent preferences", rules.agent_preferences.len());
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("Rules: No .miyabirules found");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Rules: Error loading - {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Show feature flags
|
||||
let flags = feature_flags();
|
||||
let all_flags = flags.get_all_flags();
|
||||
let enabled_count = all_flags.iter().filter(|f| f.enabled).count();
|
||||
println!("Flags: {}/{} enabled", enabled_count, all_flags.len());
|
||||
}
|
||||
Some(Commands::Init) => {
|
||||
use miyabi_core::config::Config;
|
||||
|
|
@ -284,6 +336,78 @@ async fn main() -> anyhow::Result<()> {
|
|||
std::env::consts::ARCH
|
||||
);
|
||||
}
|
||||
Some(Commands::Rules { verbose }) => {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let loader = RulesLoader::new(cwd.clone());
|
||||
|
||||
match loader.load() {
|
||||
Ok(Some(rules)) => {
|
||||
println!("Project Rules (.miyabirules)");
|
||||
println!("============================");
|
||||
println!();
|
||||
|
||||
if rules.rules.is_empty() {
|
||||
println!("No rules defined.");
|
||||
} else {
|
||||
println!("Rules ({}):", rules.rules.len());
|
||||
for rule in &rules.rules {
|
||||
let status = if rule.enabled { "✓" } else { "✗" };
|
||||
let severity = match rule.severity.as_str() {
|
||||
"error" => "🔴",
|
||||
"warning" => "🟡",
|
||||
_ => "🔵",
|
||||
};
|
||||
println!(" {} {} {} - {}", status, severity, rule.name, rule.suggestion);
|
||||
|
||||
if verbose {
|
||||
if let Some(pattern) = &rule.pattern {
|
||||
println!(" Pattern: {}", pattern);
|
||||
}
|
||||
if !rule.file_extensions.is_empty() {
|
||||
println!(" Extensions: {}", rule.file_extensions.join(", "));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !rules.agent_preferences.is_empty() {
|
||||
println!();
|
||||
println!("Agent Preferences ({}):", rules.agent_preferences.len());
|
||||
for (agent, prefs) in &rules.agent_preferences {
|
||||
println!(" {}:", agent);
|
||||
if let Some(style) = &prefs.style {
|
||||
println!(" Style: {}", style);
|
||||
}
|
||||
if let Some(handling) = &prefs.error_handling {
|
||||
println!(" Error Handling: {}", handling);
|
||||
}
|
||||
if let Some(score) = prefs.min_score {
|
||||
println!(" Min Score: {}", score);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verbose && !rules.settings.is_empty() {
|
||||
println!();
|
||||
println!("Settings:");
|
||||
for (key, value) in &rules.settings {
|
||||
println!(" {}: {}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!("No .miyabirules file found in {} or parent directories.", cwd.display());
|
||||
println!();
|
||||
println!("Create a .miyabirules file to define project-specific rules.");
|
||||
println!("See: miyabi --help for more information.");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error loading rules: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Commands::Agent {
|
||||
prompt,
|
||||
max_iterations,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ serde_json = { workspace = true }
|
|||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
|
@ -23,6 +24,8 @@ futures = { workspace = true }
|
|||
async-trait = { workspace = true }
|
||||
glob = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
git2 = "0.19"
|
||||
serde_yaml = "0.9"
|
||||
dirs = "5"
|
||||
toml = "0.8"
|
||||
|
||||
|
|
|
|||
240
crates/miyabi-core/src/cache.rs
Normal file
240
crates/miyabi-core/src/cache.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! Caching utilities for Miyabi
|
||||
//!
|
||||
//! Provides in-memory caching with TTL support for LLM responses and other expensive operations
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Cache entry with TTL support
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheEntry<T> {
|
||||
/// Cached value
|
||||
pub value: T,
|
||||
/// Expiration timestamp
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
/// Create a new cache entry with the given TTL
|
||||
pub fn new(value: T, ttl: Duration) -> Self {
|
||||
Self {
|
||||
value,
|
||||
expires_at: Instant::now() + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cache entry has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Instant::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe TTL cache
|
||||
#[derive(Debug)]
|
||||
pub struct TTLCache<K, V> {
|
||||
inner: Arc<RwLock<HashMap<K, CacheEntry<V>>>>,
|
||||
default_ttl: Duration,
|
||||
}
|
||||
|
||||
impl<K, V> TTLCache<K, V>
|
||||
where
|
||||
K: Hash + Eq + Clone + Send + Sync + 'static,
|
||||
V: Clone + Send + Sync + 'static,
|
||||
{
|
||||
/// Create a new TTL cache with default TTL
|
||||
pub fn new(default_ttl: Duration) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(HashMap::new())),
|
||||
default_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a value from the cache
|
||||
pub async fn get(&self, key: &K) -> Option<V> {
|
||||
let mut cache = self.inner.write().await;
|
||||
|
||||
if let Some(entry) = cache.get(key) {
|
||||
if entry.is_expired() {
|
||||
cache.remove(key);
|
||||
None
|
||||
} else {
|
||||
Some(entry.value.clone())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a value into the cache with default TTL
|
||||
pub async fn insert(&self, key: K, value: V) {
|
||||
self.insert_with_ttl(key, value, self.default_ttl).await;
|
||||
}
|
||||
|
||||
/// Insert a value into the cache with custom TTL
|
||||
pub async fn insert_with_ttl(&self, key: K, value: V, ttl: Duration) {
|
||||
let mut cache = self.inner.write().await;
|
||||
cache.insert(key, CacheEntry::new(value, ttl));
|
||||
}
|
||||
|
||||
/// Remove a value from the cache
|
||||
pub async fn remove(&self, key: &K) -> Option<V> {
|
||||
let mut cache = self.inner.write().await;
|
||||
cache.remove(key).map(|entry| entry.value)
|
||||
}
|
||||
|
||||
/// Clear all expired entries
|
||||
pub async fn cleanup_expired(&self) -> usize {
|
||||
let mut cache = self.inner.write().await;
|
||||
let initial_size = cache.len();
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
initial_size - cache.len()
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub async fn stats(&self) -> CacheStats {
|
||||
let cache = self.inner.read().await;
|
||||
let total_entries = cache.len();
|
||||
let expired_entries = cache.values().filter(|entry| entry.is_expired()).count();
|
||||
|
||||
CacheStats {
|
||||
total_entries,
|
||||
expired_entries,
|
||||
active_entries: total_entries - expired_entries,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all entries
|
||||
pub async fn clear(&self) {
|
||||
let mut cache = self.inner.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheStats {
|
||||
/// Total entries
|
||||
pub total_entries: usize,
|
||||
/// Expired entries
|
||||
pub expired_entries: usize,
|
||||
/// Active entries
|
||||
pub active_entries: usize,
|
||||
}
|
||||
|
||||
/// LLM response cache key
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct LLMCacheKey {
|
||||
/// Prompt hash
|
||||
pub prompt_hash: String,
|
||||
/// Model name
|
||||
pub model: String,
|
||||
/// Temperature parameter (as bits to avoid f32 Hash/Eq issues)
|
||||
pub temperature: Option<u32>,
|
||||
}
|
||||
|
||||
impl LLMCacheKey {
|
||||
pub fn new(prompt: &str, model: &str, temperature: Option<f32>) -> Self {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
prompt.hash(&mut hasher);
|
||||
model.hash(&mut hasher);
|
||||
if let Some(temp) = temperature {
|
||||
temp.to_bits().hash(&mut hasher);
|
||||
}
|
||||
|
||||
Self {
|
||||
prompt_hash: format!("{:x}", hasher.finish()),
|
||||
model: model.to_string(),
|
||||
temperature: temperature.map(|t| t.to_bits()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM response cache
|
||||
pub type LLMCache = TTLCache<LLMCacheKey, String>;
|
||||
|
||||
/// Create a new LLM cache with 1 hour TTL
|
||||
pub fn create_llm_cache() -> LLMCache {
|
||||
TTLCache::new(Duration::from_secs(3600))
|
||||
}
|
||||
|
||||
/// API response cache key
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct ApiCacheKey {
|
||||
/// Endpoint
|
||||
pub endpoint: String,
|
||||
/// Request hash
|
||||
pub request_hash: String,
|
||||
}
|
||||
|
||||
impl ApiCacheKey {
|
||||
pub fn new(endpoint: &str, request_body: &str) -> Self {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::Hasher;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
request_body.hash(&mut hasher);
|
||||
|
||||
Self {
|
||||
endpoint: endpoint.to_string(),
|
||||
request_hash: format!("{:x}", hasher.finish()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API response cache
|
||||
pub type ApiCache = TTLCache<ApiCacheKey, String>;
|
||||
|
||||
/// Create a new API cache with 30 minutes TTL
|
||||
pub fn create_api_cache() -> ApiCache {
|
||||
TTLCache::new(Duration::from_secs(1800))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ttl_cache_basic_operations() {
|
||||
let cache = TTLCache::new(Duration::from_millis(100));
|
||||
|
||||
cache.insert("key1", "value1").await;
|
||||
assert_eq!(cache.get(&"key1").await, Some("value1"));
|
||||
|
||||
sleep(Duration::from_millis(150)).await;
|
||||
assert_eq!(cache.get(&"key1").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ttl_cache_cleanup() {
|
||||
let cache = TTLCache::new(Duration::from_millis(50));
|
||||
|
||||
cache.insert("key1", "value1").await;
|
||||
cache.insert("key2", "value2").await;
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let cleaned = cache.cleanup_expired().await;
|
||||
assert_eq!(cleaned, 2);
|
||||
|
||||
let stats = cache.stats().await;
|
||||
assert_eq!(stats.total_entries, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_llm_cache_key() {
|
||||
let key1 = LLMCacheKey::new("prompt1", "model1", Some(0.7));
|
||||
let key2 = LLMCacheKey::new("prompt1", "model1", Some(0.7));
|
||||
let key3 = LLMCacheKey::new("prompt2", "model1", Some(0.7));
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
assert_ne!(key1, key3);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,85 @@ pub enum Error {
|
|||
#[error("Agent error: {0}")]
|
||||
Agent(String),
|
||||
|
||||
#[error("Git error: {0}")]
|
||||
Git(String),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("Timeout after {0}ms")]
|
||||
Timeout(u64),
|
||||
|
||||
#[error("GitHub error: {0}")]
|
||||
GitHub(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Tool error: {0}")]
|
||||
Tool(String),
|
||||
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Check if an error is retryable (transient)
|
||||
impl Error {
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
// Always retryable
|
||||
Error::Timeout(_) => true,
|
||||
|
||||
// HTTP errors - check message for transient patterns
|
||||
Error::Http(msg) => {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("connection")
|
||||
|| lower.contains("temporarily")
|
||||
}
|
||||
|
||||
// GitHub errors - check for rate limiting
|
||||
Error::GitHub(msg) => {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("rate limit")
|
||||
|| lower.contains("retry")
|
||||
|| lower.contains("temporarily unavailable")
|
||||
}
|
||||
|
||||
// IO errors - some kinds are retryable
|
||||
Error::Io(io_error) => matches!(
|
||||
io_error.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::TimedOut
|
||||
| std::io::ErrorKind::Interrupted
|
||||
| std::io::ErrorKind::WouldBlock
|
||||
),
|
||||
|
||||
// Git errors - check message for lock conflicts
|
||||
Error::Git(msg) => {
|
||||
let lower = msg.to_lowercase();
|
||||
lower.contains("lock") || lower.contains("unable to create")
|
||||
}
|
||||
|
||||
// Never retryable
|
||||
Error::Agent(_) => false,
|
||||
Error::Auth(_) => false,
|
||||
Error::Config(_) => false,
|
||||
Error::Validation(_) => false,
|
||||
Error::Json(_) => false,
|
||||
Error::Tool(_) => false,
|
||||
Error::PermissionDenied(_) => false,
|
||||
Error::Other(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
291
crates/miyabi-core/src/error_policy.rs
Normal file
291
crates/miyabi-core/src/error_policy.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
//! Error handling policies for Miyabi system
|
||||
//!
|
||||
//! This module provides advanced error handling strategies:
|
||||
//! - Circuit Breaker pattern for preventing cascading failures
|
||||
//! - Fallback strategies for graceful degradation
|
||||
|
||||
use crate::error::Error;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Fallback strategy when execution fails
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FallbackStrategy {
|
||||
/// Accept partial success
|
||||
AcceptPartialSuccess {
|
||||
/// Minimum number of successful operations required
|
||||
min_successful: usize,
|
||||
},
|
||||
/// Retry with lower temperature
|
||||
RetryWithLowerTemperature {
|
||||
/// Amount to reduce temperature by
|
||||
temperature_reduction: f64,
|
||||
},
|
||||
/// Switch to a different LLM model
|
||||
SwitchModel {
|
||||
/// Fallback model name
|
||||
fallback_model: String,
|
||||
},
|
||||
/// Wait for human intervention
|
||||
WaitForHumanIntervention {
|
||||
/// Timeout before giving up
|
||||
timeout: Duration,
|
||||
},
|
||||
/// Skip the task entirely
|
||||
SkipTask,
|
||||
}
|
||||
|
||||
impl Default for FallbackStrategy {
|
||||
fn default() -> Self {
|
||||
Self::AcceptPartialSuccess { min_successful: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl FallbackStrategy {
|
||||
/// Creates a partial success strategy with default threshold
|
||||
pub fn partial_success() -> Self {
|
||||
Self::AcceptPartialSuccess { min_successful: 1 }
|
||||
}
|
||||
|
||||
/// Creates a temperature reduction strategy
|
||||
pub fn lower_temperature() -> Self {
|
||||
Self::RetryWithLowerTemperature {
|
||||
temperature_reduction: 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a model switch strategy
|
||||
pub fn switch_to_claude() -> Self {
|
||||
Self::SwitchModel {
|
||||
fallback_model: "claude-sonnet-4-5-20250929".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a human intervention strategy
|
||||
pub fn wait_for_human() -> Self {
|
||||
Self::WaitForHumanIntervention {
|
||||
timeout: Duration::from_secs(24 * 60 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the circuit breaker
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CircuitState {
|
||||
/// Circuit is closed - requests flow normally
|
||||
Closed,
|
||||
/// Circuit is open - requests are blocked
|
||||
Open,
|
||||
/// Circuit is half-open - testing if service recovered
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
/// Circuit breaker for preventing cascading failures
|
||||
///
|
||||
/// The circuit breaker pattern prevents repeated attempts to execute
|
||||
/// operations that are likely to fail, allowing the system to recover.
|
||||
pub struct CircuitBreaker {
|
||||
/// Number of consecutive failures before opening circuit
|
||||
failure_threshold: usize,
|
||||
/// Number of consecutive successes before closing circuit
|
||||
success_threshold: usize,
|
||||
/// Duration to wait before transitioning from Open to HalfOpen
|
||||
timeout: Duration,
|
||||
/// Current circuit state
|
||||
state: Arc<Mutex<CircuitState>>,
|
||||
/// Count of consecutive failures
|
||||
consecutive_failures: Arc<Mutex<usize>>,
|
||||
/// Count of consecutive successes
|
||||
consecutive_successes: Arc<Mutex<usize>>,
|
||||
/// Time when circuit was opened
|
||||
opened_at: Arc<Mutex<Option<Instant>>>,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// Creates a new CircuitBreaker
|
||||
pub fn new(failure_threshold: usize, success_threshold: usize, timeout: Duration) -> Self {
|
||||
Self {
|
||||
failure_threshold,
|
||||
success_threshold,
|
||||
timeout,
|
||||
state: Arc::new(Mutex::new(CircuitState::Closed)),
|
||||
consecutive_failures: Arc::new(Mutex::new(0)),
|
||||
consecutive_successes: Arc::new(Mutex::new(0)),
|
||||
opened_at: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a CircuitBreaker with default settings
|
||||
pub fn default_config() -> Self {
|
||||
Self::new(5, 2, Duration::from_secs(60))
|
||||
}
|
||||
|
||||
/// Executes the given operation through the circuit breaker
|
||||
pub async fn call<F, T, E>(&self, operation: F) -> Result<T, Error>
|
||||
where
|
||||
F: FnOnce() -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<T, E>> + Send>>,
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
// Check if we should attempt reset
|
||||
if self.should_attempt_reset().await {
|
||||
*self.state.lock().await = CircuitState::HalfOpen;
|
||||
}
|
||||
|
||||
let current_state = *self.state.lock().await;
|
||||
|
||||
match current_state {
|
||||
CircuitState::Open => Err(Error::Other("Circuit breaker is open".to_string())),
|
||||
CircuitState::Closed | CircuitState::HalfOpen => match operation().await {
|
||||
Ok(result) => {
|
||||
self.on_success().await;
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
self.on_failure().await;
|
||||
Err(Error::Other(e.to_string()))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a successful operation
|
||||
async fn on_success(&self) {
|
||||
let mut successes = self.consecutive_successes.lock().await;
|
||||
*successes += 1;
|
||||
*self.consecutive_failures.lock().await = 0;
|
||||
|
||||
if *successes >= self.success_threshold {
|
||||
let mut state = self.state.lock().await;
|
||||
if *state != CircuitState::Closed {
|
||||
*state = CircuitState::Closed;
|
||||
*self.opened_at.lock().await = None;
|
||||
}
|
||||
*successes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a failed operation
|
||||
async fn on_failure(&self) {
|
||||
let mut failures = self.consecutive_failures.lock().await;
|
||||
*failures += 1;
|
||||
*self.consecutive_successes.lock().await = 0;
|
||||
|
||||
if *failures >= self.failure_threshold {
|
||||
let mut state = self.state.lock().await;
|
||||
if *state == CircuitState::Closed {
|
||||
*state = CircuitState::Open;
|
||||
*self.opened_at.lock().await = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if circuit should attempt reset
|
||||
async fn should_attempt_reset(&self) -> bool {
|
||||
let state = *self.state.lock().await;
|
||||
if state != CircuitState::Open {
|
||||
return false;
|
||||
}
|
||||
|
||||
let opened_at = self.opened_at.lock().await;
|
||||
if let Some(opened_time) = *opened_at {
|
||||
opened_time.elapsed() >= self.timeout
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the current circuit state
|
||||
pub async fn state(&self) -> CircuitState {
|
||||
*self.state.lock().await
|
||||
}
|
||||
|
||||
/// Gets the number of consecutive failures
|
||||
pub async fn consecutive_failures(&self) -> usize {
|
||||
*self.consecutive_failures.lock().await
|
||||
}
|
||||
|
||||
/// Gets the number of consecutive successes
|
||||
pub async fn consecutive_successes(&self) -> usize {
|
||||
*self.consecutive_successes.lock().await
|
||||
}
|
||||
|
||||
/// Resets the circuit breaker to closed state
|
||||
pub async fn reset(&self) {
|
||||
*self.state.lock().await = CircuitState::Closed;
|
||||
*self.consecutive_failures.lock().await = 0;
|
||||
*self.consecutive_successes.lock().await = 0;
|
||||
*self.opened_at.lock().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitBreaker {
|
||||
fn default() -> Self {
|
||||
Self::default_config()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_opens_after_failures() {
|
||||
let breaker = CircuitBreaker::new(3, 2, Duration::from_millis(100));
|
||||
assert_eq!(breaker.state().await, CircuitState::Closed);
|
||||
|
||||
for _ in 0..3 {
|
||||
let result = breaker
|
||||
.call(|| {
|
||||
Box::pin(async {
|
||||
Result::<(), std::io::Error>::Err(std::io::Error::other("test error"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
assert_eq!(breaker.state().await, CircuitState::Open);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_blocks_when_open() {
|
||||
let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60));
|
||||
|
||||
for _ in 0..2 {
|
||||
let _ = breaker
|
||||
.call(|| {
|
||||
Box::pin(async {
|
||||
Result::<(), std::io::Error>::Err(std::io::Error::other("error"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_eq!(breaker.state().await, CircuitState::Open);
|
||||
|
||||
let result = breaker
|
||||
.call(|| Box::pin(async { Ok::<(), std::io::Error>(()) }))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circuit_breaker_reset() {
|
||||
let breaker = CircuitBreaker::new(2, 2, Duration::from_secs(60));
|
||||
|
||||
for _ in 0..2 {
|
||||
let _ = breaker
|
||||
.call(|| {
|
||||
Box::pin(async {
|
||||
Result::<(), std::io::Error>::Err(std::io::Error::other("error"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_eq!(breaker.state().await, CircuitState::Open);
|
||||
breaker.reset().await;
|
||||
assert_eq!(breaker.state().await, CircuitState::Closed);
|
||||
}
|
||||
}
|
||||
330
crates/miyabi-core/src/feature_flags.rs
Normal file
330
crates/miyabi-core/src/feature_flags.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
//! Feature Flag Management System
|
||||
//!
|
||||
//! Provides a flexible feature flag system for gradual rollout and A/B testing.
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ```rust
|
||||
//! use miyabi_core::feature_flags::FeatureFlagManager;
|
||||
//!
|
||||
//! let manager = FeatureFlagManager::new();
|
||||
//! manager.set_flag("new_architecture", true);
|
||||
//!
|
||||
//! if manager.is_enabled("new_architecture") {
|
||||
//! // Use new architecture
|
||||
//! } else {
|
||||
//! // Use old architecture
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Feature flag configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeatureFlag {
|
||||
/// Flag name
|
||||
pub name: String,
|
||||
/// Enabled status
|
||||
pub enabled: bool,
|
||||
/// Optional description
|
||||
pub description: Option<String>,
|
||||
/// Optional rollout percentage (0.0 to 1.0)
|
||||
pub rollout_percentage: Option<f64>,
|
||||
}
|
||||
|
||||
/// Feature flag manager for controlling feature rollout
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeatureFlagManager {
|
||||
/// Internal flag storage (thread-safe)
|
||||
flags: Arc<RwLock<HashMap<String, FeatureFlag>>>,
|
||||
}
|
||||
|
||||
impl Default for FeatureFlagManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureFlagManager {
|
||||
/// Create a new feature flag manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
flags: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a feature flag is enabled
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `flag_name` - Name of the feature flag
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if the flag exists and is enabled, `false` otherwise
|
||||
pub fn is_enabled(&self, flag_name: &str) -> bool {
|
||||
let flags = self.flags.read().unwrap();
|
||||
flags.get(flag_name).map(|flag| flag.enabled).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set a feature flag
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `flag_name` - Name of the feature flag
|
||||
/// * `enabled` - Whether the flag should be enabled
|
||||
pub fn set_flag(&self, flag_name: impl Into<String>, enabled: bool) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
let name = flag_name.into();
|
||||
flags.insert(
|
||||
name.clone(),
|
||||
FeatureFlag {
|
||||
name,
|
||||
enabled,
|
||||
description: None,
|
||||
rollout_percentage: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Set a feature flag with description and rollout percentage
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `flag_name` - Name of the feature flag
|
||||
/// * `enabled` - Whether the flag should be enabled
|
||||
/// * `description` - Optional description of the flag
|
||||
/// * `rollout_percentage` - Optional rollout percentage (0.0 to 1.0)
|
||||
pub fn set_flag_with_options(
|
||||
&self,
|
||||
flag_name: impl Into<String>,
|
||||
enabled: bool,
|
||||
description: Option<String>,
|
||||
rollout_percentage: Option<f64>,
|
||||
) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
let name = flag_name.into();
|
||||
flags.insert(
|
||||
name.clone(),
|
||||
FeatureFlag {
|
||||
name,
|
||||
enabled,
|
||||
description,
|
||||
rollout_percentage,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a feature flag
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `flag_name` - Name of the feature flag to remove
|
||||
pub fn remove_flag(&self, flag_name: &str) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
flags.remove(flag_name);
|
||||
}
|
||||
|
||||
/// Get all feature flags
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of all feature flags
|
||||
pub fn get_all_flags(&self) -> Vec<FeatureFlag> {
|
||||
let flags = self.flags.read().unwrap();
|
||||
flags.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get a specific feature flag
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `flag_name` - Name of the feature flag
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The feature flag if it exists, None otherwise
|
||||
pub fn get_flag(&self, flag_name: &str) -> Option<FeatureFlag> {
|
||||
let flags = self.flags.read().unwrap();
|
||||
flags.get(flag_name).cloned()
|
||||
}
|
||||
|
||||
/// Load feature flags from a HashMap
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - HashMap of flag names to enabled status
|
||||
pub fn load_from_map(&self, config: HashMap<String, bool>) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
for (name, enabled) in config {
|
||||
flags.insert(
|
||||
name.clone(),
|
||||
FeatureFlag {
|
||||
name,
|
||||
enabled,
|
||||
description: None,
|
||||
rollout_percentage: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load feature flags from detailed configuration
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Vector of FeatureFlag configurations
|
||||
pub fn load_from_config(&self, config: Vec<FeatureFlag>) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
for flag in config {
|
||||
flags.insert(flag.name.clone(), flag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all flags to a HashMap
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// HashMap of flag names to enabled status
|
||||
pub fn export_to_map(&self) -> HashMap<String, bool> {
|
||||
let flags = self.flags.read().unwrap();
|
||||
flags.iter().map(|(name, flag)| (name.clone(), flag.enabled)).collect()
|
||||
}
|
||||
|
||||
/// Clear all feature flags
|
||||
pub fn clear(&self) {
|
||||
let mut flags = self.flags.write().unwrap();
|
||||
flags.clear();
|
||||
}
|
||||
|
||||
/// Get the number of feature flags
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The number of flags currently registered
|
||||
pub fn count(&self) -> usize {
|
||||
let flags = self.flags.read().unwrap();
|
||||
flags.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_flag() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
// Initially disabled
|
||||
assert!(!manager.is_enabled("new_feature"));
|
||||
|
||||
// Enable flag
|
||||
manager.set_flag("new_feature", true);
|
||||
assert!(manager.is_enabled("new_feature"));
|
||||
|
||||
// Disable flag
|
||||
manager.set_flag("new_feature", false);
|
||||
assert!(!manager.is_enabled("new_feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flag_with_options() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
manager.set_flag_with_options(
|
||||
"beta_feature",
|
||||
true,
|
||||
Some("Beta testing feature".to_string()),
|
||||
Some(0.5),
|
||||
);
|
||||
|
||||
assert!(manager.is_enabled("beta_feature"));
|
||||
|
||||
let flag = manager.get_flag("beta_feature").unwrap();
|
||||
assert_eq!(flag.description, Some("Beta testing feature".to_string()));
|
||||
assert_eq!(flag.rollout_percentage, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_map() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
let mut config = HashMap::new();
|
||||
config.insert("flag1".to_string(), true);
|
||||
config.insert("flag2".to_string(), false);
|
||||
|
||||
manager.load_from_map(config);
|
||||
|
||||
assert!(manager.is_enabled("flag1"));
|
||||
assert!(!manager.is_enabled("flag2"));
|
||||
assert_eq!(manager.count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_flags() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
manager.set_flag("flag1", true);
|
||||
manager.set_flag("flag2", false);
|
||||
|
||||
let all_flags = manager.get_all_flags();
|
||||
assert_eq!(all_flags.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_flag() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
manager.set_flag("temp_flag", true);
|
||||
assert!(manager.is_enabled("temp_flag"));
|
||||
|
||||
manager.remove_flag("temp_flag");
|
||||
assert!(!manager.is_enabled("temp_flag"));
|
||||
assert_eq!(manager.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_to_map() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
manager.set_flag("flag1", true);
|
||||
manager.set_flag("flag2", false);
|
||||
|
||||
let exported = manager.export_to_map();
|
||||
assert_eq!(exported.get("flag1"), Some(&true));
|
||||
assert_eq!(exported.get("flag2"), Some(&false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let manager = FeatureFlagManager::new();
|
||||
|
||||
manager.set_flag("flag1", true);
|
||||
manager.set_flag("flag2", true);
|
||||
assert_eq!(manager.count(), 2);
|
||||
|
||||
manager.clear();
|
||||
assert_eq!(manager.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_safety() {
|
||||
use std::thread;
|
||||
|
||||
let manager = FeatureFlagManager::new();
|
||||
let manager_clone = manager.clone();
|
||||
|
||||
// Spawn thread to set flag
|
||||
let handle = thread::spawn(move || {
|
||||
manager_clone.set_flag("concurrent_flag", true);
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Check flag is set
|
||||
assert!(manager.is_enabled("concurrent_flag"));
|
||||
}
|
||||
}
|
||||
211
crates/miyabi-core/src/git.rs
Normal file
211
crates/miyabi-core/src/git.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Git utilities for repository discovery and validation
|
||||
//!
|
||||
//! Provides utilities for working with Git repositories:
|
||||
//! - Repository root discovery from any subdirectory
|
||||
//! - Repository validation
|
||||
//! - Branch detection
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Find the Git repository root from the current directory or any subdirectory
|
||||
///
|
||||
/// This function uses Git's discovery mechanism to walk up the directory tree
|
||||
/// until it finds a `.git` directory.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use miyabi_core::git::find_git_root;
|
||||
///
|
||||
/// let root = find_git_root(None)?;
|
||||
/// println!("Git root: {:?}", root);
|
||||
/// # Ok::<(), miyabi_core::error::Error>(())
|
||||
/// ```
|
||||
pub fn find_git_root(start_path: Option<&Path>) -> Result<PathBuf> {
|
||||
let search_path = match start_path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => std::env::current_dir()?,
|
||||
};
|
||||
|
||||
match git2::Repository::discover(&search_path) {
|
||||
Ok(repo) => repo
|
||||
.workdir()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| {
|
||||
Error::Git(
|
||||
"Repository is bare (no working directory). Miyabi requires a non-bare repository.".to_string()
|
||||
)
|
||||
}),
|
||||
Err(e) => Err(Error::Git(format!(
|
||||
"Not in a Git repository. Please run miyabi from within a Git repository.\n\
|
||||
Searched from: {:?}\n\
|
||||
Git error: {}\n\n\
|
||||
To initialize a new repository, run: git init",
|
||||
search_path, e
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a path is a valid Git repository
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to validate
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the path is a valid Git repository with a working directory
|
||||
pub fn is_valid_repository(path: impl AsRef<Path>) -> bool {
|
||||
match git2::Repository::open(path.as_ref()) {
|
||||
Ok(repo) => repo.workdir().is_some(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a path is within a Git repository
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to check
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the path is within a Git repository
|
||||
pub fn is_in_git_repo(path: impl AsRef<Path>) -> bool {
|
||||
git2::Repository::discover(path.as_ref()).is_ok()
|
||||
}
|
||||
|
||||
/// Get the current branch name of a repository
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `repo_path` - Path to the repository
|
||||
///
|
||||
/// # Returns
|
||||
/// The name of the currently checked out branch
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the repository is in a detached HEAD state or cannot be opened
|
||||
pub fn get_current_branch(repo_path: impl AsRef<Path>) -> Result<String> {
|
||||
let repo = git2::Repository::open(repo_path.as_ref())
|
||||
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
|
||||
|
||||
let head = repo
|
||||
.head()
|
||||
.map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?;
|
||||
|
||||
if !head.is_branch() {
|
||||
return Err(Error::Git(
|
||||
"Repository is in detached HEAD state. Please checkout a branch.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
head.shorthand()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| Error::Git("Failed to get branch name".to_string()))
|
||||
}
|
||||
|
||||
/// Get the main branch name (tries 'main' then 'master')
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `repo_path` - Path to the repository
|
||||
///
|
||||
/// # Returns
|
||||
/// The name of the main branch ('main' or 'master')
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if neither 'main' nor 'master' branch exists
|
||||
pub fn get_main_branch(repo_path: impl AsRef<Path>) -> Result<String> {
|
||||
let repo = git2::Repository::open(repo_path.as_ref())
|
||||
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
|
||||
|
||||
if repo.find_branch("main", git2::BranchType::Local).is_ok() {
|
||||
Ok("main".to_string())
|
||||
} else if repo.find_branch("master", git2::BranchType::Local).is_ok() {
|
||||
Ok("master".to_string())
|
||||
} else {
|
||||
Err(Error::Git(
|
||||
"Neither 'main' nor 'master' branch found. Please create a main branch.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the repository has uncommitted changes
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `repo_path` - Path to the repository
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if there are uncommitted changes in the working directory or index
|
||||
pub fn has_uncommitted_changes(repo_path: impl AsRef<Path>) -> Result<bool> {
|
||||
let repo = git2::Repository::open(repo_path.as_ref())
|
||||
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
|
||||
|
||||
let statuses = repo
|
||||
.statuses(None)
|
||||
.map_err(|e| Error::Git(format!("Failed to get repository status: {}", e)))?;
|
||||
|
||||
Ok(!statuses.is_empty())
|
||||
}
|
||||
|
||||
/// Get the short hash of HEAD
|
||||
pub fn get_head_short_hash(repo_path: impl AsRef<Path>) -> Result<String> {
|
||||
let repo = git2::Repository::open(repo_path.as_ref())
|
||||
.map_err(|e| Error::Git(format!("Failed to open repository: {}", e)))?;
|
||||
|
||||
let head = repo
|
||||
.head()
|
||||
.map_err(|e| Error::Git(format!("Failed to get HEAD: {}", e)))?;
|
||||
|
||||
let commit = head
|
||||
.peel_to_commit()
|
||||
.map_err(|e| Error::Git(format!("Failed to get commit: {}", e)))?;
|
||||
|
||||
Ok(commit.id().to_string()[..7].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_test_repo() -> (TempDir, PathBuf) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
git2::Repository::init(&repo_path).unwrap();
|
||||
(temp_dir, repo_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_git_root_from_root() {
|
||||
let (_temp, repo_path) = setup_test_repo();
|
||||
let found_root = find_git_root(Some(&repo_path)).unwrap();
|
||||
let canonical_found = fs::canonicalize(&found_root).unwrap();
|
||||
let canonical_expected = fs::canonicalize(&repo_path).unwrap();
|
||||
assert_eq!(canonical_found, canonical_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_git_root_from_subdirectory() {
|
||||
let (_temp, repo_path) = setup_test_repo();
|
||||
let sub_dir = repo_path.join("src").join("nested");
|
||||
fs::create_dir_all(&sub_dir).unwrap();
|
||||
let found_root = find_git_root(Some(&sub_dir)).unwrap();
|
||||
let canonical_found = fs::canonicalize(&found_root).unwrap();
|
||||
let canonical_expected = fs::canonicalize(&repo_path).unwrap();
|
||||
assert_eq!(canonical_found, canonical_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_repository() {
|
||||
let (_temp, repo_path) = setup_test_repo();
|
||||
assert!(is_valid_repository(&repo_path));
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
assert!(!is_valid_repository(temp_dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_in_git_repo() {
|
||||
let (_temp, repo_path) = setup_test_repo();
|
||||
assert!(is_in_git_repo(&repo_path));
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
assert!(!is_in_git_repo(temp_dir.path()));
|
||||
}
|
||||
}
|
||||
502
crates/miyabi-core/src/hooks.rs
Normal file
502
crates/miyabi-core/src/hooks.rs
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
//! Hooks System for Event-Driven Execution
|
||||
//!
|
||||
//! Provides a system for registering and executing hooks based on events.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Hook event types
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEvent {
|
||||
/// Before sending a message
|
||||
PreMessage,
|
||||
/// After receiving a response
|
||||
PostMessage,
|
||||
/// Before executing a tool
|
||||
PreTool,
|
||||
/// After tool execution
|
||||
PostTool,
|
||||
/// Before committing changes
|
||||
PreCommit,
|
||||
/// After committing changes
|
||||
PostCommit,
|
||||
/// On session start
|
||||
SessionStart,
|
||||
/// On session end
|
||||
SessionEnd,
|
||||
/// On error
|
||||
OnError,
|
||||
/// Custom event
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HookEvent::PreMessage => write!(f, "pre_message"),
|
||||
HookEvent::PostMessage => write!(f, "post_message"),
|
||||
HookEvent::PreTool => write!(f, "pre_tool"),
|
||||
HookEvent::PostTool => write!(f, "post_tool"),
|
||||
HookEvent::PreCommit => write!(f, "pre_commit"),
|
||||
HookEvent::PostCommit => write!(f, "post_commit"),
|
||||
HookEvent::SessionStart => write!(f, "session_start"),
|
||||
HookEvent::SessionEnd => write!(f, "session_end"),
|
||||
HookEvent::OnError => write!(f, "on_error"),
|
||||
HookEvent::Custom(name) => write!(f, "custom:{}", name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum HookAction {
|
||||
/// Run a shell command
|
||||
Shell { command: String },
|
||||
/// Run an agent with a prompt
|
||||
Agent {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
spec: Option<String>,
|
||||
},
|
||||
/// Send a notification
|
||||
Notify { title: String, message: String },
|
||||
/// Log a message
|
||||
Log { level: String, message: String },
|
||||
/// Execute a script file
|
||||
Script { path: PathBuf },
|
||||
}
|
||||
|
||||
/// A single hook definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hook {
|
||||
/// Hook name for identification
|
||||
pub name: String,
|
||||
/// Event that triggers this hook
|
||||
pub event: HookEvent,
|
||||
/// Action to perform
|
||||
pub action: HookAction,
|
||||
/// Whether the hook is enabled
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Optional condition (tool name, etc.)
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
/// Timeout in seconds
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
/// Hook execution context
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HookContext {
|
||||
/// Event-specific data
|
||||
pub data: HashMap<String, String>,
|
||||
/// Tool name (for tool events)
|
||||
pub tool_name: Option<String>,
|
||||
/// Tool result (for post_tool)
|
||||
pub tool_result: Option<String>,
|
||||
/// Error message (for on_error)
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl HookContext {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_tool(mut self, name: &str) -> Self {
|
||||
self.tool_name = Some(name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_result(mut self, result: &str) -> Self {
|
||||
self.tool_result = Some(result.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_error(mut self, error: &str) -> Self {
|
||||
self.error = Some(error.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_data(mut self, key: &str, value: &str) -> Self {
|
||||
self.data.insert(key.to_string(), value.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook execution result
|
||||
#[derive(Debug)]
|
||||
pub struct HookResult {
|
||||
pub hook_name: String,
|
||||
pub success: bool,
|
||||
pub output: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Hooks configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct HooksConfig {
|
||||
/// List of hooks
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<Hook>,
|
||||
}
|
||||
|
||||
impl HooksConfig {
|
||||
/// Load hooks from a YAML file
|
||||
pub fn load(path: &PathBuf) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: HooksConfig = serde_yaml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load hooks from default location (~/.miyabi/hooks.yml)
|
||||
pub fn load_default() -> Result<Self> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let path = home.join(".miyabi").join("hooks.yml");
|
||||
if path.exists() {
|
||||
Self::load(&path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Save hooks to a file
|
||||
pub fn save(&self, path: &PathBuf) -> Result<()> {
|
||||
let content = serde_yaml::to_string(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook manager for registering and executing hooks
|
||||
pub struct HookManager {
|
||||
hooks: Vec<Hook>,
|
||||
}
|
||||
|
||||
impl Default for HookManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl HookManager {
|
||||
/// Create a new hook manager
|
||||
pub fn new() -> Self {
|
||||
Self { hooks: Vec::new() }
|
||||
}
|
||||
|
||||
/// Create from config
|
||||
pub fn from_config(config: HooksConfig) -> Self {
|
||||
Self {
|
||||
hooks: config.hooks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load hooks from default location
|
||||
pub fn load_default() -> Result<Self> {
|
||||
let config = HooksConfig::load_default()?;
|
||||
Ok(Self::from_config(config))
|
||||
}
|
||||
|
||||
/// Register a hook
|
||||
pub fn register(&mut self, hook: Hook) {
|
||||
self.hooks.push(hook);
|
||||
}
|
||||
|
||||
/// Get hooks for an event
|
||||
pub fn get_hooks(&self, event: &HookEvent) -> Vec<&Hook> {
|
||||
self.hooks
|
||||
.iter()
|
||||
.filter(|h| h.enabled && &h.event == event)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Execute hooks for an event
|
||||
pub async fn execute(&self, event: &HookEvent, context: &HookContext) -> Vec<HookResult> {
|
||||
let hooks = self.get_hooks(event);
|
||||
let mut results = Vec::new();
|
||||
|
||||
for hook in hooks {
|
||||
// Check condition
|
||||
if let Some(condition) = &hook.condition {
|
||||
if let Some(tool_name) = &context.tool_name {
|
||||
if condition != tool_name {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = self.execute_hook(hook, context).await;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Execute a single hook
|
||||
async fn execute_hook(&self, hook: &Hook, context: &HookContext) -> HookResult {
|
||||
match &hook.action {
|
||||
HookAction::Shell { command } => {
|
||||
let expanded = self.expand_variables(command, context);
|
||||
match self.run_shell_command(&expanded, hook.timeout).await {
|
||||
Ok(output) => HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: true,
|
||||
output: Some(output),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
HookAction::Notify { title, message } => {
|
||||
let expanded_title = self.expand_variables(title, context);
|
||||
let expanded_message = self.expand_variables(message, context);
|
||||
// For now, just log the notification
|
||||
tracing::info!("Hook notification: {} - {}", expanded_title, expanded_message);
|
||||
HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: true,
|
||||
output: Some(format!("{}: {}", expanded_title, expanded_message)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
HookAction::Log { level, message } => {
|
||||
let expanded = self.expand_variables(message, context);
|
||||
match level.as_str() {
|
||||
"error" => tracing::error!("Hook: {}", expanded),
|
||||
"warn" => tracing::warn!("Hook: {}", expanded),
|
||||
"info" => tracing::info!("Hook: {}", expanded),
|
||||
"debug" => tracing::debug!("Hook: {}", expanded),
|
||||
_ => tracing::info!("Hook: {}", expanded),
|
||||
}
|
||||
HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: true,
|
||||
output: Some(expanded),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
HookAction::Script { path } => {
|
||||
if !path.exists() {
|
||||
return HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(format!("Script not found: {:?}", path)),
|
||||
};
|
||||
}
|
||||
let command = path.to_string_lossy().to_string();
|
||||
match self.run_shell_command(&command, hook.timeout).await {
|
||||
Ok(output) => HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: true,
|
||||
output: Some(output),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
HookAction::Agent { prompt, spec: _ } => {
|
||||
// Agent execution would require the full agent infrastructure
|
||||
// For now, return a placeholder
|
||||
let expanded = self.expand_variables(prompt, context);
|
||||
HookResult {
|
||||
hook_name: hook.name.clone(),
|
||||
success: true,
|
||||
output: Some(format!("Agent prompt: {}", expanded)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand variables in a string
|
||||
fn expand_variables(&self, input: &str, context: &HookContext) -> String {
|
||||
let mut result = input.to_string();
|
||||
|
||||
// Replace context variables
|
||||
if let Some(tool_name) = &context.tool_name {
|
||||
result = result.replace("${tool_name}", tool_name);
|
||||
}
|
||||
if let Some(tool_result) = &context.tool_result {
|
||||
result = result.replace("${tool_result}", tool_result);
|
||||
}
|
||||
if let Some(error) = &context.error {
|
||||
result = result.replace("${error}", error);
|
||||
}
|
||||
|
||||
// Replace custom data
|
||||
for (key, value) in &context.data {
|
||||
result = result.replace(&format!("${{{}}}", key), value);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Run a shell command
|
||||
async fn run_shell_command(&self, command: &str, timeout_secs: u64) -> Result<String> {
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
let output = timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output(),
|
||||
)
|
||||
.await??;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(anyhow::anyhow!("Command failed: {}", stderr))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all registered hooks
|
||||
pub fn list_hooks(&self) -> &[Hook] {
|
||||
&self.hooks
|
||||
}
|
||||
|
||||
/// Enable a hook by name
|
||||
pub fn enable(&mut self, name: &str) {
|
||||
for hook in &mut self.hooks {
|
||||
if hook.name == name {
|
||||
hook.enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable a hook by name
|
||||
pub fn disable(&mut self, name: &str) {
|
||||
for hook in &mut self.hooks {
|
||||
if hook.name == name {
|
||||
hook.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hook_event_display() {
|
||||
assert_eq!(HookEvent::PreMessage.to_string(), "pre_message");
|
||||
assert_eq!(HookEvent::PostTool.to_string(), "post_tool");
|
||||
assert_eq!(
|
||||
HookEvent::Custom("test".to_string()).to_string(),
|
||||
"custom:test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_context() {
|
||||
let context = HookContext::new()
|
||||
.with_tool("bash")
|
||||
.with_result("success")
|
||||
.with_data("key", "value");
|
||||
|
||||
assert_eq!(context.tool_name, Some("bash".to_string()));
|
||||
assert_eq!(context.tool_result, Some("success".to_string()));
|
||||
assert_eq!(context.data.get("key"), Some(&"value".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_variables() {
|
||||
let manager = HookManager::new();
|
||||
let context = HookContext::new()
|
||||
.with_tool("bash")
|
||||
.with_result("ok")
|
||||
.with_data("name", "test");
|
||||
|
||||
let input = "Tool: ${tool_name}, Result: ${tool_result}, Name: ${name}";
|
||||
let expanded = manager.expand_variables(input, &context);
|
||||
assert_eq!(expanded, "Tool: bash, Result: ok, Name: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_config_serialization() {
|
||||
let config = HooksConfig {
|
||||
hooks: vec![Hook {
|
||||
name: "test".to_string(),
|
||||
event: HookEvent::PostTool,
|
||||
action: HookAction::Log {
|
||||
level: "info".to_string(),
|
||||
message: "Tool completed".to_string(),
|
||||
},
|
||||
enabled: true,
|
||||
condition: Some("bash".to_string()),
|
||||
timeout: 30,
|
||||
}],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
assert!(yaml.contains("post_tool"));
|
||||
assert!(yaml.contains("bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_manager_get_hooks() {
|
||||
let mut manager = HookManager::new();
|
||||
manager.register(Hook {
|
||||
name: "hook1".to_string(),
|
||||
event: HookEvent::PreTool,
|
||||
action: HookAction::Log {
|
||||
level: "info".to_string(),
|
||||
message: "test".to_string(),
|
||||
},
|
||||
enabled: true,
|
||||
condition: None,
|
||||
timeout: 30,
|
||||
});
|
||||
manager.register(Hook {
|
||||
name: "hook2".to_string(),
|
||||
event: HookEvent::PostTool,
|
||||
action: HookAction::Log {
|
||||
level: "info".to_string(),
|
||||
message: "test".to_string(),
|
||||
},
|
||||
enabled: true,
|
||||
condition: None,
|
||||
timeout: 30,
|
||||
});
|
||||
|
||||
let pre_hooks = manager.get_hooks(&HookEvent::PreTool);
|
||||
assert_eq!(pre_hooks.len(), 1);
|
||||
assert_eq!(pre_hooks[0].name, "hook1");
|
||||
|
||||
let post_hooks = manager.get_hooks(&HookEvent::PostTool);
|
||||
assert_eq!(post_hooks.len(), 1);
|
||||
assert_eq!(post_hooks[0].name, "hook2");
|
||||
}
|
||||
}
|
||||
|
|
@ -13,15 +13,18 @@ pub mod feature_flags;
|
|||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod github_tools;
|
||||
pub mod hooks;
|
||||
pub mod logger;
|
||||
pub mod plugin;
|
||||
pub mod retry;
|
||||
pub mod rules;
|
||||
pub mod mcp;
|
||||
pub mod session;
|
||||
pub mod token;
|
||||
pub mod tool;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
pub mod workflow;
|
||||
|
||||
pub use agent::{
|
||||
Agent, AgentConfig, AgentError, AgentEvent, AgentResult, ExecutorRegistry, RiskLevel,
|
||||
|
|
@ -64,6 +67,7 @@ pub use github_tools::{
|
|||
create_github_tool_registry, AddCommentTool, AddLabelsTool, CreateIssueTool,
|
||||
CreatePullRequestTool, GetIssueTool, ListIssuesTool, ListPullRequestsTool,
|
||||
};
|
||||
pub use hooks::{Hook, HookAction, HookContext, HookEvent, HookManager, HookResult, HooksConfig};
|
||||
pub use logger::{init_logger, init_logger_with_config, LogFormat, LogLevel, LoggerConfig};
|
||||
pub use plugin::{Plugin, PluginContext, PluginManager, PluginMetadata, PluginResult, PluginState};
|
||||
pub use retry::{retry_with_backoff, RetryConfig as BackoffRetryConfig};
|
||||
|
|
@ -76,3 +80,10 @@ pub use tools::{
|
|||
GrepTool, ReadTool, WriteTool,
|
||||
};
|
||||
pub use types::*;
|
||||
pub use workflow::{
|
||||
FailurePolicy, StepCondition, StepResult, StepStatus, Workflow, WorkflowContext,
|
||||
WorkflowManager, WorkflowResult, WorkflowStatus, WorkflowStep,
|
||||
};
|
||||
pub use mcp::{
|
||||
McpConfig, McpError, McpManager, McpRequest, McpResponse, McpServer, McpServerConfig, McpTool,
|
||||
};
|
||||
|
|
|
|||
158
crates/miyabi-core/src/logger.rs
Normal file
158
crates/miyabi-core/src/logger.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//! Logging utilities for Miyabi
|
||||
//!
|
||||
//! Provides structured logging with multiple output formats
|
||||
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, prelude::*, EnvFilter};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogFormat {
|
||||
/// Human-readable colored output
|
||||
Pretty,
|
||||
/// Compact format for CI/CD
|
||||
Compact,
|
||||
/// JSON format for structured parsing
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Logger configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoggerConfig {
|
||||
/// Log level
|
||||
pub level: LogLevel,
|
||||
/// Log format
|
||||
pub format: LogFormat,
|
||||
}
|
||||
|
||||
impl Default for LoggerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: LogLevel::Info,
|
||||
format: LogFormat::Pretty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for LogLevel {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"trace" => LogLevel::Trace,
|
||||
"debug" => LogLevel::Debug,
|
||||
"info" => LogLevel::Info,
|
||||
"warn" => LogLevel::Warn,
|
||||
"error" => LogLevel::Error,
|
||||
_ => LogLevel::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogLevel> for &'static str {
|
||||
fn from(level: LogLevel) -> Self {
|
||||
match level {
|
||||
LogLevel::Trace => "trace",
|
||||
LogLevel::Debug => "debug",
|
||||
LogLevel::Info => "info",
|
||||
LogLevel::Warn => "warn",
|
||||
LogLevel::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for LogFormat {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"json" => LogFormat::Json,
|
||||
"compact" => LogFormat::Compact,
|
||||
_ => LogFormat::Pretty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize logging with default configuration
|
||||
pub fn init_logger(level: LogLevel) {
|
||||
let config = LoggerConfig {
|
||||
level,
|
||||
..Default::default()
|
||||
};
|
||||
init_logger_with_config(config);
|
||||
}
|
||||
|
||||
/// Initialize logging with custom configuration
|
||||
pub fn init_logger_with_config(config: LoggerConfig) {
|
||||
let level_str: &'static str = config.level.into();
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level_str));
|
||||
|
||||
let subscriber = tracing_subscriber::registry().with(filter);
|
||||
|
||||
match config.format {
|
||||
LogFormat::Pretty => {
|
||||
let fmt_layer = fmt::layer()
|
||||
.pretty()
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_line_number(true);
|
||||
subscriber.with(fmt_layer).init();
|
||||
}
|
||||
LogFormat::Compact => {
|
||||
let fmt_layer = fmt::layer()
|
||||
.compact()
|
||||
.with_target(false)
|
||||
.with_thread_ids(false);
|
||||
subscriber.with(fmt_layer).init();
|
||||
}
|
||||
LogFormat::Json => {
|
||||
let fmt_layer = fmt::layer()
|
||||
.json()
|
||||
.with_current_span(true)
|
||||
.with_span_list(true);
|
||||
subscriber.with(fmt_layer).init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_log_level_from_str() {
|
||||
assert_eq!(LogLevel::from("trace"), LogLevel::Trace);
|
||||
assert_eq!(LogLevel::from("debug"), LogLevel::Debug);
|
||||
assert_eq!(LogLevel::from("info"), LogLevel::Info);
|
||||
assert_eq!(LogLevel::from("warn"), LogLevel::Warn);
|
||||
assert_eq!(LogLevel::from("error"), LogLevel::Error);
|
||||
assert_eq!(LogLevel::from("invalid"), LogLevel::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_level_to_str() {
|
||||
let level_str: &'static str = LogLevel::Trace.into();
|
||||
assert_eq!(level_str, "trace");
|
||||
|
||||
let level_str: &'static str = LogLevel::Info.into();
|
||||
assert_eq!(level_str, "info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_format_from_str() {
|
||||
assert_eq!(LogFormat::from("json"), LogFormat::Json);
|
||||
assert_eq!(LogFormat::from("compact"), LogFormat::Compact);
|
||||
assert_eq!(LogFormat::from("pretty"), LogFormat::Pretty);
|
||||
assert_eq!(LogFormat::from("invalid"), LogFormat::Pretty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logger_config_default() {
|
||||
let config = LoggerConfig::default();
|
||||
assert_eq!(config.level, LogLevel::Info);
|
||||
assert_eq!(config.format, LogFormat::Pretty);
|
||||
}
|
||||
}
|
||||
407
crates/miyabi-core/src/mcp.rs
Normal file
407
crates/miyabi-core/src/mcp.rs
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
//! MCP (Model Context Protocol) Support
|
||||
//!
|
||||
//! Integration with external MCP tool servers.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
/// MCP server configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
/// Server name
|
||||
pub name: String,
|
||||
/// Command to start the server
|
||||
pub command: String,
|
||||
/// Command arguments
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Environment variables
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
/// Whether to auto-start
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_start: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// MCP servers configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct McpConfig {
|
||||
/// List of server configurations
|
||||
#[serde(default)]
|
||||
pub servers: Vec<McpServerConfig>,
|
||||
}
|
||||
|
||||
impl McpConfig {
|
||||
/// Load configuration from a YAML file
|
||||
pub fn load(path: &PathBuf) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let config: McpConfig = serde_yaml::from_str(&content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load from default location (~/.miyabi/mcp.yml)
|
||||
pub fn load_default() -> Result<Self> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let path = home.join(".miyabi").join("mcp.yml");
|
||||
if path.exists() {
|
||||
Self::load(&path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Save configuration to a file
|
||||
pub fn save(&self, path: &PathBuf) -> Result<()> {
|
||||
let content = serde_yaml::to_string(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP JSON-RPC request
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// MCP JSON-RPC response
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct McpResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: u64,
|
||||
#[serde(default)]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub error: Option<McpError>,
|
||||
}
|
||||
|
||||
/// MCP error
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct McpError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// MCP tool definition
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// MCP server connection
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
process: Child,
|
||||
request_id: u64,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
/// Start a new MCP server
|
||||
pub async fn start(config: &McpServerConfig) -> Result<Self> {
|
||||
let mut cmd = Command::new(&config.command);
|
||||
cmd.args(&config.args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
for (key, value) in &config.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let process = cmd.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
name: config.name.clone(),
|
||||
process,
|
||||
request_id: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request and get a response
|
||||
pub async fn request(&mut self, method: &str, params: Option<serde_json::Value>) -> Result<McpResponse> {
|
||||
self.request_id += 1;
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: self.request_id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
};
|
||||
|
||||
let stdin = self.process.stdin.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?;
|
||||
let stdout = self.process.stdout.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get stdout"))?;
|
||||
|
||||
// Send request
|
||||
let request_json = serde_json::to_string(&request)?;
|
||||
stdin.write_all(request_json.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// Read response
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
|
||||
let response: McpResponse = serde_json::from_str(&line)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Initialize the server
|
||||
pub async fn initialize(&mut self) -> Result<serde_json::Value> {
|
||||
let params = serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "miyabi",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.request("initialize", Some(params)).await?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(anyhow::anyhow!("MCP error: {}", error.message));
|
||||
}
|
||||
|
||||
response.result.ok_or_else(|| anyhow::anyhow!("No result in response"))
|
||||
}
|
||||
|
||||
/// List available tools
|
||||
pub async fn list_tools(&mut self) -> Result<Vec<McpTool>> {
|
||||
let response = self.request("tools/list", None).await?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(anyhow::anyhow!("MCP error: {}", error.message));
|
||||
}
|
||||
|
||||
let result = response.result.ok_or_else(|| anyhow::anyhow!("No result"))?;
|
||||
let tools: Vec<McpTool> = serde_json::from_value(
|
||||
result.get("tools").cloned().unwrap_or(serde_json::Value::Array(vec![]))
|
||||
)?;
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// Call a tool
|
||||
pub async fn call_tool(&mut self, name: &str, arguments: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let params = serde_json::json!({
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
});
|
||||
|
||||
let response = self.request("tools/call", Some(params)).await?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(anyhow::anyhow!("MCP error: {}", error.message));
|
||||
}
|
||||
|
||||
response.result.ok_or_else(|| anyhow::anyhow!("No result"))
|
||||
}
|
||||
|
||||
/// Shutdown the server
|
||||
pub async fn shutdown(&mut self) -> Result<()> {
|
||||
let _ = self.request("shutdown", None).await;
|
||||
self.process.kill().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP Manager for handling multiple servers
|
||||
pub struct McpManager {
|
||||
servers: HashMap<String, McpServer>,
|
||||
config: McpConfig,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
/// Create a new MCP manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
servers: HashMap::new(),
|
||||
config: McpConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from configuration
|
||||
pub fn with_config(config: McpConfig) -> Self {
|
||||
Self {
|
||||
servers: HashMap::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from default location
|
||||
pub fn load_default() -> Result<Self> {
|
||||
let config = McpConfig::load_default()?;
|
||||
Ok(Self::with_config(config))
|
||||
}
|
||||
|
||||
/// Start all configured servers
|
||||
pub async fn start_all(&mut self) -> Result<()> {
|
||||
let names: Vec<_> = self
|
||||
.config
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|s| s.auto_start)
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
|
||||
for name in names {
|
||||
self.start_server(&name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a specific server
|
||||
pub async fn start_server(&mut self, name: &str) -> Result<()> {
|
||||
let config = self
|
||||
.config
|
||||
.servers
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Server not found: {}", name))?
|
||||
.clone();
|
||||
|
||||
let mut server = McpServer::start(&config).await?;
|
||||
server.initialize().await?;
|
||||
self.servers.insert(name.to_string(), server);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop a specific server
|
||||
pub async fn stop_server(&mut self, name: &str) -> Result<()> {
|
||||
if let Some(mut server) = self.servers.remove(name) {
|
||||
server.shutdown().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop all servers
|
||||
pub async fn stop_all(&mut self) -> Result<()> {
|
||||
let names: Vec<_> = self.servers.keys().cloned().collect();
|
||||
for name in names {
|
||||
self.stop_server(&name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a server by name
|
||||
pub fn get_server(&mut self, name: &str) -> Option<&mut McpServer> {
|
||||
self.servers.get_mut(name)
|
||||
}
|
||||
|
||||
/// List all available tools across all servers
|
||||
pub async fn list_all_tools(&mut self) -> Result<HashMap<String, Vec<McpTool>>> {
|
||||
let mut all_tools = HashMap::new();
|
||||
let names: Vec<_> = self.servers.keys().cloned().collect();
|
||||
|
||||
for name in names {
|
||||
if let Some(server) = self.servers.get_mut(&name) {
|
||||
let tools = server.list_tools().await?;
|
||||
all_tools.insert(name, tools);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_tools)
|
||||
}
|
||||
|
||||
/// Call a tool on a specific server
|
||||
pub async fn call_tool(
|
||||
&mut self,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
let server = self
|
||||
.get_server(server_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Server not found: {}", server_name))?;
|
||||
|
||||
server.call_tool(tool_name, arguments).await
|
||||
}
|
||||
|
||||
/// List running servers
|
||||
pub fn list_servers(&self) -> Vec<&str> {
|
||||
self.servers.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Check if a server is running
|
||||
pub fn is_running(&self, name: &str) -> bool {
|
||||
self.servers.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_serialization() {
|
||||
let config = McpConfig {
|
||||
servers: vec![McpServerConfig {
|
||||
name: "filesystem".to_string(),
|
||||
command: "npx".to_string(),
|
||||
args: vec!["@anthropic/mcp-server-filesystem".to_string()],
|
||||
env: HashMap::new(),
|
||||
auto_start: true,
|
||||
}],
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
assert!(yaml.contains("filesystem"));
|
||||
assert!(yaml.contains("npx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_request_serialization() {
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: 1,
|
||||
method: "tools/list".to_string(),
|
||||
params: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("tools/list"));
|
||||
assert!(!json.contains("params"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_manager() {
|
||||
let config = McpConfig {
|
||||
servers: vec![McpServerConfig {
|
||||
name: "test".to_string(),
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
auto_start: false,
|
||||
}],
|
||||
};
|
||||
|
||||
let manager = McpManager::with_config(config);
|
||||
assert!(!manager.is_running("test"));
|
||||
assert_eq!(manager.list_servers().len(), 0);
|
||||
}
|
||||
}
|
||||
408
crates/miyabi-core/src/plugin.rs
Normal file
408
crates/miyabi-core/src/plugin.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
//! Plugin system for Miyabi
|
||||
//!
|
||||
//! Provides a flexible plugin architecture for extending Miyabi functionality.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use miyabi_core::plugin::{Plugin, PluginManager, PluginMetadata, PluginContext, PluginResult};
|
||||
//! use anyhow::Result;
|
||||
//!
|
||||
//! struct MyPlugin;
|
||||
//!
|
||||
//! impl Plugin for MyPlugin {
|
||||
//! fn metadata(&self) -> PluginMetadata {
|
||||
//! PluginMetadata {
|
||||
//! name: "my-plugin".to_string(),
|
||||
//! version: "1.0.0".to_string(),
|
||||
//! description: Some("My custom plugin".to_string()),
|
||||
//! author: Some("Miyabi Team".to_string()),
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! fn init(&mut self) -> Result<()> {
|
||||
//! println!("Plugin initialized!");
|
||||
//! Ok(())
|
||||
//! }
|
||||
//!
|
||||
//! fn execute(&self, context: &PluginContext) -> Result<PluginResult> {
|
||||
//! Ok(PluginResult {
|
||||
//! success: true,
|
||||
//! message: Some("Plugin executed successfully".to_string()),
|
||||
//! data: None,
|
||||
//! })
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Plugin metadata information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PluginMetadata {
|
||||
/// Plugin name (unique identifier)
|
||||
pub name: String,
|
||||
/// Semantic version
|
||||
pub version: String,
|
||||
/// Optional description
|
||||
pub description: Option<String>,
|
||||
/// Optional author
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// Plugin execution context
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PluginContext {
|
||||
/// Plugin parameters
|
||||
#[serde(default)]
|
||||
pub params: HashMap<String, serde_json::Value>,
|
||||
/// Working directory
|
||||
pub working_dir: Option<String>,
|
||||
/// Environment variables
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Plugin execution result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginResult {
|
||||
/// Execution success status
|
||||
pub success: bool,
|
||||
/// Optional result message
|
||||
pub message: Option<String>,
|
||||
/// Optional result data
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Core plugin trait
|
||||
///
|
||||
/// All plugins must implement this trait to be registered with the PluginManager.
|
||||
pub trait Plugin: Send + Sync {
|
||||
/// Returns plugin metadata
|
||||
fn metadata(&self) -> PluginMetadata;
|
||||
|
||||
/// Initializes the plugin
|
||||
///
|
||||
/// Called once when the plugin is registered.
|
||||
fn init(&mut self) -> Result<()>;
|
||||
|
||||
/// Executes the plugin with the given context
|
||||
fn execute(&self, context: &PluginContext) -> Result<PluginResult>;
|
||||
|
||||
/// Cleans up plugin resources
|
||||
///
|
||||
/// Called when the plugin is unregistered or the manager is dropped.
|
||||
fn shutdown(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin lifecycle state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginState {
|
||||
/// Plugin is registered but not initialized
|
||||
Registered,
|
||||
/// Plugin is initialized and ready
|
||||
Initialized,
|
||||
/// Plugin encountered an error
|
||||
Error,
|
||||
/// Plugin is shut down
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Internal plugin wrapper
|
||||
struct PluginEntry {
|
||||
plugin: Box<dyn Plugin>,
|
||||
state: PluginState,
|
||||
}
|
||||
|
||||
/// Plugin manager
|
||||
///
|
||||
/// Manages plugin registration, initialization, and execution.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginManager {
|
||||
plugins: Arc<RwLock<HashMap<String, PluginEntry>>>,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Creates a new plugin manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
plugins: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a plugin
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - A plugin with the same name already exists
|
||||
/// - Plugin initialization fails
|
||||
pub fn register(&self, mut plugin: Box<dyn Plugin>) -> Result<()> {
|
||||
let metadata = plugin.metadata();
|
||||
let name = metadata.name.clone();
|
||||
|
||||
// Check if plugin already exists
|
||||
{
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
if plugins.contains_key(&name) {
|
||||
return Err(anyhow!("Plugin '{}' is already registered", name));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize plugin
|
||||
plugin.init()?;
|
||||
|
||||
// Store plugin
|
||||
let mut plugins = self.plugins.write().unwrap();
|
||||
plugins.insert(
|
||||
name.clone(),
|
||||
PluginEntry {
|
||||
plugin,
|
||||
state: PluginState::Initialized,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unregisters a plugin
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the plugin doesn't exist
|
||||
pub fn unregister(&self, name: &str) -> Result<()> {
|
||||
let mut plugins = self.plugins.write().unwrap();
|
||||
|
||||
let mut entry =
|
||||
plugins.remove(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?;
|
||||
|
||||
entry.plugin.shutdown()?;
|
||||
entry.state = PluginState::Shutdown;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Executes a plugin with the given context
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The plugin doesn't exist
|
||||
/// - The plugin is not initialized
|
||||
/// - Plugin execution fails
|
||||
pub fn execute(&self, name: &str, context: &PluginContext) -> Result<PluginResult> {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
|
||||
let entry = plugins.get(name).ok_or_else(|| anyhow!("Plugin '{}' not found", name))?;
|
||||
|
||||
if entry.state != PluginState::Initialized {
|
||||
return Err(anyhow!("Plugin '{}' is not initialized (state: {:?})", name, entry.state));
|
||||
}
|
||||
|
||||
entry.plugin.execute(context)
|
||||
}
|
||||
|
||||
/// Lists all registered plugins
|
||||
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
plugins.values().map(|entry| entry.plugin.metadata()).collect()
|
||||
}
|
||||
|
||||
/// Gets plugin metadata
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the plugin doesn't exist
|
||||
pub fn get_metadata(&self, name: &str) -> Result<PluginMetadata> {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
plugins
|
||||
.get(name)
|
||||
.map(|entry| entry.plugin.metadata())
|
||||
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))
|
||||
}
|
||||
|
||||
/// Gets plugin state
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the plugin doesn't exist
|
||||
pub fn get_state(&self, name: &str) -> Result<PluginState> {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
plugins
|
||||
.get(name)
|
||||
.map(|entry| entry.state)
|
||||
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))
|
||||
}
|
||||
|
||||
/// Checks if a plugin exists
|
||||
pub fn has_plugin(&self, name: &str) -> bool {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
plugins.contains_key(name)
|
||||
}
|
||||
|
||||
/// Gets the number of registered plugins
|
||||
pub fn count(&self) -> usize {
|
||||
let plugins = self.plugins.read().unwrap();
|
||||
plugins.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginManager {
|
||||
fn drop(&mut self) {
|
||||
// Shutdown all plugins
|
||||
if let Ok(mut plugins) = self.plugins.write() {
|
||||
for (name, entry) in plugins.iter_mut() {
|
||||
if let Err(e) = entry.plugin.shutdown() {
|
||||
eprintln!("Error shutting down plugin '{}': {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestPlugin {
|
||||
name: String,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl TestPlugin {
|
||||
fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for TestPlugin {
|
||||
fn metadata(&self) -> PluginMetadata {
|
||||
PluginMetadata {
|
||||
name: self.name.clone(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Some("Test plugin".to_string()),
|
||||
author: Some("Test Author".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn init(&mut self) -> Result<()> {
|
||||
self.initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute(&self, _context: &PluginContext) -> Result<PluginResult> {
|
||||
Ok(PluginResult {
|
||||
success: true,
|
||||
message: Some(format!("Plugin '{}' executed", self.name)),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_plugin() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
assert!(manager.register(plugin).is_ok());
|
||||
assert_eq!(manager.count(), 1);
|
||||
assert!(manager.has_plugin("test-plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_registration() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin1 = Box::new(TestPlugin::new("test-plugin"));
|
||||
let plugin2 = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
assert!(manager.register(plugin1).is_ok());
|
||||
assert!(manager.register(plugin2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_plugin() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
manager.register(plugin).unwrap();
|
||||
|
||||
let context = PluginContext::default();
|
||||
let result = manager.execute("test-plugin", &context).unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unregister_plugin() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
manager.register(plugin).unwrap();
|
||||
assert_eq!(manager.count(), 1);
|
||||
|
||||
manager.unregister("test-plugin").unwrap();
|
||||
assert_eq!(manager.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_plugins() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin1 = Box::new(TestPlugin::new("plugin-1"));
|
||||
let plugin2 = Box::new(TestPlugin::new("plugin-2"));
|
||||
|
||||
manager.register(plugin1).unwrap();
|
||||
manager.register(plugin2).unwrap();
|
||||
|
||||
let plugins = manager.list_plugins();
|
||||
assert_eq!(plugins.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_metadata() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
manager.register(plugin).unwrap();
|
||||
|
||||
let metadata = manager.get_metadata("test-plugin").unwrap();
|
||||
assert_eq!(metadata.name, "test-plugin");
|
||||
assert_eq!(metadata.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_state() {
|
||||
let manager = PluginManager::new();
|
||||
let plugin = Box::new(TestPlugin::new("test-plugin"));
|
||||
|
||||
manager.register(plugin).unwrap();
|
||||
|
||||
let state = manager.get_state("test-plugin").unwrap();
|
||||
assert_eq!(state, PluginState::Initialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_not_found() {
|
||||
let manager = PluginManager::new();
|
||||
let context = PluginContext::default();
|
||||
|
||||
assert!(manager.execute("nonexistent", &context).is_err());
|
||||
assert!(manager.get_metadata("nonexistent").is_err());
|
||||
assert!(manager.get_state("nonexistent").is_err());
|
||||
assert!(manager.unregister("nonexistent").is_err());
|
||||
}
|
||||
}
|
||||
212
crates/miyabi-core/src/retry.rs
Normal file
212
crates/miyabi-core/src/retry.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! Retry logic with exponential backoff
|
||||
//!
|
||||
//! Provides utilities for retrying transient failures with exponential backoff.
|
||||
//! Useful for network operations, external API calls, and transient errors.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for retry behavior
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum number of retry attempts (excluding the first attempt)
|
||||
pub max_attempts: u32,
|
||||
/// Initial delay before first retry (milliseconds)
|
||||
pub initial_delay_ms: u64,
|
||||
/// Maximum delay between retries (milliseconds)
|
||||
pub max_delay_ms: u64,
|
||||
/// Backoff multiplier (typically 2.0 for exponential backoff)
|
||||
pub backoff_multiplier: f64,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 3,
|
||||
initial_delay_ms: 100,
|
||||
max_delay_ms: 30000,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Create a new retry configuration
|
||||
pub fn new(max_attempts: u32, initial_delay_ms: u64, max_delay_ms: u64) -> Self {
|
||||
Self {
|
||||
max_attempts,
|
||||
initial_delay_ms,
|
||||
max_delay_ms,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create aggressive retry config (5 attempts, 50ms initial, 10s max)
|
||||
pub fn aggressive() -> Self {
|
||||
Self {
|
||||
max_attempts: 5,
|
||||
initial_delay_ms: 50,
|
||||
max_delay_ms: 10000,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create conservative retry config (2 attempts, 500ms initial, 60s max)
|
||||
pub fn conservative() -> Self {
|
||||
Self {
|
||||
max_attempts: 2,
|
||||
initial_delay_ms: 500,
|
||||
max_delay_ms: 60000,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate delay for a given attempt number (0-indexed)
|
||||
pub fn calculate_delay(&self, attempt: u32) -> Duration {
|
||||
let delay_ms = (self.initial_delay_ms as f64 * self.backoff_multiplier.powi(attempt as i32))
|
||||
.min(self.max_delay_ms as f64) as u64;
|
||||
Duration::from_millis(delay_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a future with exponential backoff
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - Retry configuration
|
||||
/// * `operation` - Async operation to retry (must be Fn, not FnOnce)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(T)` - Operation succeeded
|
||||
/// * `Err(Error)` - All retry attempts failed
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let result = retry_with_backoff(
|
||||
/// RetryConfig::default(),
|
||||
/// || async { fetch_data().await }
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn retry_with_backoff<F, Fut, T>(config: RetryConfig, operation: F) -> Result<T>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<T>>,
|
||||
{
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..=config.max_attempts {
|
||||
match operation().await {
|
||||
Ok(result) => {
|
||||
return Ok(result);
|
||||
}
|
||||
Err(error) => {
|
||||
// Check if error is retryable
|
||||
if !error.is_retryable() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
last_error = Some(error);
|
||||
|
||||
// Don't sleep after the last attempt
|
||||
if attempt < config.max_attempts {
|
||||
let delay = config.calculate_delay(attempt);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed
|
||||
Err(last_error.unwrap_or_else(|| Error::Other("No error captured".to_string())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_config_default() {
|
||||
let config = RetryConfig::default();
|
||||
assert_eq!(config.max_attempts, 3);
|
||||
assert_eq!(config.initial_delay_ms, 100);
|
||||
assert_eq!(config.max_delay_ms, 30000);
|
||||
assert_eq!(config.backoff_multiplier, 2.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_calculate_delay_exponential() {
|
||||
let config = RetryConfig::new(3, 100, 10000);
|
||||
|
||||
assert_eq!(config.calculate_delay(0), Duration::from_millis(100));
|
||||
assert_eq!(config.calculate_delay(1), Duration::from_millis(200));
|
||||
assert_eq!(config.calculate_delay(2), Duration::from_millis(400));
|
||||
assert_eq!(config.calculate_delay(3), Duration::from_millis(800));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_succeeds_immediately() {
|
||||
let attempt_count = Arc::new(AtomicU32::new(0));
|
||||
let attempt_count_clone = Arc::clone(&attempt_count);
|
||||
|
||||
let config = RetryConfig::new(3, 10, 1000);
|
||||
|
||||
let result = retry_with_backoff(config, || {
|
||||
let count = Arc::clone(&attempt_count_clone);
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok::<String, Error>("success".to_string())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "success");
|
||||
assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_succeeds_after_failures() {
|
||||
let attempt_count = Arc::new(AtomicU32::new(0));
|
||||
let attempt_count_clone = Arc::clone(&attempt_count);
|
||||
|
||||
let config = RetryConfig::new(3, 10, 1000);
|
||||
|
||||
let result = retry_with_backoff(config, || {
|
||||
let count = Arc::clone(&attempt_count_clone);
|
||||
async move {
|
||||
let current = count.fetch_add(1, Ordering::SeqCst);
|
||||
if current < 2 {
|
||||
Err(Error::Timeout(1000))
|
||||
} else {
|
||||
Ok::<String, Error>("success".to_string())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_non_retryable_fails_immediately() {
|
||||
let attempt_count = Arc::new(AtomicU32::new(0));
|
||||
let attempt_count_clone = Arc::clone(&attempt_count);
|
||||
|
||||
let config = RetryConfig::new(3, 10, 1000);
|
||||
|
||||
let result = retry_with_backoff(config, || {
|
||||
let count = Arc::clone(&attempt_count_clone);
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Err::<String, Error>(Error::Validation("Invalid".to_string()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempt_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
325
crates/miyabi-core/src/rules.rs
Normal file
325
crates/miyabi-core/src/rules.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
//! Project-specific rules support (.miyabirules)
|
||||
//!
|
||||
//! This module provides support for loading and applying custom rules from `.miyabirules` files.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error types for rules operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RulesError {
|
||||
/// File not found
|
||||
#[error("Rules file not found: {0}")]
|
||||
FileNotFound(PathBuf),
|
||||
|
||||
/// Parse error
|
||||
#[error("Failed to parse rules file: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
/// Validation error
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
/// I/O error
|
||||
#[error("I/O error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, RulesError>;
|
||||
|
||||
/// A single rule with pattern matching and suggestion
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Rule {
|
||||
/// Rule name
|
||||
pub name: String,
|
||||
|
||||
/// Pattern to match (regex)
|
||||
#[serde(default)]
|
||||
pub pattern: Option<String>,
|
||||
|
||||
/// Suggestion message
|
||||
pub suggestion: String,
|
||||
|
||||
/// File extension filters (e.g., ["rs", "toml"])
|
||||
#[serde(default)]
|
||||
pub file_extensions: Vec<String>,
|
||||
|
||||
/// Severity: "info", "warning", "error"
|
||||
#[serde(default = "default_severity")]
|
||||
pub severity: String,
|
||||
|
||||
/// Whether this rule is enabled
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn default_severity() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// Check if this rule applies to a given file extension
|
||||
pub fn applies_to_file(&self, file_path: &Path) -> bool {
|
||||
if self.file_extensions.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(ext) = file_path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_string();
|
||||
self.file_extensions.iter().any(|e| e == &ext_str)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this rule matches a given line of code
|
||||
pub fn matches(&self, line: &str) -> bool {
|
||||
if let Some(pattern) = &self.pattern {
|
||||
line.contains(pattern)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent-specific preferences
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct AgentPreferences {
|
||||
/// Code style preference
|
||||
#[serde(default)]
|
||||
pub style: Option<String>,
|
||||
|
||||
/// Error handling strategy
|
||||
#[serde(default)]
|
||||
pub error_handling: Option<String>,
|
||||
|
||||
/// Minimum quality score
|
||||
#[serde(default)]
|
||||
pub min_score: Option<u8>,
|
||||
|
||||
/// Enable strict Clippy checks
|
||||
#[serde(default)]
|
||||
pub clippy_strict: Option<bool>,
|
||||
|
||||
/// Custom agent-specific settings
|
||||
#[serde(flatten)]
|
||||
pub custom: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Root configuration structure for .miyabirules
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MiyabiRules {
|
||||
/// Version of the rules format
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
|
||||
/// List of rules
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
|
||||
/// Agent preferences by agent type
|
||||
#[serde(default)]
|
||||
pub agent_preferences: HashMap<String, AgentPreferences>,
|
||||
|
||||
/// Global settings
|
||||
#[serde(default)]
|
||||
pub settings: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl Default for MiyabiRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
rules: Vec::new(),
|
||||
agent_preferences: HashMap::new(),
|
||||
settings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MiyabiRules {
|
||||
/// Create a new empty rules configuration
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Validate the rules configuration
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.version != 1 {
|
||||
return Err(RulesError::ValidationError(format!(
|
||||
"Unsupported version: {}. Only version 1 is supported.",
|
||||
self.version
|
||||
)));
|
||||
}
|
||||
|
||||
for rule in &self.rules {
|
||||
if rule.name.is_empty() {
|
||||
return Err(RulesError::ValidationError(
|
||||
"Rule name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if rule.suggestion.is_empty() {
|
||||
return Err(RulesError::ValidationError(format!(
|
||||
"Rule '{}' must have a suggestion",
|
||||
rule.name
|
||||
)));
|
||||
}
|
||||
|
||||
match rule.severity.as_str() {
|
||||
"info" | "warning" | "error" => {}
|
||||
_ => {
|
||||
return Err(RulesError::ValidationError(format!(
|
||||
"Invalid severity '{}' for rule '{}'. Must be: info, warning, or error",
|
||||
rule.severity, rule.name
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get rules that apply to a specific file
|
||||
pub fn rules_for_file(&self, file_path: &Path) -> Vec<&Rule> {
|
||||
self.rules
|
||||
.iter()
|
||||
.filter(|r| r.enabled && r.applies_to_file(file_path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get agent preferences for a specific agent type
|
||||
pub fn get_agent_preferences(&self, agent_type: &str) -> Option<&AgentPreferences> {
|
||||
self.agent_preferences.get(agent_type)
|
||||
}
|
||||
|
||||
/// Get a global setting value
|
||||
pub fn get_setting(&self, key: &str) -> Option<&serde_json::Value> {
|
||||
self.settings.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rules loader for loading .miyabirules files
|
||||
pub struct RulesLoader {
|
||||
/// Root directory to search for .miyabirules
|
||||
root_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RulesLoader {
|
||||
/// Create a new RulesLoader
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
Self { root_dir }
|
||||
}
|
||||
|
||||
/// Find .miyabirules file in the directory hierarchy
|
||||
pub fn find_rules_file(&self) -> Option<PathBuf> {
|
||||
let mut current = self.root_dir.clone();
|
||||
|
||||
loop {
|
||||
let rules_path = current.join(".miyabirules");
|
||||
if rules_path.exists() {
|
||||
return Some(rules_path);
|
||||
}
|
||||
|
||||
let rules_yaml = current.join(".miyabirules.yaml");
|
||||
if rules_yaml.exists() {
|
||||
return Some(rules_yaml);
|
||||
}
|
||||
|
||||
let rules_yml = current.join(".miyabirules.yml");
|
||||
if rules_yml.exists() {
|
||||
return Some(rules_yml);
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Load rules from .miyabirules file
|
||||
pub fn load(&self) -> Result<Option<MiyabiRules>> {
|
||||
let rules_path = match self.find_rules_file() {
|
||||
Some(path) => path,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let content = fs::read_to_string(&rules_path)?;
|
||||
|
||||
let rules: MiyabiRules = serde_yaml::from_str(&content).map_err(|e| {
|
||||
RulesError::ParseError(format!("Failed to parse {}: {}", rules_path.display(), e))
|
||||
})?;
|
||||
|
||||
rules.validate()?;
|
||||
|
||||
Ok(Some(rules))
|
||||
}
|
||||
|
||||
/// Load rules or return default if not found
|
||||
pub fn load_or_default(&self) -> Result<MiyabiRules> {
|
||||
self.load().map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_rule_applies_to_file() {
|
||||
let rule = Rule {
|
||||
name: "Rust rule".to_string(),
|
||||
pattern: None,
|
||||
suggestion: "Test".to_string(),
|
||||
file_extensions: vec!["rs".to_string()],
|
||||
severity: "info".to_string(),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
assert!(rule.applies_to_file(Path::new("main.rs")));
|
||||
assert!(!rule.applies_to_file(Path::new("main.py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_matches() {
|
||||
let rule = Rule {
|
||||
name: "Test".to_string(),
|
||||
pattern: Some("async".to_string()),
|
||||
suggestion: "Test".to_string(),
|
||||
file_extensions: vec![],
|
||||
severity: "info".to_string(),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
assert!(rule.matches("async fn test() {}"));
|
||||
assert!(!rule.matches("fn test() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_validation() {
|
||||
let rules = MiyabiRules::default();
|
||||
assert!(rules.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rules_loader_not_found() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let loader = RulesLoader::new(temp_dir.path().to_path_buf());
|
||||
let rules = loader.load().unwrap();
|
||||
assert!(rules.is_none());
|
||||
}
|
||||
}
|
||||
557
crates/miyabi-core/src/workflow.rs
Normal file
557
crates/miyabi-core/src/workflow.rs
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
//! Multi-Agent Workflow System
|
||||
//!
|
||||
//! Orchestrate multiple agents in sequence or parallel execution.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Workflow definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Workflow {
|
||||
/// Workflow name
|
||||
pub name: String,
|
||||
/// Description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Workflow steps
|
||||
pub steps: Vec<WorkflowStep>,
|
||||
/// Global variables
|
||||
#[serde(default)]
|
||||
pub variables: HashMap<String, String>,
|
||||
/// On failure behavior
|
||||
#[serde(default)]
|
||||
pub on_failure: FailurePolicy,
|
||||
}
|
||||
|
||||
/// A single step in the workflow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowStep {
|
||||
/// Step ID for reference
|
||||
pub id: String,
|
||||
/// Step name
|
||||
pub name: String,
|
||||
/// Agent spec to use
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
/// Task/prompt for the agent
|
||||
pub task: String,
|
||||
/// Dependencies (step IDs that must complete first)
|
||||
#[serde(default)]
|
||||
pub depends_on: Vec<String>,
|
||||
/// Condition for execution
|
||||
#[serde(default)]
|
||||
pub condition: Option<StepCondition>,
|
||||
/// Timeout in seconds
|
||||
#[serde(default = "default_step_timeout")]
|
||||
pub timeout: u64,
|
||||
/// Retry configuration
|
||||
#[serde(default)]
|
||||
pub retry: RetryConfig,
|
||||
/// Output variable name to store result
|
||||
#[serde(default)]
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
fn default_step_timeout() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
/// Condition for step execution
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum StepCondition {
|
||||
/// Always execute
|
||||
Always,
|
||||
/// Execute if previous step succeeded
|
||||
OnSuccess,
|
||||
/// Execute if previous step failed
|
||||
OnFailure,
|
||||
/// Execute if expression is true
|
||||
If { expression: String },
|
||||
}
|
||||
|
||||
impl Default for StepCondition {
|
||||
fn default() -> Self {
|
||||
StepCondition::Always
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry configuration for a step
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum retry attempts
|
||||
#[serde(default = "default_max_retries")]
|
||||
pub max_attempts: u32,
|
||||
/// Delay between retries in seconds
|
||||
#[serde(default = "default_retry_delay")]
|
||||
pub delay: u64,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 1,
|
||||
delay: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_retries() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_retry_delay() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Failure policy for workflow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailurePolicy {
|
||||
/// Stop workflow on first failure
|
||||
Stop,
|
||||
/// Continue with remaining steps
|
||||
Continue,
|
||||
/// Run cleanup steps
|
||||
Cleanup,
|
||||
}
|
||||
|
||||
impl Default for FailurePolicy {
|
||||
fn default() -> Self {
|
||||
FailurePolicy::Stop
|
||||
}
|
||||
}
|
||||
|
||||
/// Step execution result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepResult {
|
||||
pub step_id: String,
|
||||
pub status: StepStatus,
|
||||
pub output: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Step execution status
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Workflow execution context
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkflowContext {
|
||||
/// Variables available to steps
|
||||
pub variables: HashMap<String, String>,
|
||||
/// Results from completed steps
|
||||
pub results: HashMap<String, StepResult>,
|
||||
}
|
||||
|
||||
impl WorkflowContext {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_variables(mut self, vars: HashMap<String, String>) -> Self {
|
||||
self.variables = vars;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_variable(&mut self, key: &str, value: &str) {
|
||||
self.variables.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
pub fn get_variable(&self, key: &str) -> Option<&String> {
|
||||
self.variables.get(key)
|
||||
}
|
||||
|
||||
pub fn add_result(&mut self, result: StepResult) {
|
||||
self.results.insert(result.step_id.clone(), result);
|
||||
}
|
||||
|
||||
pub fn get_result(&self, step_id: &str) -> Option<&StepResult> {
|
||||
self.results.get(step_id)
|
||||
}
|
||||
|
||||
/// Expand variables in a string
|
||||
pub fn expand(&self, input: &str) -> String {
|
||||
let mut result = input.to_string();
|
||||
for (key, value) in &self.variables {
|
||||
result = result.replace(&format!("${{{}}}", key), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Workflow execution result
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowResult {
|
||||
pub workflow_name: String,
|
||||
pub status: WorkflowStatus,
|
||||
pub steps: Vec<StepResult>,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Workflow execution status
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
PartiallyCompleted,
|
||||
}
|
||||
|
||||
/// Workflow manager for loading and executing workflows
|
||||
pub struct WorkflowManager {
|
||||
workflows: HashMap<String, Workflow>,
|
||||
}
|
||||
|
||||
impl Default for WorkflowManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowManager {
|
||||
/// Create a new workflow manager
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
workflows: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a workflow from a YAML file
|
||||
pub fn load_workflow(&mut self, path: &PathBuf) -> Result<String> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let workflow: Workflow = serde_yaml::from_str(&content)?;
|
||||
let name = workflow.name.clone();
|
||||
self.workflows.insert(name.clone(), workflow);
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Load all workflows from a directory
|
||||
pub fn load_from_directory(&mut self, dir: &PathBuf) -> Result<Vec<String>> {
|
||||
let mut loaded = Vec::new();
|
||||
if dir.exists() && dir.is_dir() {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "yml" || e == "yaml").unwrap_or(false) {
|
||||
if let Ok(name) = self.load_workflow(&path) {
|
||||
loaded.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
/// Register a workflow directly
|
||||
pub fn register(&mut self, workflow: Workflow) {
|
||||
self.workflows.insert(workflow.name.clone(), workflow);
|
||||
}
|
||||
|
||||
/// Get a workflow by name
|
||||
pub fn get_workflow(&self, name: &str) -> Option<&Workflow> {
|
||||
self.workflows.get(name)
|
||||
}
|
||||
|
||||
/// List all registered workflows
|
||||
pub fn list_workflows(&self) -> Vec<&str> {
|
||||
self.workflows.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Execute a workflow
|
||||
pub async fn execute(&self, name: &str, initial_vars: HashMap<String, String>) -> Result<WorkflowResult> {
|
||||
let workflow = self
|
||||
.workflows
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Workflow not found: {}", name))?;
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut context = WorkflowContext::new().with_variables(workflow.variables.clone());
|
||||
|
||||
// Add initial variables
|
||||
for (key, value) in initial_vars {
|
||||
context.set_variable(&key, &value);
|
||||
}
|
||||
|
||||
let mut step_results = Vec::new();
|
||||
let mut all_succeeded = true;
|
||||
|
||||
// Build dependency graph and execute
|
||||
let execution_order = self.build_execution_order(workflow)?;
|
||||
|
||||
for step_id in execution_order {
|
||||
let step = workflow
|
||||
.steps
|
||||
.iter()
|
||||
.find(|s| s.id == step_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?;
|
||||
|
||||
// Check dependencies
|
||||
let deps_satisfied = step.depends_on.iter().all(|dep_id| {
|
||||
context
|
||||
.get_result(dep_id)
|
||||
.map(|r| r.status == StepStatus::Completed)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if !deps_satisfied {
|
||||
let result = StepResult {
|
||||
step_id: step.id.clone(),
|
||||
status: StepStatus::Skipped,
|
||||
output: None,
|
||||
error: Some("Dependencies not satisfied".to_string()),
|
||||
duration_ms: 0,
|
||||
};
|
||||
step_results.push(result.clone());
|
||||
context.add_result(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check condition
|
||||
if !self.evaluate_condition(&step.condition, &context) {
|
||||
let result = StepResult {
|
||||
step_id: step.id.clone(),
|
||||
status: StepStatus::Skipped,
|
||||
output: None,
|
||||
error: Some("Condition not met".to_string()),
|
||||
duration_ms: 0,
|
||||
};
|
||||
step_results.push(result.clone());
|
||||
context.add_result(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute step
|
||||
let step_start = std::time::Instant::now();
|
||||
let expanded_task = context.expand(&step.task);
|
||||
|
||||
// Placeholder for actual agent execution
|
||||
let result = StepResult {
|
||||
step_id: step.id.clone(),
|
||||
status: StepStatus::Completed,
|
||||
output: Some(format!("Executed: {}", expanded_task)),
|
||||
error: None,
|
||||
duration_ms: step_start.elapsed().as_millis() as u64,
|
||||
};
|
||||
|
||||
// Store output variable if specified
|
||||
if let Some(output_var) = &step.output {
|
||||
if let Some(output) = &result.output {
|
||||
context.set_variable(output_var, output);
|
||||
}
|
||||
}
|
||||
|
||||
if result.status == StepStatus::Failed {
|
||||
all_succeeded = false;
|
||||
if matches!(workflow.on_failure, FailurePolicy::Stop) {
|
||||
step_results.push(result.clone());
|
||||
context.add_result(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
step_results.push(result.clone());
|
||||
context.add_result(result);
|
||||
}
|
||||
|
||||
let status = if all_succeeded {
|
||||
WorkflowStatus::Completed
|
||||
} else if step_results.iter().any(|r| r.status == StepStatus::Completed) {
|
||||
WorkflowStatus::PartiallyCompleted
|
||||
} else {
|
||||
WorkflowStatus::Failed
|
||||
};
|
||||
|
||||
Ok(WorkflowResult {
|
||||
workflow_name: name.to_string(),
|
||||
status,
|
||||
steps: step_results,
|
||||
duration_ms: start_time.elapsed().as_millis() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build execution order from dependencies (topological sort)
|
||||
fn build_execution_order(&self, workflow: &Workflow) -> Result<Vec<String>> {
|
||||
let mut order = Vec::new();
|
||||
let mut visited = HashMap::new();
|
||||
let mut temp_visited = HashMap::new();
|
||||
|
||||
for step in &workflow.steps {
|
||||
if !visited.contains_key(&step.id) {
|
||||
self.visit_step(workflow, &step.id, &mut visited, &mut temp_visited, &mut order)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
fn visit_step(
|
||||
&self,
|
||||
workflow: &Workflow,
|
||||
step_id: &str,
|
||||
visited: &mut HashMap<String, bool>,
|
||||
temp_visited: &mut HashMap<String, bool>,
|
||||
order: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
if temp_visited.get(step_id).copied().unwrap_or(false) {
|
||||
return Err(anyhow::anyhow!("Circular dependency detected at step: {}", step_id));
|
||||
}
|
||||
|
||||
if visited.get(step_id).copied().unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
temp_visited.insert(step_id.to_string(), true);
|
||||
|
||||
let step = workflow
|
||||
.steps
|
||||
.iter()
|
||||
.find(|s| s.id == step_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Step not found: {}", step_id))?;
|
||||
|
||||
for dep_id in &step.depends_on {
|
||||
self.visit_step(workflow, dep_id, visited, temp_visited, order)?;
|
||||
}
|
||||
|
||||
temp_visited.insert(step_id.to_string(), false);
|
||||
visited.insert(step_id.to_string(), true);
|
||||
order.push(step_id.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate a step condition
|
||||
fn evaluate_condition(&self, condition: &Option<StepCondition>, context: &WorkflowContext) -> bool {
|
||||
match condition {
|
||||
None | Some(StepCondition::Always) => true,
|
||||
Some(StepCondition::OnSuccess) => context
|
||||
.results
|
||||
.values()
|
||||
.last()
|
||||
.map(|r| r.status == StepStatus::Completed)
|
||||
.unwrap_or(true),
|
||||
Some(StepCondition::OnFailure) => context
|
||||
.results
|
||||
.values()
|
||||
.last()
|
||||
.map(|r| r.status == StepStatus::Failed)
|
||||
.unwrap_or(false),
|
||||
Some(StepCondition::If { expression }) => {
|
||||
// Simple expression evaluation (variable == value)
|
||||
let expanded = context.expand(expression);
|
||||
!expanded.is_empty() && expanded != "false" && expanded != "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_workflow_context() {
|
||||
let mut context = WorkflowContext::new();
|
||||
context.set_variable("name", "test");
|
||||
context.set_variable("version", "1.0");
|
||||
|
||||
assert_eq!(context.get_variable("name"), Some(&"test".to_string()));
|
||||
assert_eq!(
|
||||
context.expand("Project: ${name} v${version}"),
|
||||
"Project: test v1.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workflow_serialization() {
|
||||
let workflow = Workflow {
|
||||
name: "test-workflow".to_string(),
|
||||
description: "Test".to_string(),
|
||||
steps: vec![
|
||||
WorkflowStep {
|
||||
id: "step1".to_string(),
|
||||
name: "First Step".to_string(),
|
||||
agent: Some("code-reviewer".to_string()),
|
||||
task: "Review code".to_string(),
|
||||
depends_on: vec![],
|
||||
condition: None,
|
||||
timeout: 300,
|
||||
retry: RetryConfig::default(),
|
||||
output: Some("review_result".to_string()),
|
||||
},
|
||||
],
|
||||
variables: HashMap::new(),
|
||||
on_failure: FailurePolicy::Stop,
|
||||
};
|
||||
|
||||
let yaml = serde_yaml::to_string(&workflow).unwrap();
|
||||
assert!(yaml.contains("test-workflow"));
|
||||
assert!(yaml.contains("step1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workflow_manager() {
|
||||
let mut manager = WorkflowManager::new();
|
||||
let workflow = Workflow {
|
||||
name: "test".to_string(),
|
||||
description: "Test workflow".to_string(),
|
||||
steps: vec![],
|
||||
variables: HashMap::new(),
|
||||
on_failure: FailurePolicy::Stop,
|
||||
};
|
||||
|
||||
manager.register(workflow);
|
||||
assert!(manager.get_workflow("test").is_some());
|
||||
assert_eq!(manager.list_workflows(), vec!["test"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workflow_execution() {
|
||||
let mut manager = WorkflowManager::new();
|
||||
let workflow = Workflow {
|
||||
name: "simple".to_string(),
|
||||
description: "Simple workflow".to_string(),
|
||||
steps: vec![
|
||||
WorkflowStep {
|
||||
id: "step1".to_string(),
|
||||
name: "Step 1".to_string(),
|
||||
agent: None,
|
||||
task: "Task 1".to_string(),
|
||||
depends_on: vec![],
|
||||
condition: None,
|
||||
timeout: 30,
|
||||
retry: RetryConfig::default(),
|
||||
output: None,
|
||||
},
|
||||
WorkflowStep {
|
||||
id: "step2".to_string(),
|
||||
name: "Step 2".to_string(),
|
||||
agent: None,
|
||||
task: "Task 2 depends on ${result}".to_string(),
|
||||
depends_on: vec!["step1".to_string()],
|
||||
condition: None,
|
||||
timeout: 30,
|
||||
retry: RetryConfig::default(),
|
||||
output: None,
|
||||
},
|
||||
],
|
||||
variables: HashMap::new(),
|
||||
on_failure: FailurePolicy::Stop,
|
||||
};
|
||||
|
||||
manager.register(workflow);
|
||||
let result = manager.execute("simple", HashMap::new()).await.unwrap();
|
||||
|
||||
assert_eq!(result.workflow_name, "simple");
|
||||
assert_eq!(result.status, WorkflowStatus::Completed);
|
||||
assert_eq!(result.steps.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,4 +50,5 @@ once_cell = { workspace = true }
|
|||
uuid = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["clipboard"]
|
||||
clipboard = ["arboard"]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use crate::history_cell::{
|
|||
use crate::views::{MainView, ViewAction};
|
||||
use miyabi_core::anthropic::{AnthropicClient, ContentBlock, Message, StreamEvent};
|
||||
use miyabi_core::config::Config;
|
||||
use miyabi_core::rules::{MiyabiRules, RulesLoader};
|
||||
use miyabi_core::session::{Session, SessionStorage};
|
||||
use miyabi_core::tool::ToolRegistry;
|
||||
use miyabi_core::tools::create_standard_tool_registry;
|
||||
|
|
@ -72,6 +73,8 @@ pub struct App {
|
|||
max_tokens: u32,
|
||||
/// Whether to request extended thinking
|
||||
thinking: bool,
|
||||
/// Project rules loaded from .miyabirules
|
||||
rules: Option<MiyabiRules>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -135,6 +138,27 @@ impl App {
|
|||
let max_tokens = config.api.max_tokens;
|
||||
let thinking = config.api.thinking;
|
||||
|
||||
// Load project rules from .miyabirules
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let loader = RulesLoader::new(cwd);
|
||||
let rules = match loader.load() {
|
||||
Ok(Some(rules)) => {
|
||||
let count = rules.rules.len();
|
||||
if count > 0 {
|
||||
view.notifications.info(
|
||||
"Rules Loaded",
|
||||
format!("{} project rules active", count),
|
||||
);
|
||||
}
|
||||
Some(rules)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
view.notifications.error("Rules Error", format!("Failed to load: {}", e));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
should_quit: false,
|
||||
view,
|
||||
|
|
@ -151,9 +175,15 @@ impl App {
|
|||
model_name,
|
||||
max_tokens,
|
||||
thinking,
|
||||
rules,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the loaded project rules
|
||||
pub fn get_rules(&self) -> Option<&MiyabiRules> {
|
||||
self.rules.as_ref()
|
||||
}
|
||||
|
||||
/// Toggle agent mode
|
||||
pub fn toggle_agent_mode(&mut self) {
|
||||
self.agent_mode = !self.agent_mode;
|
||||
|
|
@ -241,18 +271,106 @@ impl App {
|
|||
self.view.notifications.panel.push(notification);
|
||||
}
|
||||
ViewAction::Copy(text) => {
|
||||
// TODO: Implement clipboard support
|
||||
self.view
|
||||
.notifications
|
||||
.info("Copied", format!("{} chars", text.len()));
|
||||
#[cfg(feature = "clipboard")]
|
||||
{
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
if let Err(e) = clipboard.set_text(&text) {
|
||||
self.view
|
||||
.notifications
|
||||
.error("Clipboard Error", e.to_string());
|
||||
} else {
|
||||
self.view
|
||||
.notifications
|
||||
.info("Copied", format!("{} chars", text.len()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.view
|
||||
.notifications
|
||||
.error("Clipboard Error", e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "clipboard"))]
|
||||
{
|
||||
self.view
|
||||
.notifications
|
||||
.warning("Clipboard", "Clipboard feature not enabled");
|
||||
let _ = text; // Suppress unused warning
|
||||
}
|
||||
}
|
||||
ViewAction::OpenFile(path) => {
|
||||
// TODO: Implement file opening
|
||||
self.view.notifications.info("Open File", &path);
|
||||
// Open file with system default application
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = std::process::Command::new("open").arg(&path).spawn();
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(&path).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", &path])
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.view.notifications.info("Opened", &path);
|
||||
}
|
||||
Err(e) => {
|
||||
self.view
|
||||
.notifications
|
||||
.error("Open Error", e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewAction::ResumeSession(session_id) => {
|
||||
// TODO: Implement session resume
|
||||
self.view.notifications.info("Resume Session", &session_id);
|
||||
match self.load_session(&session_id) {
|
||||
Ok(()) => {
|
||||
// Rebuild UI from loaded conversation
|
||||
self.view.history.clear();
|
||||
let timestamp =
|
||||
chrono::Local::now().format("%H:%M").to_string();
|
||||
|
||||
// Replay conversation into UI
|
||||
for msg in &self.conversation {
|
||||
match msg.role {
|
||||
miyabi_core::anthropic::Role::User => {
|
||||
if let Some(ContentBlock::Text { text }) =
|
||||
msg.content.first()
|
||||
{
|
||||
self.view.push_message(Box::new(
|
||||
UserMessageCell {
|
||||
content: text.clone(),
|
||||
timestamp: timestamp.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
miyabi_core::anthropic::Role::Assistant => {
|
||||
if let Some(ContentBlock::Text { text }) =
|
||||
msg.content.first()
|
||||
{
|
||||
let mut cell = AssistantMessageCell::new(
|
||||
timestamp.clone(),
|
||||
);
|
||||
cell.set_content(text);
|
||||
cell.set_complete();
|
||||
self.view.push_message(Box::new(cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.view.notifications.success(
|
||||
"Session Resumed",
|
||||
format!("{} messages loaded", self.conversation.len()),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.view
|
||||
.notifications
|
||||
.error("Resume Error", e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewAction::ToggleAgentMode => {
|
||||
self.toggle_agent_mode();
|
||||
|
|
@ -815,7 +933,7 @@ impl App {
|
|||
match client
|
||||
.message_stream(
|
||||
self.conversation.clone(),
|
||||
Some("You are a helpful AI assistant. Be concise and clear.".to_string()),
|
||||
self.system_prompt.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue