mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247445f819 | ||
|
|
14254a699f | ||
|
|
2039442885 | ||
|
|
e796b838fa | ||
|
|
54ce7434fb | ||
|
|
e9e0374c85 | ||
|
|
5582a0dfbf | ||
|
|
1cf08a4b42 | ||
|
|
d55b302b39 | ||
|
|
517be42ccc | ||
|
|
09198c68ab | ||
|
|
115b7f38fe | ||
|
|
bb228f6315 | ||
|
|
45f547c711 | ||
|
|
23de75d75b | ||
|
|
ced83d5b4d | ||
|
|
810ba58fd2 | ||
|
|
202665a55c | ||
|
|
a35db4d32d | ||
|
|
e6725eb6d9 | ||
|
|
91e27b7395 | ||
|
|
642c320b13 | ||
|
|
6831a54793 | ||
|
|
6bcc168ec5 | ||
|
|
bf3b8b339f | ||
|
|
2cdd04a359 | ||
|
|
9b729795fb | ||
|
|
a351711312 | ||
|
|
f34a80191e | ||
|
|
9d156411fc | ||
|
|
8439293df3 | ||
|
|
8be390afab | ||
|
|
4d0fe7d37e | ||
|
|
cb987321a9 | ||
|
|
a93c7ed893 | ||
|
|
3c54e692a5 | ||
|
|
4f8fd4ad5f | ||
|
|
7fcc2279cc | ||
|
|
0ca05e3de3 |
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
description: Scaffold a new SSE event end-to-end (Rust backend to web frontend)
|
||||||
|
allowed-tools: Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*)
|
||||||
|
argument-hint: <event_name> [description]
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new SSE event called `$ARGUMENTS` to the IronClaw web gateway. This involves changes across 5 files in a specific order. Follow each step exactly.
|
||||||
|
|
||||||
|
## Step 1: Add `StatusUpdate` variant
|
||||||
|
|
||||||
|
**File**: `src/channels/channel.rs`
|
||||||
|
|
||||||
|
Find the `StatusUpdate` enum and add a new variant. Use the event name in PascalCase. Include any fields the event needs as named fields (not a generic String).
|
||||||
|
|
||||||
|
Example for reference (existing variants):
|
||||||
|
```rust
|
||||||
|
pub enum StatusUpdate {
|
||||||
|
Thinking(String),
|
||||||
|
ToolStarted { name: String },
|
||||||
|
ToolCompleted { name: String, success: bool },
|
||||||
|
Status(String),
|
||||||
|
ApprovalNeeded {
|
||||||
|
request_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
description: String,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Map to `SseEvent` in web channel
|
||||||
|
|
||||||
|
**File**: `src/channels/web/mod.rs`
|
||||||
|
|
||||||
|
Find the `send_status` method in the `Channel` impl for `WebChannel`. Add a match arm for the new `StatusUpdate` variant that maps it to an `SseEvent`. The SSE event name should be snake_case.
|
||||||
|
|
||||||
|
Look at existing match arms for the pattern. The event data is serialized as JSON.
|
||||||
|
|
||||||
|
## Step 3: Add types if needed
|
||||||
|
|
||||||
|
**File**: `src/channels/web/types.rs`
|
||||||
|
|
||||||
|
If the event carries structured data beyond a simple string, add a serializable DTO struct here. Use `#[derive(Debug, Clone, Serialize, Deserialize)]`. Follow the existing patterns in the file.
|
||||||
|
|
||||||
|
## Step 4: Add frontend handler
|
||||||
|
|
||||||
|
**File**: `src/channels/web/static/app.js`
|
||||||
|
|
||||||
|
In the `connectSSE()` function, add a new `eventSource.addEventListener()` for the snake_case event name. Parse the JSON data and call a handler function.
|
||||||
|
|
||||||
|
Create the handler function that updates the DOM. Follow existing patterns:
|
||||||
|
- `showApproval(data)` for complex card-style UI
|
||||||
|
- `addMessage(role, content)` for simple text
|
||||||
|
- `setStatus(text, spinning)` for status bar updates
|
||||||
|
|
||||||
|
## Step 5: Add CSS if needed
|
||||||
|
|
||||||
|
**File**: `src/channels/web/static/style.css`
|
||||||
|
|
||||||
|
If the event needs custom UI (cards, badges, etc.), add styles. Follow the existing naming conventions (`.approval-card`, `.log-entry`, etc.).
|
||||||
|
|
||||||
|
## Step 6: Send the event from Rust
|
||||||
|
|
||||||
|
Identify where in the backend this event should be triggered. Common locations:
|
||||||
|
- `src/agent/agent_loop.rs` - During message processing or tool execution
|
||||||
|
- `src/agent/worker.rs` - During job execution
|
||||||
|
- `src/agent/heartbeat.rs` - During periodic execution
|
||||||
|
|
||||||
|
Use the existing pattern:
|
||||||
|
```rust
|
||||||
|
let _ = self.channels.send_status(
|
||||||
|
&message.channel,
|
||||||
|
StatusUpdate::YourNewVariant { ... },
|
||||||
|
&message.metadata,
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Quality gate
|
||||||
|
|
||||||
|
Run `cargo fmt` and `cargo clippy --all --benches --tests --examples --all-features` to verify the changes compile cleanly.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before finishing, verify:
|
||||||
|
- [ ] `StatusUpdate` variant added in `channel.rs`
|
||||||
|
- [ ] Match arm added in `web/mod.rs` `send_status`
|
||||||
|
- [ ] DTO added in `types.rs` (if needed)
|
||||||
|
- [ ] `addEventListener` added in `app.js`
|
||||||
|
- [ ] Handler function created in `app.js`
|
||||||
|
- [ ] CSS styles added (if needed)
|
||||||
|
- [ ] Event sent from appropriate backend location
|
||||||
|
- [ ] `cargo fmt` clean
|
||||||
|
- [ ] `cargo clippy` clean
|
||||||
|
- [ ] Non-web channels unaffected (they ignore unknown StatusUpdate variants)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
---
|
||||||
|
description: Scaffold a new tool (WASM or built-in Rust) with all boilerplate wired up
|
||||||
|
allowed-tools: Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo component:*), Bash(ls:*), Bash(mkdir:*)
|
||||||
|
argument-hint: <tool_name> [description]
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
Scaffold a new tool called `$ARGUMENTS` for the IronClaw agent. First, determine the tool type and then follow the appropriate path.
|
||||||
|
|
||||||
|
## Step 0: Determine tool type
|
||||||
|
|
||||||
|
Ask the user which type of tool to create:
|
||||||
|
|
||||||
|
- **WASM tool** (recommended) - Sandboxed, dynamically loadable, external API integrations. Lives in `tools-src/<name>/`. This is the right choice for anything that talks to an external service (Notion, GitHub, Discord, etc.).
|
||||||
|
- **Built-in tool** - Compiled into the main binary. Only for core agent infrastructure (e.g., memory, file ops, shell). Lives in `src/tools/builtin/<name>.rs`.
|
||||||
|
|
||||||
|
If the description clearly implies an external service integration, default to WASM. If it's a core agent capability, default to built-in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path A: WASM Tool
|
||||||
|
|
||||||
|
### A1: Create directory structure
|
||||||
|
|
||||||
|
Create `tools-src/<name>/` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
tools-src/<name>/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── <name>-tool.capabilities.json
|
||||||
|
└── src/
|
||||||
|
├── lib.rs
|
||||||
|
├── types.rs
|
||||||
|
└── api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
### A2: Write `Cargo.toml`
|
||||||
|
|
||||||
|
Follow this exact pattern (adjust name and description):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "<name>-tool"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "<Description> tool for IronClaw (WASM component)"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wit-bindgen = "=0.36"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
codegen-units = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### A3: Write `<name>-tool.capabilities.json`
|
||||||
|
|
||||||
|
Declare the tool's security requirements. Determine what APIs it needs and create the allowlist. Reference `tools-src/slack/slack-tool.capabilities.json` for the format.
|
||||||
|
|
||||||
|
Key sections to include:
|
||||||
|
- `http.allowlist` - API endpoints (host, path_prefix, methods)
|
||||||
|
- `http.credentials` - Secret injection config (secret_name, location type: bearer/header/query)
|
||||||
|
- `http.rate_limit` - requests_per_minute, requests_per_hour
|
||||||
|
- `http.timeout_secs`
|
||||||
|
- `secrets.allowed_names` - Which secrets the tool can check existence of
|
||||||
|
- `auth` - Authentication setup (OAuth or manual token entry)
|
||||||
|
|
||||||
|
If the tool needs OAuth, include:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "<service>_token",
|
||||||
|
"display_name": "<Service>",
|
||||||
|
"oauth": {
|
||||||
|
"authorization_url": "https://...",
|
||||||
|
"token_url": "https://...",
|
||||||
|
"client_id_env": "<SERVICE>_OAUTH_CLIENT_ID",
|
||||||
|
"client_secret_env": "<SERVICE>_OAUTH_CLIENT_SECRET",
|
||||||
|
"scopes": [],
|
||||||
|
"use_pkce": false
|
||||||
|
},
|
||||||
|
"env_var": "<SERVICE>_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no OAuth, include manual setup instructions:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "<service>_api_key",
|
||||||
|
"display_name": "<Service>",
|
||||||
|
"instructions": "Get your API key from <url>",
|
||||||
|
"setup_url": "https://...",
|
||||||
|
"token_hint": "Starts with '<prefix>'",
|
||||||
|
"env_var": "<SERVICE>_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A4: Write `src/types.rs`
|
||||||
|
|
||||||
|
Define the action enum using serde's tagged enum pattern:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
pub enum <Name>Action {
|
||||||
|
// Add variants based on the tool's capabilities.
|
||||||
|
// Each variant maps to one API operation.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add result structs with `#[derive(Debug, Serialize)]`. Use `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields.
|
||||||
|
|
||||||
|
### A5: Write `src/api.rs`
|
||||||
|
|
||||||
|
Implement the API calls using the host HTTP capability:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::near::agent::host;
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
const API_BASE: &str = "https://api.example.com";
|
||||||
|
|
||||||
|
fn api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result<String, String> {
|
||||||
|
let url = format!("{}/{}", API_BASE, endpoint);
|
||||||
|
let headers = if body.is_some() {
|
||||||
|
r#"{"Content-Type": "application/json"}"#
|
||||||
|
} else {
|
||||||
|
"{}"
|
||||||
|
};
|
||||||
|
let body_bytes = body.map(|b| b.as_bytes().to_vec());
|
||||||
|
|
||||||
|
host::log(host::LogLevel::Debug, &format!("API: {} {}", method, endpoint));
|
||||||
|
|
||||||
|
let response = host::http_request(method, &url, headers, body_bytes.as_deref())?;
|
||||||
|
|
||||||
|
if response.status < 200 || response.status >= 300 {
|
||||||
|
return Err(format!(
|
||||||
|
"API returned status {}: {}",
|
||||||
|
response.status,
|
||||||
|
String::from_utf8_lossy(&response.body)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add one function per action variant that calls `api_call` and parses the response into the result structs.
|
||||||
|
|
||||||
|
### A6: Write `src/lib.rs`
|
||||||
|
|
||||||
|
Wire everything together:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
mod api;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use types::<Name>Action;
|
||||||
|
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
world: "sandboxed-tool",
|
||||||
|
path: "../../wit/tool.wit",
|
||||||
|
});
|
||||||
|
|
||||||
|
struct <Name>Tool;
|
||||||
|
|
||||||
|
impl exports::near::agent::tool::Guest for <Name>Tool {
|
||||||
|
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||||
|
match execute_inner(&req.params) {
|
||||||
|
Ok(result) => exports::near::agent::tool::Response {
|
||||||
|
output: Some(result),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => exports::near::agent::tool::Response {
|
||||||
|
output: None,
|
||||||
|
error: Some(e),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema() -> String {
|
||||||
|
// Return JSON Schema matching the action enum
|
||||||
|
todo!("Fill in JSON Schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description() -> String {
|
||||||
|
"<Description>".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_inner(params: &str) -> Result<String, String> {
|
||||||
|
// Check required secrets
|
||||||
|
if !crate::near::agent::host::secret_exists("<secret_name>") {
|
||||||
|
return Err("<Secret> not configured. Please add the '<secret_name>' secret.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let action: <Name>Action =
|
||||||
|
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
|
||||||
|
|
||||||
|
crate::near::agent::host::log(
|
||||||
|
crate::near::agent::host::LogLevel::Info,
|
||||||
|
&format!("Executing action: {:?}", action),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = match action {
|
||||||
|
// Dispatch to api:: functions for each variant
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
export!(<Name>Tool);
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in the `schema()` with a proper JSON Schema using `oneOf` for each action variant. Reference `tools-src/slack/src/lib.rs` for the exact pattern.
|
||||||
|
|
||||||
|
### A7: Verify
|
||||||
|
|
||||||
|
Run `cargo fmt` in the tool directory. If `cargo-component` is available, run `cargo component build --release` to verify the WASM compiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path B: Built-in Tool
|
||||||
|
|
||||||
|
### B1: Create the tool file
|
||||||
|
|
||||||
|
Create `src/tools/builtin/<name>.rs` implementing the `Tool` trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::context::JobContext;
|
||||||
|
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
|
pub struct <Name>Tool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for <Name>Tool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"<snake_case_name>"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"<Description>"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
// Define parameters here
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Extract and validate parameters
|
||||||
|
// Do the work
|
||||||
|
// Return result
|
||||||
|
|
||||||
|
Ok(ToolOutput::text("result", start.elapsed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false // Set true if tool processes external data
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self) -> bool {
|
||||||
|
false // Set true if tool is destructive or contacts external services
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the tool needs shared state (HTTP client, config), add a struct field and `new()` constructor:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct <Name>Tool {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl <Name>Tool {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B2: Update `src/tools/builtin/mod.rs`
|
||||||
|
|
||||||
|
Add the module declaration and pub use, keeping alphabetical order:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
mod <name>;
|
||||||
|
pub use <name>::<Name>Tool;
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3: Update `src/tools/registry.rs`
|
||||||
|
|
||||||
|
Add the import to the `use crate::tools::builtin::{...}` block and register the tool in the appropriate registration method:
|
||||||
|
|
||||||
|
- If it's a core tool: add to `register_builtin_tools()`
|
||||||
|
- If it needs shared state (workspace, context_manager, etc.): create a new `register_<category>_tools()` method or add to an existing one
|
||||||
|
- Wire the new registration call in `src/main.rs` if a new method was created
|
||||||
|
|
||||||
|
### B4: Add tests
|
||||||
|
|
||||||
|
Add a `mod tests {}` block at the bottom of the tool file:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::context::JobContext;
|
||||||
|
|
||||||
|
fn test_context() -> JobContext {
|
||||||
|
JobContext::test_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_<name>_basic() {
|
||||||
|
let tool = <Name>Tool::new();
|
||||||
|
let params = serde_json::json!({ /* test params */ });
|
||||||
|
let result = tool.execute(params, &test_context()).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_<name>_missing_params() {
|
||||||
|
let tool = <Name>Tool::new();
|
||||||
|
let params = serde_json::json!({});
|
||||||
|
let result = tool.execute(params, &test_context()).await;
|
||||||
|
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B5: Quality gate
|
||||||
|
|
||||||
|
Run `cargo fmt` and `cargo clippy --all --benches --tests --examples --all-features`. Fix any issues.
|
||||||
|
|
||||||
|
Run the new tests: `cargo test --lib -- builtin::<name>::tests`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before finishing, verify:
|
||||||
|
- [ ] Tool type chosen (WASM or built-in) and confirmed with user
|
||||||
|
- [ ] All files created with correct structure
|
||||||
|
- [ ] For WASM: capabilities.json declares all needed permissions (HTTP, secrets, auth)
|
||||||
|
- [ ] For WASM: JSON Schema in `schema()` matches the action enum variants
|
||||||
|
- [ ] For built-in: mod.rs updated with module + pub use
|
||||||
|
- [ ] For built-in: registry.rs imports and registers the tool
|
||||||
|
- [ ] For built-in: tests added and passing
|
||||||
|
- [ ] `cargo fmt` clean
|
||||||
|
- [ ] `cargo clippy` clean (for built-in) or `cargo component build` clean (for WASM)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
description: Run the full Rust quality gate (fmt, clippy, tests) before shipping changes
|
||||||
|
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
Run the IronClaw shipping checklist. This is the mandatory quality gate before any change is considered done.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Format**: Run `cargo fmt` to normalize formatting.
|
||||||
|
|
||||||
|
2. **Lint**: Run `cargo clippy --all --benches --tests --examples --all-features` and report any warnings or errors. ALL clippy warnings must be resolved before proceeding.
|
||||||
|
|
||||||
|
3. **Test**: Run `cargo test --lib` to execute the full library test suite. Report the total pass/fail count.
|
||||||
|
|
||||||
|
4. **Summary**: Report results for all three steps. If any step failed, list the specific errors and suggest fixes. Do NOT proceed past a failing step.
|
||||||
|
|
||||||
|
If `$ARGUMENTS` is provided, treat it as a specific test filter and run `cargo test --lib -- $ARGUMENTS` instead of the full suite in step 3.
|
||||||
|
|
||||||
|
The expected outcome for a clean ship is:
|
||||||
|
- `cargo fmt` produces no changes
|
||||||
|
- `cargo clippy` has zero warnings
|
||||||
|
- All tests pass
|
||||||
|
|
||||||
|
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
description: Trace a data flow or bug through the IronClaw codebase end-to-end
|
||||||
|
allowed-tools: Read, Glob, Grep, Bash(cargo test:*)
|
||||||
|
argument-hint: <symptom or feature name>
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
Trace the flow of `$ARGUMENTS` through the IronClaw codebase. Your job is to map every file and function involved, identify where data transforms or could break, and report the full chain.
|
||||||
|
|
||||||
|
## Architecture Reference
|
||||||
|
|
||||||
|
IronClaw has three main data flow paths. Identify which one(s) are relevant and trace through them:
|
||||||
|
|
||||||
|
### Message Flow (user input to LLM response)
|
||||||
|
```
|
||||||
|
Channel (cli/web/wasm) → IncomingMessage
|
||||||
|
→ Agent::run() message loop (agent_loop.rs)
|
||||||
|
→ handle_message() dispatches by Submission type
|
||||||
|
→ SubmissionParser::parse() (submission.rs) classifies input
|
||||||
|
→ process_user_input() for new turns
|
||||||
|
→ process_approval() for tool approval responses
|
||||||
|
→ handle_command() for /commands
|
||||||
|
→ run_agentic_loop() iterates LLM calls
|
||||||
|
→ Reasoning::respond_with_tools() (reasoning.rs)
|
||||||
|
→ LlmProvider::complete_with_tools() (nearai_chat.rs or nearai.rs)
|
||||||
|
→ Tool execution with approval gating
|
||||||
|
→ Context message accumulation
|
||||||
|
→ Response flows back through Channel::send_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSE Event Flow (backend status to web UI)
|
||||||
|
```
|
||||||
|
StatusUpdate variant (channel.rs)
|
||||||
|
→ Channel::send_status() trait method
|
||||||
|
→ WebChannel::send_status() (web/mod.rs) maps to SseEvent
|
||||||
|
→ broadcast via tokio::broadcast channel
|
||||||
|
→ SSE endpoint streams events (web/server.rs)
|
||||||
|
→ Browser EventSource listener (app.js)
|
||||||
|
→ DOM update function
|
||||||
|
→ CSS styling (style.css)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Flow (tool definition to execution)
|
||||||
|
```
|
||||||
|
Tool trait impl (tools/builtin/*.rs or tools/mcp/client.rs or tools/wasm/wrapper.rs)
|
||||||
|
→ ToolRegistry::register() (tools/registry.rs)
|
||||||
|
→ tool_definitions() builds Vec<ToolDefinition> for LLM
|
||||||
|
→ ToolDefinition { name, description, parameters } (llm/provider.rs)
|
||||||
|
→ Serialized to ChatCompletionTool (nearai_chat.rs)
|
||||||
|
→ LLM returns ToolCall { id, name, arguments }
|
||||||
|
→ agent_loop.rs executes via execute_chat_tool()
|
||||||
|
→ Safety layer sanitizes output
|
||||||
|
→ Result added as ChatMessage::tool_result()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tracing Instructions
|
||||||
|
|
||||||
|
1. **Read** each file in the relevant flow path, focusing on the functions that handle the data.
|
||||||
|
2. **Identify transforms**: Where does the data change shape? (e.g., `McpTool.input_schema` → `ToolDefinition.parameters` → `ChatCompletionTool.function.parameters`)
|
||||||
|
3. **Identify failure points**: Where could the data be lost, malformed, or misrouted?
|
||||||
|
4. **Report the chain**: List every file:line involved, what happens at each step, and where the issue (if any) is.
|
||||||
|
|
||||||
|
## Key Files Quick Reference
|
||||||
|
|
||||||
|
| Area | File | Key Functions |
|
||||||
|
|------|------|---------------|
|
||||||
|
| Message dispatch | `src/agent/agent_loop.rs` | `handle_message`, `process_user_input`, `process_approval`, `run_agentic_loop` |
|
||||||
|
| Input parsing | `src/agent/submission.rs` | `SubmissionParser::parse` |
|
||||||
|
| LLM reasoning | `src/llm/reasoning.rs` | `respond_with_tools`, `select_tools`, `plan` |
|
||||||
|
| Chat completions | `src/llm/nearai_chat.rs` | `complete_with_tools`, `From<ChatMessage>` |
|
||||||
|
| Responses API | `src/llm/nearai.rs` | `complete_with_tools`, `split_messages` |
|
||||||
|
| Channel trait | `src/channels/channel.rs` | `Channel`, `StatusUpdate`, `IncomingMessage` |
|
||||||
|
| Web gateway | `src/channels/web/mod.rs` | `send_status`, `send_response` |
|
||||||
|
| Web server | `src/channels/web/server.rs` | Route handlers, SSE endpoints |
|
||||||
|
| Web frontend | `src/channels/web/static/app.js` | SSE listeners, DOM builders |
|
||||||
|
| Tool registry | `src/tools/registry.rs` | `tool_definitions`, `get`, `register` |
|
||||||
|
| MCP tools | `src/tools/mcp/client.rs` | `McpToolWrapper`, `list_tools`, `call_tool` |
|
||||||
|
| MCP protocol | `src/tools/mcp/protocol.rs` | `McpTool`, `inputSchema` |
|
||||||
|
| Safety | `src/safety/sanitizer.rs` | `sanitize_tool_output`, `wrap_for_llm` |
|
||||||
|
| Session state | `src/agent/session.rs` | `ThreadState`, `Turn`, `PendingApproval` |
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Report your findings as:
|
||||||
|
|
||||||
|
1. **Flow path**: The specific chain of files and functions involved
|
||||||
|
2. **Data transforms**: How the data changes at each step
|
||||||
|
3. **Findings**: Any bugs, missing data, or suspicious patterns
|
||||||
|
4. **Recommendation**: What to fix or investigate further
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
!CLAUDE.md
|
||||||
|
node_modules/
|
||||||
|
tools-src/
|
||||||
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
|
DATABASE_URL=postgres://localhost/ironclaw
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_POOL_SIZE=10
|
||||||
|
|
||||||
# LLM Provider (NEAR AI)
|
# LLM Provider (NEAR AI)
|
||||||
# NEAR AI provides a unified interface to all models with user authentication
|
# NEAR AI provides a unified interface to all models with user authentication
|
||||||
# Session token is stored in ~/.near-agent/session.json and managed automatically.
|
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
|
||||||
# On first run, the agent will open a browser for OAuth authentication.
|
# On first run, the agent will open a browser for OAuth authentication.
|
||||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||||
NEARAI_AUTH_URL=https://private.near.ai
|
NEARAI_AUTH_URL=https://private.near.ai
|
||||||
# NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown
|
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
# CLI is always enabled
|
# CLI is always enabled
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Code Style
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
codestyle:
|
||||||
|
name: Code Style (fmt + clippy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
profile: minimal
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
- name: Check lints (cargo clippy)
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Release-plz
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
# Release unpublished packages.
|
||||||
|
release-plz-release:
|
||||||
|
if: ${{ github.repository_owner == 'nearai' }}
|
||||||
|
name: Release-plz release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- &checkout
|
||||||
|
name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- &install-rust
|
||||||
|
name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
# Generating a GitHub token, so that PRs and tags created by
|
||||||
|
# the release-plz-action can trigger actions workflows.
|
||||||
|
- name: Generate GitHub token
|
||||||
|
uses: actions/create-github-app-token@v2
|
||||||
|
id: generate-token
|
||||||
|
with:
|
||||||
|
# GitHub App ID secret name
|
||||||
|
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||||
|
# GitHub App private key secret name
|
||||||
|
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||||
|
- name: Run release-plz
|
||||||
|
uses: release-plz/[email protected]
|
||||||
|
with:
|
||||||
|
command: release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||||
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
# Create a PR with the new versions and changelog, preparing the next release.
|
||||||
|
release-plz-pr:
|
||||||
|
if: ${{ github.repository_owner == 'nearai' }}
|
||||||
|
name: Release-plz PR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
concurrency:
|
||||||
|
group: release-plz-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
steps:
|
||||||
|
- *checkout
|
||||||
|
- *install-rust
|
||||||
|
- name: Run release-plz
|
||||||
|
uses: release-plz/[email protected]
|
||||||
|
with:
|
||||||
|
command: release-pr
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||||
|
#
|
||||||
|
# Copyright 2022-2024, axodotdev
|
||||||
|
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||||
|
#
|
||||||
|
# CI that:
|
||||||
|
#
|
||||||
|
# * checks for a Git Tag that looks like a release
|
||||||
|
# * builds artifacts with dist (archives, installers, hashes)
|
||||||
|
# * uploads those artifacts to temporary workflow zip
|
||||||
|
# * on success, uploads the artifacts to a GitHub Release
|
||||||
|
#
|
||||||
|
# Note that the GitHub Release will be created with a generated
|
||||||
|
# title/body based on your changelogs.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
permissions:
|
||||||
|
"contents": "write"
|
||||||
|
|
||||||
|
# This task will run whenever you push a git tag that looks like a version
|
||||||
|
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||||
|
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||||
|
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||||
|
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||||
|
#
|
||||||
|
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||||
|
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||||
|
#
|
||||||
|
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||||
|
# (dist-able) packages in the workspace with that version (this mode is
|
||||||
|
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||||
|
# packages versioned/released in lockstep).
|
||||||
|
#
|
||||||
|
# If you push multiple tags at once, separate instances of this workflow will
|
||||||
|
# spin up, creating an independent announcement for each one. However, GitHub
|
||||||
|
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||||
|
# mistake.
|
||||||
|
#
|
||||||
|
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||||
|
# will be marked as a prerelease.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||||
|
plan:
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
outputs:
|
||||||
|
val: ${{ steps.plan.outputs.manifest }}
|
||||||
|
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||||
|
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||||
|
publishing: ${{ !github.event.pull_request }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install dist
|
||||||
|
# we specify bash to get pipefail; it guards against the `curl` command
|
||||||
|
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||||
|
shell: bash
|
||||||
|
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
|
||||||
|
- name: Cache dist
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/dist
|
||||||
|
# sure would be cool if github gave us proper conditionals...
|
||||||
|
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||||
|
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||||
|
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||||
|
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||||
|
- id: plan
|
||||||
|
run: |
|
||||||
|
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
cat plan-dist-manifest.json
|
||||||
|
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: "Upload dist-manifest.json"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifacts-plan-dist-manifest
|
||||||
|
path: plan-dist-manifest.json
|
||||||
|
|
||||||
|
# Build and packages all the platform-specific things
|
||||||
|
build-local-artifacts:
|
||||||
|
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||||
|
# Let the initial task tell us to not run (currently very blunt)
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
# Target platforms/runners are computed by dist in create-release.
|
||||||
|
# Each member of the matrix has the following arguments:
|
||||||
|
#
|
||||||
|
# - runner: the github runner
|
||||||
|
# - dist-args: cli flags to pass to dist
|
||||||
|
# - install-dist: expression to run to install dist on the runner
|
||||||
|
#
|
||||||
|
# Typically there will be:
|
||||||
|
# - 1 "global" task that builds universal installers
|
||||||
|
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
container: ${{ matrix.container && matrix.container.image || null }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||||
|
steps:
|
||||||
|
- name: enable windows longpaths
|
||||||
|
run: |
|
||||||
|
git config --global core.longpaths true
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install Rust non-interactively if not already installed
|
||||||
|
if: ${{ matrix.container }}
|
||||||
|
run: |
|
||||||
|
if ! command -v cargo > /dev/null 2>&1; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
- uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: ${{ join(matrix.targets, '-') }}
|
||||||
|
cache-provider: ${{ matrix.cache_provider }}
|
||||||
|
- name: Install dist
|
||||||
|
run: ${{ matrix.install_dist.run }}
|
||||||
|
# Get the dist-manifest
|
||||||
|
- name: Fetch local artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
${{ matrix.packages_install }}
|
||||||
|
- name: Build artifacts
|
||||||
|
run: |
|
||||||
|
# Actually do builds and make zips and whatnot
|
||||||
|
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
- id: cargo-dist
|
||||||
|
name: Post-build
|
||||||
|
# We force bash here just because github makes it really hard to get values up
|
||||||
|
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||||
|
# inconsistent syntax between shell and powershell.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Parse out what we just built and upload it to scratch storage
|
||||||
|
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
|
- name: "Upload artifacts"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||||
|
path: |
|
||||||
|
${{ steps.cargo-dist.outputs.paths }}
|
||||||
|
${{ env.BUILD_MANIFEST_NAME }}
|
||||||
|
|
||||||
|
# Build and package all the platform-agnostic(ish) things
|
||||||
|
build-global-artifacts:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- build-local-artifacts
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install cached dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/
|
||||||
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
|
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||||
|
- name: Fetch local artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- id: cargo-dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
|
||||||
|
# Parse out what we just built and upload it to scratch storage
|
||||||
|
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
|
- name: "Upload artifacts"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: artifacts-build-global
|
||||||
|
path: |
|
||||||
|
${{ steps.cargo-dist.outputs.paths }}
|
||||||
|
${{ env.BUILD_MANIFEST_NAME }}
|
||||||
|
# Determines if we should publish/announce
|
||||||
|
host:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- build-local-artifacts
|
||||||
|
- build-global-artifacts
|
||||||
|
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||||
|
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
outputs:
|
||||||
|
val: ${{ steps.host.outputs.manifest }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install cached dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/
|
||||||
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
|
# Fetch artifacts from scratch-storage
|
||||||
|
- name: Fetch artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- id: host
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||||
|
echo "artifacts uploaded and released successfully"
|
||||||
|
cat dist-manifest.json
|
||||||
|
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: "Upload dist-manifest.json"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
# Overwrite the previous copy
|
||||||
|
name: artifacts-dist-manifest
|
||||||
|
path: dist-manifest.json
|
||||||
|
# Create a GitHub Release while uploading all files to it
|
||||||
|
- name: "Download GitHub Artifacts"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
# Remove the granular manifests
|
||||||
|
rm -f artifacts/*-dist-manifest.json
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||||
|
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||||
|
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||||
|
RELEASE_COMMIT: "${{ github.sha }}"
|
||||||
|
run: |
|
||||||
|
# Write and read notes from a file to avoid quoting breaking things
|
||||||
|
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||||
|
|
||||||
|
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||||
|
|
||||||
|
publish-npm:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- host
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PLAN: ${{ needs.plan.outputs.val }}
|
||||||
|
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
|
||||||
|
steps:
|
||||||
|
- name: Fetch npm packages
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: npm/
|
||||||
|
merge-multiple: true
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
- run: |
|
||||||
|
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
|
||||||
|
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
|
||||||
|
npm publish --access public "./npm/${pkg}"
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
announce:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- host
|
||||||
|
- publish-npm
|
||||||
|
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||||
|
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||||
|
# "host" however must run to completion, no skipping allowed!
|
||||||
|
if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Run Tests
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
profile: minimal
|
||||||
|
- name: Run Tests
|
||||||
|
run: cargo test --all-features -- --nocapture
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# WASM build artifacts (loaded from disk, not bundled)
|
||||||
|
*.wasm
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Agent Rules
|
||||||
|
|
||||||
|
## Feature Parity Update Policy
|
||||||
|
|
||||||
|
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
|
||||||
|
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Added Installation instructions for the pre-built binaries
|
||||||
|
- Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support
|
||||||
|
|
||||||
|
## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Renamed the secrets in release-plz.yml to match the configuration
|
||||||
|
- Make sure that the binaries release CD it kicking in after release-plz
|
||||||
|
|
||||||
|
## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add multi-provider LLM support via rig-core adapter ([#36](https://github.com/nearai/ironclaw/pull/36))
|
||||||
|
- Sandbox jobs ([#4](https://github.com/nearai/ironclaw/pull/4))
|
||||||
|
- Add Google Suite & Telegram WASM tools ([#9](https://github.com/nearai/ironclaw/pull/9))
|
||||||
|
- Improve CLI ([#5](https://github.com/nearai/ironclaw/pull/5))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- resolve runtime panic in Linux keychain integration ([#32](https://github.com/nearai/ironclaw/pull/32))
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Skip release-plz on forks
|
||||||
|
- Upgraded release-plz CD pipeline
|
||||||
|
- Added CI/CD and release pipelines ([#45](https://github.com/nearai/ironclaw/pull/45))
|
||||||
|
- DM pairing + Telegram channel improvements ([#17](https://github.com/nearai/ironclaw/pull/17))
|
||||||
|
- Fixes build, adds missing sse event and correct command ([#11](https://github.com/nearai/ironclaw/pull/11))
|
||||||
|
- Codex/feature parity pr hook ([#6](https://github.com/nearai/ironclaw/pull/6))
|
||||||
|
- Add WebSocket gateway and control plane ([#8](https://github.com/nearai/ironclaw/pull/8))
|
||||||
|
- select bundled Telegram channel and auto-install ([#3](https://github.com/nearai/ironclaw/pull/3))
|
||||||
|
- Adding skills for reusable work
|
||||||
|
- Fix MCP tool calls, approval loop, shutdown, and improve web UI
|
||||||
|
- Add auth mode, fix MCP token handling, and parallelize startup loading
|
||||||
|
- Merge remote-tracking branch 'origin/main' into ui
|
||||||
|
- Adding web UI
|
||||||
|
- Rename `setup` CLI command to `onboard` for compatibility
|
||||||
|
- Add in-chat extension discovery, auth, and activation system
|
||||||
|
- Add Telegram typing indicator via WIT on-status callback
|
||||||
|
- Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings
|
||||||
|
- Add hosted MCP server support with OAuth 2.1 and token refresh
|
||||||
|
- Add interactive setup wizard and persistent settings
|
||||||
|
- Rebrand to IronClaw with security-first mission
|
||||||
|
- Fix build_software tool stuck in planning mode loop
|
||||||
|
- Enable sandbox by default
|
||||||
|
- Fix Telegram Markdown formatting and clarify tool/memory distinctions
|
||||||
|
- Simplify Telegram channel config with host-injected tunnel/webhook settings
|
||||||
|
- Apply Telegram channel learnings to WhatsApp implementation
|
||||||
|
- Merge remote-tracking branch 'origin/main'
|
||||||
|
- Docker file for sandbox
|
||||||
|
- Replace hardcoded intent patterns with job tools
|
||||||
|
- Fix router test to match intentional job creation patterns
|
||||||
|
- Add Docker execution sandbox for secure shell command isolation
|
||||||
|
- Move setup wizard credentials to database storage
|
||||||
|
- Add interactive setup wizard for first-run configuration
|
||||||
|
- Add Telegram Bot API channel as WASM module
|
||||||
|
- Add OpenClaw feature parity tracking matrix
|
||||||
|
- Add Chat Completions API support and expand REPL debugging
|
||||||
|
- Implementing channels to be handled in wasm
|
||||||
|
- Support non interactive mode and model selection
|
||||||
|
- Implement tool approval, fix tool definition refresh, and wire embeddings
|
||||||
|
- Tool use
|
||||||
|
- Wiring more
|
||||||
|
- Add heartbeat integration, planning phase, and auto-repair
|
||||||
|
- Login flow
|
||||||
|
- Extend support for session management
|
||||||
|
- Adding builder capability
|
||||||
|
- Load tools at launch
|
||||||
|
- Fix multiline message rendering in TUI
|
||||||
|
- Parse NEAR AI alternative response format with output field
|
||||||
|
- Handle NEAR AI plain text responses
|
||||||
|
- Disable mouse capture to allow text selection in TUI
|
||||||
|
- Add verbose logging to debug empty NEAR AI responses
|
||||||
|
- Improve NEAR AI response parsing for varying response formats
|
||||||
|
- Show status/thinking messages in chat window, debug empty responses
|
||||||
|
- Add timeout and logging to NEAR AI provider
|
||||||
|
- Add status updates to show agent thinking/processing state
|
||||||
|
- Add CLI subcommands for WASM tool management
|
||||||
|
- Fix TUI shutdown: send /shutdown message and handle in agent loop
|
||||||
|
- Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI
|
||||||
|
- Fix TuiChannel integration and enable in main.rs
|
||||||
|
- Integrate Codex patterns: task scheduler, TUI, sessions, compaction
|
||||||
|
- Adding LICENSE
|
||||||
|
- Add README with IronClaw branding
|
||||||
|
- Add WASM sandbox secure API extension
|
||||||
|
- Wire database Store into agent loop
|
||||||
|
- Implementing WASM runtime
|
||||||
|
- Add workspace integration tests
|
||||||
|
- Compact memory_tree output format
|
||||||
|
- Replace memory_list with memory_tree tool
|
||||||
|
- Simplify workspace to path-based storage, remove legacy code
|
||||||
|
- Add NEAR AI chat-api as default LLM provider
|
||||||
|
- Add CLAUDE.md project documentation
|
||||||
|
- Add workspace and memory system (OpenClaw-inspired)
|
||||||
|
- Initial implementation of the agent framework
|
||||||
@@ -11,8 +11,13 @@
|
|||||||
- **Always available** - Multi-channel access with proactive background execution
|
- **Always available** - Multi-channel access with proactive background execution
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels)
|
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
|
||||||
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
||||||
|
- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern
|
||||||
|
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
|
||||||
|
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
|
||||||
|
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
|
||||||
|
- **Extension management**: Install, auth, activate MCP/WASM extensions
|
||||||
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
||||||
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
||||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
||||||
@@ -59,7 +64,9 @@ src/
|
|||||||
│ ├── context_monitor.rs # Memory pressure detection
|
│ ├── context_monitor.rs # Memory pressure detection
|
||||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||||
│ └── task.rs # Sub-task execution framework
|
│ ├── task.rs # Sub-task execution framework
|
||||||
|
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
||||||
|
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
||||||
│
|
│
|
||||||
├── channels/ # Multi-channel input
|
├── channels/ # Multi-channel input
|
||||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||||
@@ -72,8 +79,33 @@ src/
|
|||||||
│ │ ├── overlay.rs # Approval overlays
|
│ │ ├── overlay.rs # Approval overlays
|
||||||
│ │ └── composer.rs # Message composition
|
│ │ └── composer.rs # Message composition
|
||||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||||
│ ├── slack.rs # Stub
|
│ ├── repl.rs # Simple REPL (for testing)
|
||||||
│ └── telegram.rs # Stub
|
│ ├── web/ # Web gateway (browser UI)
|
||||||
|
│ │ ├── mod.rs # Gateway builder, startup
|
||||||
|
│ │ ├── server.rs # Axum router, 40+ API endpoints
|
||||||
|
│ │ ├── sse.rs # SSE broadcast manager
|
||||||
|
│ │ ├── ws.rs # WebSocket gateway + connection tracking
|
||||||
|
│ │ ├── types.rs # Request/response types, SseEvent enum
|
||||||
|
│ │ ├── auth.rs # Bearer token auth middleware
|
||||||
|
│ │ ├── log_layer.rs # Tracing layer for log streaming
|
||||||
|
│ │ └── static/ # HTML, CSS, JS (single-page app)
|
||||||
|
│ └── wasm/ # WASM channel runtime
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── bundled.rs # Bundled channel discovery
|
||||||
|
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
||||||
|
│
|
||||||
|
├── orchestrator/ # Internal HTTP API for sandbox containers
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
|
||||||
|
│ ├── auth.rs # Per-job bearer token store
|
||||||
|
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
|
||||||
|
│
|
||||||
|
├── worker/ # Runs inside Docker containers
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
|
||||||
|
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
|
||||||
|
│ ├── api.rs # HTTP client to orchestrator
|
||||||
|
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
|
||||||
│
|
│
|
||||||
├── safety/ # Prompt injection defense
|
├── safety/ # Prompt injection defense
|
||||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||||
@@ -96,6 +128,9 @@ src/
|
|||||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||||
│ │ ├── shell.rs # Shell command execution
|
│ │ ├── shell.rs # Shell command execution
|
||||||
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
||||||
|
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
|
||||||
|
│ │ ├── routine.rs # routine_create/list/update/delete/history
|
||||||
|
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
|
||||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||||
│ ├── builder/ # Dynamic tool building
|
│ ├── builder/ # Dynamic tool building
|
||||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||||
@@ -151,6 +186,10 @@ src/
|
|||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Use `thiserror` for error types in `error.rs`
|
- Use `thiserror` for error types in `error.rs`
|
||||||
- Never use `.unwrap()` in production code (tests are fine)
|
- Never use `.unwrap()` in production code (tests are fine)
|
||||||
@@ -232,6 +271,30 @@ HEARTBEAT_ENABLED=true
|
|||||||
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
||||||
HEARTBEAT_NOTIFY_CHANNEL=tui
|
HEARTBEAT_NOTIFY_CHANNEL=tui
|
||||||
HEARTBEAT_NOTIFY_USER=default
|
HEARTBEAT_NOTIFY_USER=default
|
||||||
|
|
||||||
|
# Web gateway
|
||||||
|
GATEWAY_ENABLED=true
|
||||||
|
GATEWAY_HOST=127.0.0.1
|
||||||
|
GATEWAY_PORT=3001
|
||||||
|
GATEWAY_AUTH_TOKEN=changeme # Required for API access
|
||||||
|
GATEWAY_USER_ID=default
|
||||||
|
|
||||||
|
# Docker sandbox
|
||||||
|
SANDBOX_ENABLED=true
|
||||||
|
SANDBOX_IMAGE=ironclaw-worker:latest
|
||||||
|
SANDBOX_MEMORY_LIMIT_MB=512
|
||||||
|
SANDBOX_TIMEOUT_SECS=1800
|
||||||
|
|
||||||
|
# Claude Code mode (runs inside sandbox containers)
|
||||||
|
CLAUDE_CODE_ENABLED=false
|
||||||
|
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
|
||||||
|
CLAUDE_CODE_MAX_TURNS=50
|
||||||
|
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
|
||||||
|
|
||||||
|
# Routines (scheduled/reactive execution)
|
||||||
|
ROUTINES_ENABLED=true
|
||||||
|
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
|
||||||
|
ROUTINES_MAX_CONCURRENT=3
|
||||||
```
|
```
|
||||||
|
|
||||||
### NEAR AI Provider
|
### NEAR AI Provider
|
||||||
@@ -293,13 +356,14 @@ Key test patterns:
|
|||||||
|
|
||||||
## Current Limitations / TODOs
|
## Current Limitations / TODOs
|
||||||
|
|
||||||
1. **Slack/Telegram channels** - Stubs only, need implementation
|
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||||
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
2. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||||
3. **Integration tests** - Need testcontainers setup for PostgreSQL
|
3. **MCP stdio transport** - Only HTTP transport implemented
|
||||||
4. **MCP stdio transport** - Only HTTP transport implemented
|
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||||
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||||
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||||
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
|
||||||
|
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
|
|
||||||
@@ -316,6 +380,13 @@ Key test patterns:
|
|||||||
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
||||||
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
||||||
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
||||||
|
- ✅ **Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket
|
||||||
|
- ✅ **Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines
|
||||||
|
- ✅ **Slack/Telegram channels** - Implemented as WASM tools
|
||||||
|
- ✅ **Docker sandbox** - Orchestrator/worker containers with per-job auth
|
||||||
|
- ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers
|
||||||
|
- ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails
|
||||||
|
- ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
|
||||||
|
|
||||||
## Adding a New Tool
|
## Adding a New Tool
|
||||||
|
|
||||||
@@ -331,13 +402,13 @@ Key test patterns:
|
|||||||
|
|
||||||
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
|
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
|
||||||
|
|
||||||
1. Create a new crate in `examples/wasm-tools/<name>/`
|
1. Create a new crate in `tools-src/<name>/`
|
||||||
2. Implement the WIT interface (`wit/tool.wit`)
|
2. Implement the WIT interface (`wit/tool.wit`)
|
||||||
3. Create `<name>.capabilities.json` declaring required permissions
|
3. Create `<name>.capabilities.json` declaring required permissions
|
||||||
4. Build with `cargo build --target wasm32-wasip2 --release`
|
4. Build with `cargo build --target wasm32-wasip2 --release`
|
||||||
5. Install with `ironclaw tool install path/to/tool.wasm`
|
5. Install with `ironclaw tool install path/to/tool.wasm`
|
||||||
|
|
||||||
See `examples/wasm-tools/` for examples.
|
See `tools-src/` for examples.
|
||||||
|
|
||||||
## Tool Architecture Principles
|
## Tool Architecture Principles
|
||||||
|
|
||||||
@@ -419,6 +490,39 @@ When running `ironclaw tool auth <tool>`:
|
|||||||
|
|
||||||
The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent.
|
The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent.
|
||||||
|
|
||||||
|
### WASM Tools vs MCP Servers: When to Use Which
|
||||||
|
|
||||||
|
Both are first-class in the extension system (`ironclaw tool install` handles both), but they have different strengths.
|
||||||
|
|
||||||
|
**WASM Tools (IronClaw native)**
|
||||||
|
|
||||||
|
- Sandboxed: fuel metering, memory limits, no access except what's allowlisted
|
||||||
|
- Credentials injected by host runtime, tool code never sees the actual token
|
||||||
|
- Output scanned for secret leakage before returning to the LLM
|
||||||
|
- Auth (OAuth/manual) declared in `capabilities.json`, agent handles the flow
|
||||||
|
- Single binary, no process management, works offline
|
||||||
|
- Cost: must build yourself in Rust, no ecosystem, synchronous only
|
||||||
|
|
||||||
|
**MCP Servers (Model Context Protocol)**
|
||||||
|
|
||||||
|
- Growing ecosystem of pre-built servers (GitHub, Notion, Postgres, etc.)
|
||||||
|
- Any language (TypeScript/Python most common)
|
||||||
|
- Can do websockets, streaming, background polling
|
||||||
|
- Cost: external process with full system access (no sandbox), manages own credentials, IronClaw can't prevent leaks
|
||||||
|
|
||||||
|
**Decision guide:**
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
|----------|-----|
|
||||||
|
| Good MCP server already exists | **MCP** |
|
||||||
|
| Handles sensitive credentials (email send, banking) | **WASM** |
|
||||||
|
| Quick prototype or one-off integration | **MCP** |
|
||||||
|
| Core capability you'll maintain long-term | **WASM** |
|
||||||
|
| Needs background connections (websockets, polling) | **MCP** |
|
||||||
|
| Multiple tools share one OAuth token (e.g., Google suite) | **WASM** |
|
||||||
|
|
||||||
|
The LLM-facing interface is identical for both (tool name, schema, execute), so swapping between them is transparent to the agent.
|
||||||
|
|
||||||
## Adding a New Channel
|
## Adding a New Channel
|
||||||
|
|
||||||
1. Create `src/channels/my_channel.rs`
|
1. Create `src/channels/my_channel.rs`
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Feature Parity Requirement
|
||||||
|
|
||||||
|
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
|
||||||
|
|
||||||
|
### Required before opening a PR
|
||||||
|
|
||||||
|
1. Review the relevant parity rows in `FEATURE_PARITY.md`.
|
||||||
|
2. Update status/notes if behavior changed.
|
||||||
|
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
|
||||||
Generated
+753
-174
File diff suppressed because it is too large
Load Diff
+66
-7
@@ -1,15 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
authors = ["NEAR AI <[email protected]>"]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
homepage = "https://github.com/nearai/ironclaw"
|
||||||
|
repository = "https://github.com/nearai/ironclaw"
|
||||||
|
|
||||||
|
[package.metadata.wix]
|
||||||
|
upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F"
|
||||||
|
path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339"
|
||||||
|
license = false
|
||||||
|
eula = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
@@ -18,7 +27,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
deadpool-postgres = "0.14"
|
deadpool-postgres = "0.14"
|
||||||
@@ -49,21 +57,26 @@ async-trait = "0.1"
|
|||||||
# CLI
|
# CLI
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
# TUI
|
# Terminal
|
||||||
ratatui = "0.29"
|
crossterm = "0.28"
|
||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
rustyline = { version = "17", features = ["derive", "with-file-history"] }
|
||||||
|
termimad = "0.34"
|
||||||
|
|
||||||
# Channel integrations
|
# Channel integrations
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||||
|
|
||||||
|
# Cron scheduling for routines
|
||||||
|
cron = "0.13"
|
||||||
|
|
||||||
# Safety/sanitization
|
# Safety/sanitization
|
||||||
regex = "1"
|
regex = "1"
|
||||||
aho-corasick = "1"
|
aho-corasick = "1"
|
||||||
|
|
||||||
# Filesystem paths
|
# Filesystem paths
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
fs4 = "0.6"
|
||||||
|
|
||||||
# Secrecy for sensitive values
|
# Secrecy for sensitive values
|
||||||
secrecy = { version = "0.10", features = ["serde"] }
|
secrecy = { version = "0.10", features = ["serde"] }
|
||||||
@@ -90,6 +103,9 @@ sha2 = "0.10"
|
|||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
||||||
|
# Multi-provider LLM support
|
||||||
|
rig-core = "0.30"
|
||||||
|
|
||||||
# Docker sandbox
|
# Docker sandbox
|
||||||
bollard = "0.18"
|
bollard = "0.18"
|
||||||
|
|
||||||
@@ -99,6 +115,7 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
|
|||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
|
||||||
# macOS keychain
|
# macOS keychain
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
@@ -111,6 +128,7 @@ zbus = "4"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
tokio-tungstenite = "0.26"
|
||||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||||
pretty_assertions = "1"
|
pretty_assertions = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -118,3 +136,44 @@ tempfile = "3"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
integration = []
|
integration = []
|
||||||
|
|
||||||
|
# The profile that 'cargo dist' will build with
|
||||||
|
[profile.dist]
|
||||||
|
inherits = "release"
|
||||||
|
lto = "thin"
|
||||||
|
|
||||||
|
# Config for 'dist'
|
||||||
|
[workspace.metadata.dist]
|
||||||
|
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||||
|
cargo-dist-version = "0.30.3"
|
||||||
|
# CI backends to support
|
||||||
|
ci = "github"
|
||||||
|
# The installers to generate for each app
|
||||||
|
installers = ["shell", "powershell", "npm", "msi"]
|
||||||
|
# Publish jobs to run in CI
|
||||||
|
publish-jobs = ["npm"]
|
||||||
|
# Target platforms to build apps for (Rust target-triple syntax)
|
||||||
|
targets = [
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"aarch64-unknown-linux-gnu",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
|
"x86_64-pc-windows-msvc",
|
||||||
|
]
|
||||||
|
# The archive format to use for windows builds (defaults .zip)
|
||||||
|
windows-archive = ".tar.gz"
|
||||||
|
# The archive format to use for non-windows builds (defaults .tar.xz)
|
||||||
|
unix-archive = ".tar.gz"
|
||||||
|
# Which actions to run on pull requests
|
||||||
|
pr-run-mode = "upload"
|
||||||
|
# Path that installers should place binaries in
|
||||||
|
install-path = "CARGO_HOME"
|
||||||
|
# Whether to install an updater program
|
||||||
|
install-updater = true
|
||||||
|
|
||||||
|
[workspace.metadata.dist.github-custom-runners]
|
||||||
|
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
|
||||||
|
x86_64-unknown-linux-gnu = "ubuntu-22.04"
|
||||||
|
x86_64-pc-windows-msvc = "windows-2022"
|
||||||
|
x86_64-apple-darwin = "macos-15-intel"
|
||||||
|
aarch64-apple-darwin = "macos-14"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Multi-stage Dockerfile for the IronClaw worker container.
|
||||||
|
#
|
||||||
|
# This image runs the ironclaw binary in worker mode inside Docker containers.
|
||||||
|
# The orchestrator creates instances of this image for sandboxed job execution.
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -f Dockerfile.worker -t ironclaw-worker .
|
||||||
|
#
|
||||||
|
# The image includes common development tools so workers can build software,
|
||||||
|
# run tests, and execute shell commands.
|
||||||
|
|
||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build only the ironclaw binary (release mode)
|
||||||
|
RUN cargo build --release --bin ironclaw
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install common development tools
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Rust toolchain for the sandbox user
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \
|
||||||
|
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
|
||||||
|
|
||||||
|
# Install Claude Code CLI (for claude-bridge mode)
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code@latest
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw
|
||||||
|
|
||||||
|
# Create non-root user (UID 1000 matches the orchestrator's container config)
|
||||||
|
RUN useradd -m -u 1000 -s /bin/bash sandbox \
|
||||||
|
&& mkdir -p /workspace \
|
||||||
|
&& chown sandbox:sandbox /workspace \
|
||||||
|
&& mkdir -p /home/sandbox/.claude \
|
||||||
|
&& chown sandbox:sandbox /home/sandbox/.claude
|
||||||
|
|
||||||
|
USER sandbox
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# The orchestrator passes the full command via Docker cmd.
|
||||||
|
ENTRYPOINT ["ironclaw"]
|
||||||
+44
-34
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
|
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
|
||||||
| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 |
|
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE |
|
||||||
| Single-user system | ✅ | ✅ | |
|
| Single-user system | ✅ | ✅ | |
|
||||||
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
||||||
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
||||||
@@ -31,9 +31,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Gateway control plane | ✅ | ❌ | Central WebSocket server |
|
| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints |
|
||||||
| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard |
|
| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions |
|
||||||
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
|
| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker |
|
||||||
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
||||||
| Configuration hot-reload | ✅ | ❌ | |
|
| Configuration hot-reload | ✅ | ❌ | |
|
||||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||||
@@ -43,7 +43,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| launchd/systemd integration | ✅ | ❌ | |
|
| launchd/systemd integration | ✅ | ❌ | |
|
||||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||||
| Tailscale integration | ✅ | ❌ | |
|
| Tailscale integration | ✅ | ❌ | |
|
||||||
| Health check endpoints | ✅ | ❌ | |
|
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||||
| `doctor` diagnostics | ✅ | ❌ | |
|
| `doctor` diagnostics | ✅ | ❌ | |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||||
| Telegram | ✅ | ❌ | P1 | grammY (Bot API) |
|
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||||
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
|
| Slack | ✅ | ✅ | - | WASM tool |
|
||||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||||
| LINE | ✅ | ❌ | P3 | |
|
| LINE | ✅ | ❌ | P3 | |
|
||||||
| WebChat | ✅ | ❌ | P2 | Browser-based chat |
|
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||||
| Mattermost | ✅ | ❌ | P3 | |
|
| Mattermost | ✅ | ❌ | P3 | |
|
||||||
| Google Chat | ✅ | ❌ | P3 | |
|
| Google Chat | ✅ | ❌ | P3 | |
|
||||||
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| DM pairing codes | ✅ | ❌ | Verification for unknown senders |
|
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||||
| Allowlist/blocklist | ✅ | ❌ | Per-channel access control |
|
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||||
| Mention-based activation | ✅ | ❌ | Configurable patterns |
|
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||||
| Per-channel media limits | ✅ | ❌ | |
|
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -99,17 +99,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `run` (agent) | ✅ | ✅ | - | Default command |
|
| `run` (agent) | ✅ | ✅ | - | Default command |
|
||||||
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
||||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||||
| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup |
|
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||||
| `config` | ✅ | ❌ | P2 | Read/write config |
|
| `config` | ✅ | ✅ | - | Read/write config |
|
||||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||||
| `status` | ✅ | ❌ | P2 | System status |
|
| `status` | ✅ | ✅ | - | System status |
|
||||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||||
| `memory` | ✅ | ❌ | P2 | Memory search CLI |
|
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
||||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||||
| `pairing` | ✅ | ❌ | P3 | Node pairing |
|
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
||||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||||
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||||
@@ -132,7 +132,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
||||||
| RPC-based execution | ✅ | 🚧 | Worker isolation |
|
| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
|
||||||
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
||||||
| Per-sender sessions | ✅ | ✅ | |
|
| Per-sender sessions | ✅ | ✅ | |
|
||||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||||
@@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config |
|
| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions |
|
||||||
| Channel status view | ✅ | ❌ | P2 | |
|
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
|
||||||
| Agent management | ✅ | ❌ | P3 | |
|
| Agent management | ✅ | ❌ | P3 | |
|
||||||
| Model selection | ✅ | ✅ | - | TUI only |
|
| Model selection | ✅ | ✅ | - | TUI only |
|
||||||
| Config editing | ✅ | ❌ | P3 | |
|
| Config editing | ✅ | ❌ | P3 | |
|
||||||
| Debug/logs viewer | ✅ | ❌ | P3 | |
|
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
||||||
| WebChat interface | ✅ | ❌ | P2 | Browser chat |
|
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
||||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks |
|
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||||
| Timezone support | ✅ | ❌ | P2 | |
|
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||||
| One-shot/recurring jobs | ✅ | ❌ | P2 | |
|
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||||
| `onMessage` hook | ✅ | ❌ | P2 | |
|
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||||
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||||
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||||
@@ -346,18 +346,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
|
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||||
| Device pairing | ✅ | ❌ | |
|
| Device pairing | ✅ | ❌ | |
|
||||||
| Tailscale identity | ✅ | ❌ | |
|
| Tailscale identity | ✅ | ❌ | |
|
||||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||||
| DM pairing verification | ✅ | ❌ | |
|
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||||
| Allowlist/blocklist | ✅ | ❌ | |
|
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||||
| Per-group tool policies | ✅ | ❌ | |
|
| Per-group tool policies | ✅ | ❌ | |
|
||||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||||
| Docker sandbox | ✅ | ❌ | Uses WASM sandbox |
|
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
||||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||||
| Tool policies | ✅ | ✅ | |
|
| Tool policies | ✅ | ✅ | |
|
||||||
| Elevated mode | ✅ | ❌ | |
|
| Elevated mode | ✅ | ❌ | |
|
||||||
@@ -397,6 +397,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
### P0 - Core (Already Done)
|
### P0 - Core (Already Done)
|
||||||
- ✅ TUI channel with approval overlays
|
- ✅ TUI channel with approval overlays
|
||||||
- ✅ HTTP webhook channel
|
- ✅ HTTP webhook channel
|
||||||
|
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||||
- ✅ WASM tool sandbox
|
- ✅ WASM tool sandbox
|
||||||
- ✅ Workspace/memory with hybrid search
|
- ✅ Workspace/memory with hybrid search
|
||||||
- ✅ Prompt injection defense
|
- ✅ Prompt injection defense
|
||||||
@@ -404,23 +405,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- ✅ Session management
|
- ✅ Session management
|
||||||
- ✅ Context compaction
|
- ✅ Context compaction
|
||||||
- ✅ Model selection
|
- ✅ Model selection
|
||||||
|
- ✅ Gateway control plane + WebSocket
|
||||||
|
- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines)
|
||||||
|
- ✅ WebChat channel (web gateway)
|
||||||
|
- ✅ Slack channel (WASM tool)
|
||||||
|
- ✅ Telegram channel (WASM tool, MTProto)
|
||||||
|
- ✅ Docker sandbox (orchestrator/worker)
|
||||||
|
- ✅ Cron job scheduling (routines)
|
||||||
|
- ✅ CLI subcommands (onboard, config, status, memory)
|
||||||
|
- ✅ Gateway token auth
|
||||||
|
|
||||||
### P1 - High Priority
|
### P1 - High Priority
|
||||||
- ❌ Slack channel (real implementation)
|
- ❌ Slack channel (real implementation)
|
||||||
- ❌ Telegram channel
|
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||||
- ❌ WhatsApp channel
|
- ❌ WhatsApp channel
|
||||||
- ❌ Multi-provider failover
|
- ❌ Multi-provider failover
|
||||||
- ❌ Gateway control plane + WebSocket
|
|
||||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||||
|
|
||||||
### P2 - Medium Priority
|
### P2 - Medium Priority
|
||||||
- ❌ Cron job scheduling
|
- ❌ Cron job scheduling
|
||||||
- ❌ Web Control UI
|
- ❌ Web Control UI
|
||||||
- ❌ WebChat channel
|
- ❌ WebChat channel
|
||||||
- ❌ Media handling (images, PDFs)
|
- 🚧 Media handling (caption support; no image/PDF processing)
|
||||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||||
- ❌ Ollama/local model support
|
- ❌ Ollama/local model support
|
||||||
- ❌ Configuration hot-reload
|
- ❌ Configuration hot-reload
|
||||||
|
- ❌ Webhook trigger endpoint in web gateway
|
||||||
|
|
||||||
### P3 - Lower Priority
|
### P3 - Lower Priority
|
||||||
- ❌ Discord channel
|
- ❌ Discord channel
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding any notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2026 NEAR AI
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -43,7 +43,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
|||||||
|
|
||||||
### Always Available
|
### Always Available
|
||||||
|
|
||||||
- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks
|
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway
|
||||||
|
- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern
|
||||||
|
- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming
|
||||||
|
- **Routines** - Cron schedules, event triggers, webhook handlers for background automation
|
||||||
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
||||||
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
||||||
- **Self-repair** - Automatic detection and recovery of stuck operations
|
- **Self-repair** - Automatic detection and recovery of stuck operations
|
||||||
@@ -65,10 +68,57 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Rust 1.85+
|
- Rust 1.85+
|
||||||
- PostgreSQL 15+ with pgvector extension
|
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
|
||||||
- NEAR AI session token (or other LLM provider)
|
- NEAR AI account (authentication handled via setup wizard)
|
||||||
|
|
||||||
### Build
|
## Download or Build
|
||||||
|
|
||||||
|
Visit [Releases page](https://github.com/nearai/ironclaw/releases/) to see the latest updates.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Install via Windows Installer (Windows)</summary>
|
||||||
|
|
||||||
|
Download the [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) and run it.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Install via powershell script (Windows)</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Install via shell script (macOS, Linux, Windows/WSL)</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Run via npx (Node.js on Windows, Linux, macOS)</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx ironclaw
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Use in package.json scripts (Node.js on Windows, Linux, macOS)</summary>
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install ironclaw
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Compile the source code (Cargo on Windows, Linux, macOS)</summary>
|
||||||
|
|
||||||
|
Install it with `cargo`, just make sure you have [Rust](https://rustup.rs) installed on your computer.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
@@ -82,6 +132,10 @@ cargo build --release
|
|||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Database Setup
|
### Database Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -90,36 +144,19 @@ createdb ironclaw
|
|||||||
|
|
||||||
# Enable pgvector
|
# Enable pgvector
|
||||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
refinery migrate -c refinery.toml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
Run the setup wizard to configure IronClaw:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required
|
ironclaw onboard
|
||||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
|
||||||
NEARAI_SESSION_TOKEN=sess_...
|
|
||||||
|
|
||||||
# Optional: Enable channels
|
|
||||||
TELEGRAM_BOT_TOKEN=...
|
|
||||||
WHATSAPP_ACCESS_TOKEN=...
|
|
||||||
SLACK_BOT_TOKEN=xoxb-...
|
|
||||||
HTTP_PORT=8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||||
|
and secrets encryption (using your system keychain). All settings are saved to
|
||||||
| Variable | Description | Required |
|
`~/.ironclaw/settings.toml`.
|
||||||
|----------|-------------|----------|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | Yes |
|
|
||||||
| `NEARAI_SESSION_TOKEN` | NEAR AI authentication token | Yes |
|
|
||||||
| `NEARAI_MODEL` | Model to use (default: claude-3-5-sonnet) | No |
|
|
||||||
| `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No |
|
|
||||||
| `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets |
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -160,37 +197,42 @@ External content passes through multiple security layers:
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
│ Channels │
|
│ Channels │
|
||||||
│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │
|
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │
|
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │
|
||||||
│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │
|
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||||
│ └──────────┴─────────────┴────────────┘ │
|
│ │ │ │ └──────┬──────┘ │
|
||||||
│ │ │
|
│ └─────────┴──────────────┴────────────────┘ │
|
||||||
│ ┌────▼────┐ │
|
│ │ │
|
||||||
│ │ Router │ Intent classification │
|
│ ┌─────────▼─────────┐ │
|
||||||
│ └────┬────┘ │
|
│ │ Agent Loop │ Intent routing │
|
||||||
│ │ │
|
│ └────┬─────────┬────┘ │
|
||||||
│ ┌──────────▼──────────┐ │
|
│ │ │ │
|
||||||
│ │ Scheduler │ Parallel job management │
|
│ ┌──────────▼───┐ ┌──▼──────────────┐ │
|
||||||
│ └──────────┬──────────┘ │
|
│ │ Scheduler │ │ Routines Engine │ │
|
||||||
│ │ │
|
│ │(parallel jobs)│ │(cron, event, wh) │ │
|
||||||
│ ┌───────────────┼───────────────┐ │
|
│ └──────┬───────┘ └────────┬─────────┘ │
|
||||||
│ ▼ ▼ ▼ │
|
│ │ │ │
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
│ ┌─────────────┼───────────────────┘ │
|
||||||
│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │
|
│ │ │ │
|
||||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
│ ┌───▼────┐ ┌────▼────────────────┐ │
|
||||||
│ └───────────────┼───────────────┘ │
|
│ │ Local │ │ Orchestrator │ │
|
||||||
│ │ │
|
│ │Workers │ │ ┌───────────────┐ │ │
|
||||||
│ ┌──────────▼──────────┐ │
|
│ │(in-proc)│ │ │ Docker Sandbox│ │ │
|
||||||
│ │ Tool Registry │ │
|
│ └───┬────┘ │ │ Containers │ │ │
|
||||||
│ │ ┌───────────────┐ │ │
|
│ │ │ │ ┌───────────┐ │ │ │
|
||||||
│ │ │ Built-in │ │ │
|
│ │ │ │ │Worker / CC│ │ │ │
|
||||||
│ │ │ MCP │ │ │
|
│ │ │ │ └───────────┘ │ │ │
|
||||||
│ │ │ WASM Sandbox │ │ │
|
│ │ │ └───────────────┘ │ │
|
||||||
│ │ └───────────────┘ │ │
|
│ │ └─────────┬───────────┘ │
|
||||||
│ └─────────────────────┘ │
|
│ └──────────────────┤ │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
│ │ │
|
||||||
|
│ ┌───────────▼──────────┐ │
|
||||||
|
│ │ Tool Registry │ │
|
||||||
|
│ │ Built-in, MCP, WASM │ │
|
||||||
|
│ └──────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Core Components
|
### Core Components
|
||||||
@@ -201,33 +243,25 @@ External content passes through multiple security layers:
|
|||||||
| **Router** | Classifies user intent (command, query, task) |
|
| **Router** | Classifies user intent (command, query, task) |
|
||||||
| **Scheduler** | Manages parallel job execution with priorities |
|
| **Scheduler** | Manages parallel job execution with priorities |
|
||||||
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
||||||
|
| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth |
|
||||||
|
| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines |
|
||||||
|
| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks |
|
||||||
| **Workspace** | Persistent memory with hybrid search |
|
| **Workspace** | Persistent memory with hybrid search |
|
||||||
| **Safety Layer** | Prompt injection defense and content sanitization |
|
| **Safety Layer** | Prompt injection defense and content sanitization |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### CLI Mode
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start interactive CLI
|
# First-time setup (configures database, auth, etc.)
|
||||||
|
ironclaw onboard
|
||||||
|
|
||||||
|
# Start interactive REPL
|
||||||
cargo run
|
cargo run
|
||||||
|
|
||||||
# With debug logging
|
# With debug logging
|
||||||
RUST_LOG=ironclaw=debug cargo run
|
RUST_LOG=ironclaw=debug cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTTP Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start with HTTP webhook server
|
|
||||||
HTTP_PORT=8080 cargo run
|
|
||||||
|
|
||||||
# Send a request
|
|
||||||
curl -X POST http://localhost:8080/webhook \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"message": "Hello, IronClaw!"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -238,12 +272,16 @@ cargo fmt
|
|||||||
cargo clippy --all --benches --tests --examples --all-features
|
cargo clippy --all --benches --tests --examples --all-features
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
|
createdb ironclaw_test
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Run specific test
|
# Run specific test
|
||||||
cargo test test_name
|
cargo test test_name
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
|
||||||
|
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
|
||||||
|
|
||||||
## OpenClaw Heritage
|
## OpenClaw Heritage
|
||||||
|
|
||||||
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Build script: compile Telegram channel WASM from source.
|
||||||
|
//!
|
||||||
|
//! Do not commit compiled WASM binaries — they are a supply chain risk.
|
||||||
|
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
|
||||||
|
//!
|
||||||
|
//! Reproducible build:
|
||||||
|
//! cargo build --release
|
||||||
|
//! (build.rs invokes the channel build automatically)
|
||||||
|
//!
|
||||||
|
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||||
|
let root = PathBuf::from(&manifest_dir);
|
||||||
|
let channel_dir = root.join("channels-src/telegram");
|
||||||
|
let wasm_out = channel_dir.join("telegram.wasm");
|
||||||
|
|
||||||
|
// Rerun when channel source or build script changes
|
||||||
|
println!("cargo:rerun-if-changed=channels-src/telegram/src");
|
||||||
|
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=wit/channel.wit");
|
||||||
|
|
||||||
|
if !channel_dir.is_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WASM module
|
||||||
|
let status = match Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"--release",
|
||||||
|
"--target",
|
||||||
|
"wasm32-wasip2",
|
||||||
|
"--manifest-path",
|
||||||
|
channel_dir.join("Cargo.toml").to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.current_dir(&root)
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!(
|
||||||
|
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
|
||||||
|
if !raw_wasm.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"cargo:warning=Telegram WASM output not found at {:?}",
|
||||||
|
raw_wasm
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to component and strip (wasm-tools)
|
||||||
|
let component_ok = Command::new("wasm-tools")
|
||||||
|
.args([
|
||||||
|
"component",
|
||||||
|
"new",
|
||||||
|
raw_wasm.to_str().unwrap(),
|
||||||
|
"-o",
|
||||||
|
wasm_out.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.current_dir(&root)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !component_ok {
|
||||||
|
// Fallback: copy raw module if wasm-tools unavailable
|
||||||
|
if std::fs::copy(&raw_wasm, &wasm_out).is_err() {
|
||||||
|
eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Strip debug info (use temp file to avoid clobbering)
|
||||||
|
let stripped = wasm_out.with_extension("wasm.stripped");
|
||||||
|
let strip_ok = Command::new("wasm-tools")
|
||||||
|
.args([
|
||||||
|
"strip",
|
||||||
|
wasm_out.to_str().unwrap(),
|
||||||
|
"-o",
|
||||||
|
stripped.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.current_dir(&root)
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if strip_ok {
|
||||||
|
let _ = std::fs::rename(&stripped, &wasm_out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+497
@@ -0,0 +1,497 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.101"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.180"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slack-channel"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spdx"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||||
|
dependencies = [
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.220.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||||
|
dependencies = [
|
||||||
|
"leb128",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.220.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"spdx",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.220.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.36.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rt",
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.36.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rt"
|
||||||
|
version = "0.36.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.36.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.36.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.220.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.220.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.39"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.39"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||||
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
|
|||||||
wasm-tools strip slack.wasm -o slack.wasm
|
wasm-tools strip slack.wasm -o slack.wasm
|
||||||
|
|
||||||
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
||||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/"
|
echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/"
|
||||||
else
|
else
|
||||||
echo "Error: WASM output not found at $WASM_PATH"
|
echo "Error: WASM output not found at $WASM_PATH"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
// Re-export generated types
|
// Re-export generated types
|
||||||
use exports::near::agent::channel::{
|
use exports::near::agent::channel::{
|
||||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||||
OutgoingHttpResponse, PollConfig,
|
OutgoingHttpResponse, StatusUpdate,
|
||||||
};
|
};
|
||||||
use near::agent::channel_host::{self, EmittedMessage};
|
use near::agent::channel_host::{self, EmittedMessage};
|
||||||
|
|
||||||
@@ -108,7 +108,10 @@ struct SlackPostMessageResponse {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SlackConfig {
|
struct SlackConfig {
|
||||||
/// Name of secret containing signing secret (for verification by host).
|
/// Name of secret containing signing secret (for verification by host).
|
||||||
|
/// Parsed from config for forward compatibility; not yet used in WASM
|
||||||
|
/// (host handles signature verification).
|
||||||
#[serde(default = "default_signing_secret_name")]
|
#[serde(default = "default_signing_secret_name")]
|
||||||
|
#[allow(dead_code)]
|
||||||
signing_secret_name: String,
|
signing_secret_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,11 +178,7 @@ impl Guest for SlackChannel {
|
|||||||
// Actual event callback
|
// Actual event callback
|
||||||
"event_callback" => {
|
"event_callback" => {
|
||||||
if let Some(event) = event_wrapper.event {
|
if let Some(event) = event_wrapper.event {
|
||||||
handle_slack_event(
|
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id);
|
||||||
event,
|
|
||||||
event_wrapper.team_id,
|
|
||||||
event_wrapper.event_id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
||||||
json_response(200, serde_json::json!({"ok": true}))
|
json_response(200, serde_json::json!({"ok": true}))
|
||||||
@@ -230,6 +229,7 @@ impl Guest for SlackChannel {
|
|||||||
"https://slack.com/api/chat.postMessage",
|
"https://slack.com/api/chat.postMessage",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -243,14 +243,15 @@ impl Guest for SlackChannel {
|
|||||||
|
|
||||||
// Parse Slack response
|
// Parse Slack response
|
||||||
let slack_response: SlackPostMessageResponse =
|
let slack_response: SlackPostMessageResponse =
|
||||||
serde_json::from_slice(&http_response.body).map_err(|e| {
|
serde_json::from_slice(&http_response.body)
|
||||||
format!("Failed to parse Slack response: {}", e)
|
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
if !slack_response.ok {
|
if !slack_response.ok {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Slack API error: {}",
|
"Slack API error: {}",
|
||||||
slack_response.error.unwrap_or_else(|| "unknown".to_string())
|
slack_response
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,23 +270,24 @@ impl Guest for SlackChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_status(_update: StatusUpdate) {}
|
||||||
|
|
||||||
fn on_shutdown() {
|
fn on_shutdown() {
|
||||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a Slack event and emit message if applicable.
|
/// Handle a Slack event and emit message if applicable.
|
||||||
fn handle_slack_event(
|
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||||
event: SlackEvent,
|
|
||||||
team_id: Option<String>,
|
|
||||||
_event_id: Option<String>,
|
|
||||||
) {
|
|
||||||
match event.event_type.as_str() {
|
match event.event_type.as_str() {
|
||||||
// Direct mention of the bot
|
// Direct mention of the bot
|
||||||
"app_mention" => {
|
"app_mention" => {
|
||||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
event.user,
|
||||||
{
|
event.channel.clone(),
|
||||||
|
event.text,
|
||||||
|
event.ts.clone(),
|
||||||
|
) {
|
||||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,9 +299,12 @@ fn handle_slack_event(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
event.user,
|
||||||
{
|
event.channel.clone(),
|
||||||
|
event.text,
|
||||||
|
event.ts.clone(),
|
||||||
|
) {
|
||||||
// Only process DMs (channel IDs starting with D)
|
// Only process DMs (channel IDs starting with D)
|
||||||
if channel.starts_with('D') {
|
if channel.starts_with('D') {
|
||||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||||
@@ -333,13 +338,12 @@ fn emit_message(
|
|||||||
team_id,
|
team_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metadata_json =
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||||
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
|
||||||
|
|
||||||
// Strip @ mentions of the bot from the text for cleaner messages
|
// Strip @ mentions of the bot from the text for cleaner messages
|
||||||
let cleaned_text = strip_bot_mention(&text);
|
let cleaned_text = strip_bot_mention(&text);
|
||||||
|
|
||||||
channel_host::emit_message(EmittedMessage {
|
channel_host::emit_message(&EmittedMessage {
|
||||||
user_id,
|
user_id,
|
||||||
user_name: None, // Could fetch from Slack API if needed
|
user_name: None, // Could fetch from Slack API if needed
|
||||||
content: cleaned_text,
|
content: cleaned_text,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
|
|||||||
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To install:"
|
echo "To install:"
|
||||||
echo " mkdir -p ~/.near-agent/channels"
|
echo " mkdir -p ~/.ironclaw/channels"
|
||||||
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
|
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Then add your bot token to secrets:"
|
echo "Then add your bot token to secrets:"
|
||||||
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
||||||
|
|||||||
+402
-101
@@ -72,6 +72,10 @@ struct TelegramMessage {
|
|||||||
/// Message text.
|
/// Message text.
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
|
|
||||||
|
/// Caption for media (photo, video, document, etc.).
|
||||||
|
#[serde(default)]
|
||||||
|
caption: Option<String>,
|
||||||
|
|
||||||
/// Original message if this is a reply.
|
/// Original message if this is a reply.
|
||||||
reply_to_message: Option<Box<TelegramMessage>>,
|
reply_to_message: Option<Box<TelegramMessage>>,
|
||||||
|
|
||||||
@@ -160,6 +164,21 @@ const POLLING_STATE_PATH: &str = "state/last_update_id";
|
|||||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||||
|
|
||||||
|
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||||
|
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||||
|
|
||||||
|
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||||
|
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||||
|
|
||||||
|
/// Channel name for pairing store (used by pairing host APIs).
|
||||||
|
const CHANNEL_NAME: &str = "telegram";
|
||||||
|
|
||||||
|
/// Workspace path for persisting bot_username for mention detection in groups.
|
||||||
|
const BOT_USERNAME_PATH: &str = "state/bot_username";
|
||||||
|
|
||||||
|
/// Workspace path for persisting respond_to_all_group_messages flag.
|
||||||
|
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Channel Metadata
|
// Channel Metadata
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -196,6 +215,14 @@ struct TelegramConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
owner_id: Option<i64>,
|
owner_id: Option<i64>,
|
||||||
|
|
||||||
|
/// DM policy: "pairing" (default), "allowlist", or "open".
|
||||||
|
#[serde(default)]
|
||||||
|
dm_policy: Option<String>,
|
||||||
|
|
||||||
|
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
|
||||||
|
#[serde(default)]
|
||||||
|
allow_from: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Whether to respond to all group messages (not just mentions).
|
/// Whether to respond to all group messages (not just mentions).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
respond_to_all_group_messages: bool,
|
respond_to_all_group_messages: bool,
|
||||||
@@ -257,6 +284,28 @@ impl Guest for TelegramChannel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist dm_policy and allow_from for DM pairing in handle_message
|
||||||
|
let dm_policy = config
|
||||||
|
.dm_policy
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("pairing")
|
||||||
|
.to_string();
|
||||||
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
||||||
|
|
||||||
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||||
|
|
||||||
|
// Persist bot_username and respond_to_all_group_messages for group handling
|
||||||
|
let _ = channel_host::workspace_write(
|
||||||
|
BOT_USERNAME_PATH,
|
||||||
|
&config.bot_username.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let _ = channel_host::workspace_write(
|
||||||
|
RESPOND_TO_ALL_GROUP_PATH,
|
||||||
|
&config.respond_to_all_group_messages.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
// Mode is determined by whether the host injected a tunnel_url
|
// Mode is determined by whether the host injected a tunnel_url
|
||||||
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
||||||
let webhook_mode = config.tunnel_url.is_some();
|
let webhook_mode = config.tunnel_url.is_some();
|
||||||
@@ -388,7 +437,9 @@ impl Guest for TelegramChannel {
|
|||||||
|
|
||||||
let headers = serde_json::json!({});
|
let headers = serde_json::json!({});
|
||||||
|
|
||||||
let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
|
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll
|
||||||
|
let result =
|
||||||
|
channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000));
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
@@ -461,72 +512,52 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||||
// Parse metadata to get chat info
|
|
||||||
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||||
|
|
||||||
// Build sendMessage payload
|
// Try sending with Markdown first; fall back to plain text if Telegram
|
||||||
let mut payload = serde_json::json!({
|
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
|
||||||
"chat_id": metadata.chat_id,
|
let result = send_message(
|
||||||
"text": response.content,
|
metadata.chat_id,
|
||||||
"parse_mode": "Markdown",
|
&response.content,
|
||||||
});
|
metadata.message_id,
|
||||||
|
Some("Markdown"),
|
||||||
// Reply to the original message for context
|
|
||||||
payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into());
|
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&payload)
|
|
||||||
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
|
||||||
|
|
||||||
// Make HTTP request to Telegram API
|
|
||||||
// The bot token is injected into the URL by the host
|
|
||||||
let headers = serde_json::json!({
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = channel_host::http_request(
|
|
||||||
"POST",
|
|
||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
||||||
&headers.to_string(),
|
|
||||||
Some(&payload_bytes),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(http_response) => {
|
Ok(msg_id) => {
|
||||||
if http_response.status != 200 {
|
|
||||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
||||||
return Err(format!(
|
|
||||||
"Telegram API returned status {}: {}",
|
|
||||||
http_response.status, body_str
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Telegram response
|
|
||||||
let api_response: TelegramApiResponse<SentMessage> =
|
|
||||||
serde_json::from_slice(&http_response.body)
|
|
||||||
.map_err(|e| format!("Failed to parse Telegram response: {}", e))?;
|
|
||||||
|
|
||||||
if !api_response.ok {
|
|
||||||
return Err(format!(
|
|
||||||
"Telegram API error: {}",
|
|
||||||
api_response
|
|
||||||
.description
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Debug,
|
channel_host::LogLevel::Debug,
|
||||||
&format!(
|
&format!(
|
||||||
"Sent message to chat {}: message_id={}",
|
"Sent message to chat {}: message_id={}",
|
||||||
metadata.chat_id,
|
metadata.chat_id, msg_id
|
||||||
api_response.result.map(|r| r.message_id).unwrap_or(0)
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
Err(SendError::ParseEntities(detail)) => {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Warn,
|
||||||
|
&format!("Markdown parse failed ({}), retrying as plain text", detail),
|
||||||
|
);
|
||||||
|
let msg_id = send_message(
|
||||||
|
metadata.chat_id,
|
||||||
|
&response.content,
|
||||||
|
metadata.message_id,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
|
||||||
|
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Debug,
|
||||||
|
&format!(
|
||||||
|
"Sent plain-text message to chat {}: message_id={}",
|
||||||
|
metadata.chat_id, msg_id
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +599,7 @@ impl Guest for TelegramChannel {
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -586,6 +618,101 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Send Message Helper
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Errors from send_message, split so callers can match on parse-entity failures.
|
||||||
|
enum SendError {
|
||||||
|
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
|
||||||
|
ParseEntities(String),
|
||||||
|
/// Any other failure.
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SendError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
|
||||||
|
SendError::Other(msg) => write!(f, "{}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message via the Telegram Bot API.
|
||||||
|
///
|
||||||
|
/// Returns the sent message_id on success. When `parse_mode` is set and
|
||||||
|
/// Telegram returns a 400 "can't parse entities" error, returns
|
||||||
|
/// `SendError::ParseEntities` so the caller can retry without formatting.
|
||||||
|
fn send_message(
|
||||||
|
chat_id: i64,
|
||||||
|
text: &str,
|
||||||
|
reply_to_message_id: i64,
|
||||||
|
parse_mode: Option<&str>,
|
||||||
|
) -> Result<i64, SendError> {
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"reply_to_message_id": reply_to_message_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(mode) = parse_mode {
|
||||||
|
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_bytes = serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
|
||||||
|
|
||||||
|
let headers = serde_json::json!({ "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
let result = channel_host::http_request(
|
||||||
|
"POST",
|
||||||
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
&headers.to_string(),
|
||||||
|
Some(&payload_bytes),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(http_response) => {
|
||||||
|
if http_response.status == 400 {
|
||||||
|
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||||
|
if body_str.contains("can't parse entities") {
|
||||||
|
return Err(SendError::ParseEntities(body_str.to_string()));
|
||||||
|
}
|
||||||
|
return Err(SendError::Other(format!(
|
||||||
|
"Telegram API returned 400: {}",
|
||||||
|
body_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if http_response.status != 200 {
|
||||||
|
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||||
|
return Err(SendError::Other(format!(
|
||||||
|
"Telegram API returned status {}: {}",
|
||||||
|
http_response.status, body_str
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_response: TelegramApiResponse<SentMessage> =
|
||||||
|
serde_json::from_slice(&http_response.body)
|
||||||
|
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
|
||||||
|
|
||||||
|
if !api_response.ok {
|
||||||
|
return Err(SendError::Other(format!(
|
||||||
|
"Telegram API error: {}",
|
||||||
|
api_response
|
||||||
|
.description
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
|
||||||
|
}
|
||||||
|
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Webhook Management
|
// Webhook Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -604,6 +731,7 @@ fn delete_webhook() -> Result<(), String> {
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -666,6 +794,7 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&body_bytes),
|
Some(&body_bytes),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -700,6 +829,47 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pairing Reply
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
|
||||||
|
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": format!(
|
||||||
|
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
|
||||||
|
code
|
||||||
|
),
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload_bytes = serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||||
|
|
||||||
|
let headers = serde_json::json!({
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = channel_host::http_request(
|
||||||
|
"POST",
|
||||||
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
&headers.to_string(),
|
||||||
|
Some(&payload_bytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status != 200 {
|
||||||
|
let body_str = String::from_utf8_lossy(&response.body);
|
||||||
|
return Err(format!("HTTP {}: {}", response.status, body_str));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Update Handling
|
// Update Handling
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -719,11 +889,16 @@ fn handle_update(update: TelegramUpdate) {
|
|||||||
|
|
||||||
/// Process a single message.
|
/// Process a single message.
|
||||||
fn handle_message(message: TelegramMessage) {
|
fn handle_message(message: TelegramMessage) {
|
||||||
// Skip messages without text
|
// Use text or caption (for media messages)
|
||||||
let text = match message.text {
|
let content = message
|
||||||
Some(t) if !t.is_empty() => t,
|
.text
|
||||||
_ => return,
|
.filter(|t| !t.is_empty())
|
||||||
};
|
.or_else(|| message.caption.filter(|c| !c.is_empty()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if content.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip messages without a sender (channel posts)
|
// Skip messages without a sender (channel posts)
|
||||||
let from = match message.from {
|
let from = match message.from {
|
||||||
@@ -736,41 +911,111 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner validation: silently drop messages from non-owner users
|
let is_private = message.chat.chat_type == "private";
|
||||||
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
|
|
||||||
if !owner_id_str.is_empty() {
|
// Owner validation: when owner_id is set, only that user can message
|
||||||
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
|
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
|
||||||
if from.id != owner_id {
|
.map(|s| !s.is_empty())
|
||||||
channel_host::log(
|
.unwrap_or(false);
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
&format!(
|
if owner_configured {
|
||||||
"Dropping message from non-owner user {} (owner: {})",
|
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
|
||||||
from.id, owner_id
|
.unwrap()
|
||||||
),
|
.parse::<i64>()
|
||||||
);
|
{
|
||||||
return;
|
if from.id != owner_id {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Debug,
|
||||||
|
&format!(
|
||||||
|
"Dropping message from non-owner user {} (owner: {})",
|
||||||
|
from.id, owner_id
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if is_private {
|
||||||
|
// No owner_id: apply dm_policy for private chats
|
||||||
|
let dm_policy = channel_host::workspace_read(DM_POLICY_PATH)
|
||||||
|
.unwrap_or_else(|| "pairing".to_string());
|
||||||
|
|
||||||
|
if dm_policy != "open" {
|
||||||
|
// Build effective allow list: config allow_from + pairing store
|
||||||
|
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||||
|
allowed.extend(store_allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_str = from.id.to_string();
|
||||||
|
let username_opt = from.username.as_deref();
|
||||||
|
let is_allowed = allowed.contains(&"*".to_string())
|
||||||
|
|| allowed.contains(&id_str)
|
||||||
|
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
|
||||||
|
|
||||||
|
if !is_allowed {
|
||||||
|
if dm_policy == "pairing" {
|
||||||
|
// Upsert pairing request and send reply
|
||||||
|
let meta = serde_json::json!({
|
||||||
|
"chat_id": message.chat.id,
|
||||||
|
"user_id": from.id,
|
||||||
|
"username": username_opt,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
|
||||||
|
Ok(result) => {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Info,
|
||||||
|
&format!(
|
||||||
|
"Pairing request for user {} (chat {}): code {}",
|
||||||
|
from.id, message.chat.id, result.code
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if result.created {
|
||||||
|
let _ = send_pairing_reply(message.chat.id, &result.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Error,
|
||||||
|
&format!("Pairing upsert failed: {}", e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_private = message.chat.chat_type == "private";
|
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
|
||||||
|
|
||||||
// For group chats, check if the bot was mentioned
|
|
||||||
// TODO: Read bot_username from config and check mentions
|
|
||||||
// For now, process all messages in private chats and groups
|
|
||||||
if !is_private {
|
if !is_private {
|
||||||
// In groups, only respond if there's a bot mention or command
|
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
|
||||||
// This is a simplified check - proper implementation would use entities
|
.as_deref()
|
||||||
let has_command = text.starts_with('/');
|
.unwrap_or("false")
|
||||||
let has_mention = text.contains('@');
|
== "true";
|
||||||
|
|
||||||
if !has_command && !has_mention {
|
if !respond_to_all {
|
||||||
channel_host::log(
|
let has_command = content.starts_with('/');
|
||||||
channel_host::LogLevel::Debug,
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
|
||||||
&format!("Ignoring group message without mention: {}", text),
|
.unwrap_or_default();
|
||||||
);
|
let has_bot_mention = if bot_username.is_empty() {
|
||||||
return;
|
content.contains('@')
|
||||||
|
} else {
|
||||||
|
let mention = format!("@{}", bot_username);
|
||||||
|
content.to_lowercase().contains(&mention.to_lowercase())
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_command && !has_bot_mention {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Debug,
|
||||||
|
&format!("Ignoring group message without mention: {}", content),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,17 +1037,30 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
|
||||||
// Clean the message text (strip bot mentions and commands)
|
// Clean the message text (strip bot mentions and commands)
|
||||||
let cleaned_text = clean_message_text(&text);
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||||
|
let cleaned_text = clean_message_text(
|
||||||
|
&content,
|
||||||
|
if bot_username.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(bot_username.as_str())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if cleaned_text.is_empty() {
|
// For /start with no args, emit placeholder so agent can respond with welcome
|
||||||
|
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
|
||||||
|
"[User started the bot]".to_string()
|
||||||
|
} else if cleaned_text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
} else {
|
||||||
|
cleaned_text
|
||||||
|
};
|
||||||
|
|
||||||
// Emit the message to the agent
|
// Emit the message to the agent
|
||||||
channel_host::emit_message(&EmittedMessage {
|
channel_host::emit_message(&EmittedMessage {
|
||||||
user_id: from.id.to_string(),
|
user_id: from.id.to_string(),
|
||||||
user_name: Some(user_name),
|
user_name: Some(user_name),
|
||||||
content: cleaned_text,
|
content: content_to_emit,
|
||||||
thread_id: None, // Telegram doesn't have threads in the same way
|
thread_id: None, // Telegram doesn't have threads in the same way
|
||||||
metadata_json,
|
metadata_json,
|
||||||
});
|
});
|
||||||
@@ -817,7 +1075,8 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean message text by removing bot commands and @mentions at the start.
|
/// Clean message text by removing bot commands and @mentions at the start.
|
||||||
fn clean_message_text(text: &str) -> String {
|
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
|
||||||
|
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
||||||
let mut result = text.trim().to_string();
|
let mut result = text.trim().to_string();
|
||||||
|
|
||||||
// Remove leading /command
|
// Remove leading /command
|
||||||
@@ -832,11 +1091,30 @@ fn clean_message_text(text: &str) -> String {
|
|||||||
|
|
||||||
// Remove leading @mention
|
// Remove leading @mention
|
||||||
if result.starts_with('@') {
|
if result.starts_with('@') {
|
||||||
if let Some(space_idx) = result.find(' ') {
|
if let Some(bot) = bot_username {
|
||||||
result = result[space_idx..].trim_start().to_string();
|
let mention = format!("@{}", bot);
|
||||||
|
let mention_lower = mention.to_lowercase();
|
||||||
|
let result_lower = result.to_lowercase();
|
||||||
|
if result_lower.starts_with(&mention_lower) {
|
||||||
|
let rest = result[mention.len()..].trim_start();
|
||||||
|
if rest.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
result = rest.to_string();
|
||||||
|
} else if let Some(space_idx) = result.find(' ') {
|
||||||
|
// Different leading @mention - only strip if it's the bot
|
||||||
|
let first_word = &result[..space_idx];
|
||||||
|
if first_word.eq_ignore_ascii_case(&mention) {
|
||||||
|
result = result[space_idx..].trim_start().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Just a mention with no text
|
// No bot_username: strip any leading @mention
|
||||||
return String::new();
|
if let Some(space_idx) = result.find(' ') {
|
||||||
|
result = result[space_idx..].trim_start().to_string();
|
||||||
|
} else {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,12 +1150,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clean_message_text() {
|
fn test_clean_message_text() {
|
||||||
assert_eq!(clean_message_text("/start hello"), "hello");
|
// Without bot_username: strips any leading @mention
|
||||||
assert_eq!(clean_message_text("@bot hello world"), "hello world");
|
assert_eq!(clean_message_text("/start hello", None), "hello");
|
||||||
assert_eq!(clean_message_text("/start"), "");
|
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
|
||||||
assert_eq!(clean_message_text("@botname"), "");
|
assert_eq!(clean_message_text("/start", None), "");
|
||||||
assert_eq!(clean_message_text("just text"), "just text");
|
assert_eq!(clean_message_text("@botname", None), "");
|
||||||
assert_eq!(clean_message_text(" spaced "), "spaced");
|
assert_eq!(clean_message_text("just text", None), "just text");
|
||||||
|
assert_eq!(clean_message_text(" spaced ", None), "spaced");
|
||||||
|
|
||||||
|
// With bot_username: only strips @MyBot, not @alice
|
||||||
|
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
|
||||||
|
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
|
||||||
|
assert_eq!(
|
||||||
|
clean_message_text("@alice hello", Some("MyBot")),
|
||||||
|
"@alice hello"
|
||||||
|
);
|
||||||
|
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -945,4 +1233,17 @@ mod tests {
|
|||||||
assert_eq!(from.id, 789);
|
assert_eq!(from.id, 789);
|
||||||
assert_eq!(from.first_name, "John");
|
assert_eq!(from.first_name, "John");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_message_with_caption() {
|
||||||
|
let json = r#"{
|
||||||
|
"message_id": 1,
|
||||||
|
"from": {"id": 1, "is_bot": false, "first_name": "A"},
|
||||||
|
"chat": {"id": 1, "type": "private"},
|
||||||
|
"caption": "What's in this image?"
|
||||||
|
}"#;
|
||||||
|
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(msg.text, None);
|
||||||
|
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1 @@
|
|||||||
{
|
{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}}
|
||||||
"type": "channel",
|
|
||||||
"name": "telegram",
|
|
||||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
|
||||||
"capabilities": {
|
|
||||||
"http": {
|
|
||||||
"allowlist": [
|
|
||||||
{ "host": "api.telegram.org", "path_prefix": "/bot" }
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"telegram_bot": {
|
|
||||||
"secret_name": "telegram_bot_token",
|
|
||||||
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
|
|
||||||
"host_patterns": ["api.telegram.org"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rate_limit": {
|
|
||||||
"requests_per_minute": 30,
|
|
||||||
"requests_per_hour": 1000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secrets": {
|
|
||||||
"allowed_names": ["telegram_*"]
|
|
||||||
},
|
|
||||||
"channel": {
|
|
||||||
"allowed_paths": ["/webhook/telegram"],
|
|
||||||
"allow_polling": true,
|
|
||||||
"min_poll_interval_ms": 30000,
|
|
||||||
"workspace_prefix": "channels/telegram/",
|
|
||||||
"emit_rate_limit": {
|
|
||||||
"messages_per_minute": 100,
|
|
||||||
"messages_per_hour": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"bot_username": null,
|
|
||||||
"owner_id": null,
|
|
||||||
"respond_to_all_group_messages": false,
|
|
||||||
"polling_enabled": false,
|
|
||||||
"poll_interval_ms": 30000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
// Re-export generated types
|
// Re-export generated types
|
||||||
use exports::near::agent::channel::{
|
use exports::near::agent::channel::{
|
||||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||||
OutgoingHttpResponse,
|
OutgoingHttpResponse, StatusUpdate,
|
||||||
};
|
};
|
||||||
use near::agent::channel_host::{self, EmittedMessage};
|
use near::agent::channel_host::{self, EmittedMessage};
|
||||||
|
|
||||||
@@ -361,6 +361,7 @@ impl Guest for WhatsAppChannel {
|
|||||||
&api_url,
|
&api_url,
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -417,6 +418,8 @@ impl Guest for WhatsAppChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_status(_update: StatusUpdate) {}
|
||||||
|
|
||||||
fn on_shutdown() {
|
fn on_shutdown() {
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Info,
|
channel_host::LogLevel::Info,
|
||||||
@@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`:
|
|||||||
|
|
||||||
## Building and Deploying
|
## Building and Deploying
|
||||||
|
|
||||||
|
### Supply Chain Security: No Committed Binaries
|
||||||
|
|
||||||
|
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
|
||||||
|
|
||||||
|
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
|
||||||
|
- The built binary is in `.gitignore` and is not committed
|
||||||
|
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
|
||||||
|
|
||||||
|
**Reproducible build:**
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
|
||||||
|
|
||||||
|
### Telegram Channel (Manual Build)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add WASM target if needed
|
||||||
|
rustup target add wasm32-wasip2
|
||||||
|
|
||||||
|
# Build Telegram channel
|
||||||
|
./channels-src/telegram/build.sh
|
||||||
|
|
||||||
|
# Install (or use ironclaw onboard to install bundled channel)
|
||||||
|
mkdir -p ~/.ironclaw/channels
|
||||||
|
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
|
||||||
|
|
||||||
|
### Other Channels
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the WASM component
|
# Build the WASM component
|
||||||
cd channels/my-channel
|
cd channels-src/my-channel
|
||||||
cargo component build --release
|
cargo build --release --target wasm32-wasip2
|
||||||
|
|
||||||
# Deploy to ~/.ironclaw/channels/
|
# Deploy to ~/.ironclaw/channels/
|
||||||
cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||||
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Telegram Channel Setup
|
||||||
|
|
||||||
|
This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports:
|
||||||
|
|
||||||
|
- **Webhook mode** (recommended): Instant delivery via tunnel
|
||||||
|
- **Polling mode**: No tunnel required; ~30s delay
|
||||||
|
- **DM pairing**: Approve unknown users before they can message the agent
|
||||||
|
- **Group mentions**: `@YourBot` or `/command` to trigger in groups
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- IronClaw installed and configured (`ironclaw onboard`)
|
||||||
|
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create a Bot
|
||||||
|
|
||||||
|
1. Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||||
|
2. Send `/newbot` and follow the prompts
|
||||||
|
3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
### 2. Configure via Setup Wizard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ironclaw onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
When prompted, enable the Telegram channel and paste your bot token. The wizard will:
|
||||||
|
|
||||||
|
- Validate the token
|
||||||
|
- Optionally configure a webhook secret
|
||||||
|
- Set up tunnel (if you want webhook mode)
|
||||||
|
|
||||||
|
### 3. (Optional) Configure Tunnel for Webhooks
|
||||||
|
|
||||||
|
For instant message delivery, expose your agent via a tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ngrok
|
||||||
|
ngrok http 8080
|
||||||
|
|
||||||
|
# Cloudflare
|
||||||
|
cloudflared tunnel --url http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay).
|
||||||
|
|
||||||
|
## DM Pairing
|
||||||
|
|
||||||
|
When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Unknown user sends a message to your bot
|
||||||
|
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
|
||||||
|
3. You run: `ironclaw pairing approve telegram ABC12345`
|
||||||
|
4. User is added to the allow list; future messages are delivered
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List pending pairing requests
|
||||||
|
ironclaw pairing list telegram
|
||||||
|
|
||||||
|
# List as JSON
|
||||||
|
ironclaw pairing list telegram --json
|
||||||
|
|
||||||
|
# Approve a user by code
|
||||||
|
ironclaw pairing approve telegram ABC12345
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host):
|
||||||
|
|
||||||
|
| Option | Values | Default | Description |
|
||||||
|
|--------|--------|---------|-------------|
|
||||||
|
| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown |
|
||||||
|
| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. |
|
||||||
|
| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) |
|
||||||
|
| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages |
|
||||||
|
| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands |
|
||||||
|
|
||||||
|
## Manual Installation
|
||||||
|
|
||||||
|
If the channel isn't installed via the wizard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the Telegram channel (requires wasm32-wasip2 target)
|
||||||
|
rustup target add wasm32-wasip2
|
||||||
|
./channels-src/telegram/build.sh
|
||||||
|
|
||||||
|
# Install
|
||||||
|
mkdir -p ~/.ironclaw/channels
|
||||||
|
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
The channel expects a secret named `telegram_bot_token`. Configure via:
|
||||||
|
|
||||||
|
- **Setup wizard**: Saves to encrypted secrets store
|
||||||
|
- **Environment**: `TELEGRAM_BOT_TOKEN=your_token`
|
||||||
|
- **Secrets store**: `ironclaw` CLI (if available)
|
||||||
|
|
||||||
|
## Webhook Secret (Optional)
|
||||||
|
|
||||||
|
For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Messages not delivered
|
||||||
|
|
||||||
|
- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid.
|
||||||
|
- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS.
|
||||||
|
|
||||||
|
### Pairing code not received
|
||||||
|
|
||||||
|
- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`)
|
||||||
|
- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply)
|
||||||
|
|
||||||
|
### Group mentions not working
|
||||||
|
|
||||||
|
- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`)
|
||||||
|
- Ensure the message contains `@YourBot` or starts with `/`
|
||||||
|
|
||||||
|
### "Connection refused" when starting
|
||||||
|
|
||||||
|
- For webhook mode: Start your tunnel before `ironclaw run`
|
||||||
|
- For polling only: No tunnel needed; ignore tunnel-related warnings
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Standalone heartbeat test.
|
||||||
|
//!
|
||||||
|
//! Exercises the heartbeat system in isolation: connects to the real
|
||||||
|
//! database, reads the real HEARTBEAT.md, calls the real LLM, and prints
|
||||||
|
//! every step so you can see exactly where it breaks.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example test_heartbeat
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ironclaw::{
|
||||||
|
agent::HeartbeatRunner,
|
||||||
|
config::Config,
|
||||||
|
history::Store,
|
||||||
|
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
||||||
|
workspace::Workspace,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Load .env and set up logging
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter("ironclaw=debug")
|
||||||
|
.init();
|
||||||
|
|
||||||
|
println!("=== Heartbeat Integration Test ===\n");
|
||||||
|
|
||||||
|
// 1. Load config
|
||||||
|
let config = Config::from_env()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Config: {}", e))?;
|
||||||
|
println!("[1/6] Config loaded");
|
||||||
|
println!(" heartbeat.enabled = {}", config.heartbeat.enabled);
|
||||||
|
println!(
|
||||||
|
" heartbeat.interval_secs = {}",
|
||||||
|
config.heartbeat.interval_secs
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" heartbeat.notify_channel = {:?}",
|
||||||
|
config.heartbeat.notify_channel
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" heartbeat.notify_user = {:?}",
|
||||||
|
config.heartbeat.notify_user
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Connect to database
|
||||||
|
let store = Store::new(&config.database).await?;
|
||||||
|
store.run_migrations().await?;
|
||||||
|
println!("[2/6] Database connected");
|
||||||
|
|
||||||
|
// 3. Create workspace
|
||||||
|
let workspace = Arc::new(Workspace::new("default", store.pool()));
|
||||||
|
println!("[3/6] Workspace created");
|
||||||
|
|
||||||
|
// 4. Read HEARTBEAT.md
|
||||||
|
let checklist = workspace.heartbeat_checklist().await;
|
||||||
|
match &checklist {
|
||||||
|
Ok(Some(content)) => {
|
||||||
|
let preview: String = content.chars().take(200).collect();
|
||||||
|
println!("[4/6] HEARTBEAT.md found ({} chars)", content.len());
|
||||||
|
println!(" Preview: {}...", preview);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
println!("[4/6] HEARTBEAT.md is None (no file, no seed fallback)");
|
||||||
|
println!(" Heartbeat will return Skipped.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("[4/6] HEARTBEAT.md read error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the checklist would be considered "effectively empty"
|
||||||
|
if let Ok(Some(_)) = checklist {
|
||||||
|
println!(" (Will verify via runner below)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create LLM provider
|
||||||
|
let session = create_session_manager(SessionConfig {
|
||||||
|
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||||
|
session_path: config.llm.nearai.session_path.clone(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let llm = create_llm_provider(&config.llm, session)?;
|
||||||
|
println!("[5/6] LLM provider created (model: {})", llm.model_name());
|
||||||
|
|
||||||
|
// 6. Run heartbeat check
|
||||||
|
println!("[6/6] Running check_heartbeat()...\n");
|
||||||
|
|
||||||
|
let hb_config = ironclaw::agent::HeartbeatConfig::default();
|
||||||
|
let runner = HeartbeatRunner::new(hb_config, workspace, llm);
|
||||||
|
|
||||||
|
let result = runner.check_heartbeat().await;
|
||||||
|
|
||||||
|
println!("=== Result ===\n");
|
||||||
|
match &result {
|
||||||
|
ironclaw::agent::HeartbeatResult::Ok => {
|
||||||
|
println!("HeartbeatResult::Ok");
|
||||||
|
println!(" LLM responded HEARTBEAT_OK, nothing needs attention.");
|
||||||
|
}
|
||||||
|
ironclaw::agent::HeartbeatResult::NeedsAttention(msg) => {
|
||||||
|
println!("HeartbeatResult::NeedsAttention");
|
||||||
|
println!(" Message:\n{}", msg);
|
||||||
|
}
|
||||||
|
ironclaw::agent::HeartbeatResult::Skipped => {
|
||||||
|
println!("HeartbeatResult::Skipped");
|
||||||
|
println!(" No checklist found, or checklist was effectively empty.");
|
||||||
|
println!(" This means the HEARTBEAT.md either:");
|
||||||
|
println!(" - Does not exist in the workspace database");
|
||||||
|
println!(" - Contains only headers, comments, and empty checkboxes");
|
||||||
|
}
|
||||||
|
ironclaw::agent::HeartbeatResult::Failed(err) => {
|
||||||
|
println!("HeartbeatResult::Failed");
|
||||||
|
println!(" Error: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"http": {
|
|
||||||
"allowlist": [
|
|
||||||
{
|
|
||||||
"host": "slack.com",
|
|
||||||
"path_prefix": "/api/",
|
|
||||||
"methods": ["GET", "POST"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"slack_bot_token": {
|
|
||||||
"secret_name": "slack_bot_token",
|
|
||||||
"location": { "type": "bearer" },
|
|
||||||
"host_patterns": ["slack.com"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rate_limit": {
|
|
||||||
"requests_per_minute": 50,
|
|
||||||
"requests_per_hour": 1000
|
|
||||||
},
|
|
||||||
"timeout_secs": 30
|
|
||||||
},
|
|
||||||
"secrets": {
|
|
||||||
"allowed_names": ["slack_bot_token"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Add project_dir and user_id columns for sandbox job tracking.
|
||||||
|
-- user_id was previously hardcoded to "default" in the Rust layer;
|
||||||
|
-- now it's persisted so we can filter per-user.
|
||||||
|
|
||||||
|
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT;
|
||||||
|
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Track which mode a sandbox job uses (worker vs claude_code).
|
||||||
|
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker';
|
||||||
|
|
||||||
|
-- Persist Claude Code streaming events so they survive restarts and can be
|
||||||
|
-- loaded when the frontend opens a job detail view after the fact.
|
||||||
|
CREATE TABLE IF NOT EXISTS claude_code_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
job_id UUID NOT NULL REFERENCES agent_jobs(id),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Routines: scheduled and reactive job system.
|
||||||
|
--
|
||||||
|
-- A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||||
|
-- Triggers fire independently (cron, event, webhook, manual) so only the
|
||||||
|
-- relevant routine's prompt hits the LLM, not the whole checklist.
|
||||||
|
|
||||||
|
CREATE TABLE routines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Trigger definition
|
||||||
|
trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual'
|
||||||
|
trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.)
|
||||||
|
|
||||||
|
-- Action definition
|
||||||
|
action_type TEXT NOT NULL, -- 'lightweight', 'full_job'
|
||||||
|
action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations
|
||||||
|
|
||||||
|
-- Guardrails
|
||||||
|
cooldown_secs INTEGER NOT NULL DEFAULT 300,
|
||||||
|
max_concurrent INTEGER NOT NULL DEFAULT 1,
|
||||||
|
dedup_window_secs INTEGER, -- NULL = no dedup
|
||||||
|
|
||||||
|
-- Notification preferences
|
||||||
|
notify_channel TEXT, -- NULL = use default
|
||||||
|
notify_user TEXT NOT NULL DEFAULT 'default',
|
||||||
|
notify_on_success BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
notify_on_attention BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
-- Runtime state (updated by engine)
|
||||||
|
state JSONB NOT NULL DEFAULT '{}',
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers
|
||||||
|
run_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fast lookup: "which cron routines need to fire right now?"
|
||||||
|
CREATE INDEX idx_routines_next_fire
|
||||||
|
ON routines (next_fire_at)
|
||||||
|
WHERE enabled AND next_fire_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Fast lookup: event triggers for a user
|
||||||
|
CREATE INDEX idx_routines_event_triggers
|
||||||
|
ON routines (user_id)
|
||||||
|
WHERE enabled AND trigger_type = 'event';
|
||||||
|
|
||||||
|
-- Audit log of individual routine executions.
|
||||||
|
CREATE TABLE routine_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
|
||||||
|
trigger_type TEXT NOT NULL,
|
||||||
|
trigger_detail TEXT, -- e.g. matched message preview, cron expression
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed
|
||||||
|
result_summary TEXT,
|
||||||
|
tokens_used INTEGER,
|
||||||
|
job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id);
|
||||||
|
CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Rename claude_code_events to job_events (generic for all sandbox job types).
|
||||||
|
ALTER TABLE claude_code_events RENAME TO job_events;
|
||||||
|
ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Settings table: key-value store for all user configuration.
|
||||||
|
--
|
||||||
|
-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json.
|
||||||
|
-- Keys use dotted paths matching the existing Settings.get()/set() convention
|
||||||
|
-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers").
|
||||||
|
-- One row per setting so individual values can be updated atomically.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[workspace]
|
||||||
|
git_release_enable = false
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build IronClaw and all bundled channels.
|
||||||
|
#
|
||||||
|
# Run this before release or when channel sources have changed.
|
||||||
|
# The main binary bundles telegram.wasm via include_bytes!; it must exist.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "Building bundled channels..."
|
||||||
|
if [ -d "channels-src/telegram" ]; then
|
||||||
|
./channels-src/telegram/build.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building IronClaw..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Binary: target/release/ironclaw"
|
||||||
+1031
-613
File diff suppressed because it is too large
Load Diff
+177
-4
@@ -29,7 +29,7 @@ use std::time::Duration;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::channels::OutgoingResponse;
|
use crate::channels::OutgoingResponse;
|
||||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
/// Configuration for the heartbeat runner.
|
/// Configuration for the heartbeat runner.
|
||||||
@@ -178,7 +178,7 @@ impl HeartbeatRunner {
|
|||||||
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
||||||
// Get the heartbeat checklist
|
// Get the heartbeat checklist
|
||||||
let checklist = match self.workspace.heartbeat_checklist().await {
|
let checklist = match self.workspace.heartbeat_checklist().await {
|
||||||
Ok(Some(content)) if !content.trim().is_empty() => content,
|
Ok(Some(content)) if !is_effectively_empty(&content) => content,
|
||||||
Ok(_) => return HeartbeatResult::Skipped,
|
Ok(_) => return HeartbeatResult::Skipped,
|
||||||
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
|
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
|
||||||
};
|
};
|
||||||
@@ -217,9 +217,26 @@ impl HeartbeatRunner {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use the model's context_length to set max_tokens. The API returns
|
||||||
|
// the total context window; we cap output at half of that (the rest is
|
||||||
|
// the prompt) with a floor of 4096.
|
||||||
|
let max_tokens = match self.llm.model_metadata().await {
|
||||||
|
Ok(meta) => {
|
||||||
|
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
|
||||||
|
from_api.max(4096)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not fetch model metadata, using default max_tokens: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let request = CompletionRequest::new(messages)
|
let request = CompletionRequest::new(messages)
|
||||||
.with_max_tokens(1024)
|
.with_max_tokens(max_tokens)
|
||||||
.with_temperature(0.3); // Lower temperature for more focused responses
|
.with_temperature(0.3);
|
||||||
|
|
||||||
let response = match self.llm.complete(request).await {
|
let response = match self.llm.complete(request).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -228,6 +245,20 @@ impl HeartbeatRunner {
|
|||||||
|
|
||||||
let content = response.content.trim();
|
let content = response.content.trim();
|
||||||
|
|
||||||
|
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
|
||||||
|
// burn all output tokens on chain-of-thought and return content: null.
|
||||||
|
if content.is_empty() {
|
||||||
|
return if response.finish_reason == FinishReason::Length {
|
||||||
|
HeartbeatResult::Failed(
|
||||||
|
"LLM response was truncated (finish_reason=length) with no content. \
|
||||||
|
The model may have exhausted its token budget on reasoning."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HeartbeatResult::Failed("LLM returned empty content.".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Check if nothing needs attention
|
// Check if nothing needs attention
|
||||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
||||||
return HeartbeatResult::Ok;
|
return HeartbeatResult::Ok;
|
||||||
@@ -257,6 +288,45 @@ impl HeartbeatRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if heartbeat content is effectively empty.
|
||||||
|
///
|
||||||
|
/// Returns true if the content contains only:
|
||||||
|
/// - Whitespace
|
||||||
|
/// - Markdown headers (lines starting with #)
|
||||||
|
/// - HTML comments (`<!-- ... -->`)
|
||||||
|
/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`)
|
||||||
|
///
|
||||||
|
/// This skips the LLM call when the user hasn't added real tasks yet,
|
||||||
|
/// saving API costs.
|
||||||
|
fn is_effectively_empty(content: &str) -> bool {
|
||||||
|
let without_comments = strip_html_comments(content);
|
||||||
|
|
||||||
|
without_comments.lines().all(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
trimmed.is_empty()
|
||||||
|
|| trimmed.starts_with('#')
|
||||||
|
|| trimmed == "- [ ]"
|
||||||
|
|| trimmed == "- [x]"
|
||||||
|
|| trimmed == "-"
|
||||||
|
|| trimmed == "*"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove HTML comments from content.
|
||||||
|
fn strip_html_comments(content: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(content.len());
|
||||||
|
let mut rest = content;
|
||||||
|
while let Some(start) = rest.find("<!--") {
|
||||||
|
result.push_str(&rest[..start]);
|
||||||
|
match rest[start..].find("-->") {
|
||||||
|
Some(end) => rest = &rest[start + end + 3..],
|
||||||
|
None => return result, // unclosed comment, treat rest as comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(rest);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the heartbeat runner as a background task.
|
/// Spawn the heartbeat runner as a background task.
|
||||||
///
|
///
|
||||||
/// Returns a handle that can be used to stop the runner.
|
/// Returns a handle that can be used to stop the runner.
|
||||||
@@ -301,4 +371,107 @@ mod tests {
|
|||||||
let disabled = HeartbeatConfig::default().disabled();
|
let disabled = HeartbeatConfig::default().disabled();
|
||||||
assert!(!disabled.enabled);
|
assert!(!disabled.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== strip_html_comments ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_html_comments_no_comments() {
|
||||||
|
assert_eq!(strip_html_comments("hello world"), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_html_comments_single() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_html_comments("before<!-- gone -->after"),
|
||||||
|
"beforeafter"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_html_comments_multiple() {
|
||||||
|
let input = "a<!-- 1 -->b<!-- 2 -->c";
|
||||||
|
assert_eq!(strip_html_comments(input), "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_html_comments_multiline() {
|
||||||
|
let input = "# Title\n<!-- multi\nline\ncomment -->\nreal content";
|
||||||
|
assert_eq!(strip_html_comments(input), "# Title\n\nreal content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_html_comments_unclosed() {
|
||||||
|
let input = "before<!-- never closed";
|
||||||
|
assert_eq!(strip_html_comments(input), "before");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== is_effectively_empty ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_empty_string() {
|
||||||
|
assert!(is_effectively_empty(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_whitespace() {
|
||||||
|
assert!(is_effectively_empty(" \n\n \n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_headers_only() {
|
||||||
|
assert!(is_effectively_empty("# Title\n## Subtitle\n### Section"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_html_comments_only() {
|
||||||
|
assert!(is_effectively_empty("<!-- this is a comment -->"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_empty_checkboxes() {
|
||||||
|
assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_bare_list_markers() {
|
||||||
|
assert!(is_effectively_empty("-\n*\n-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_seeded_template() {
|
||||||
|
let template = "\
|
||||||
|
# Heartbeat Checklist
|
||||||
|
|
||||||
|
<!-- Keep this file empty to skip heartbeat API calls.
|
||||||
|
Add tasks below when you want the agent to check something periodically.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- [ ] Check for unread emails needing a reply
|
||||||
|
- [ ] Review today's calendar for upcoming meetings
|
||||||
|
- [ ] Check CI build status for main branch
|
||||||
|
-->";
|
||||||
|
assert!(is_effectively_empty(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_real_checklist() {
|
||||||
|
let content = "\
|
||||||
|
# Heartbeat Checklist
|
||||||
|
|
||||||
|
- [ ] Check for unread emails needing a reply
|
||||||
|
- [ ] Review today's calendar for upcoming meetings";
|
||||||
|
assert!(!is_effectively_empty(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_mixed_real_and_headers() {
|
||||||
|
let content = "# Title\n\nDo something important";
|
||||||
|
assert!(!is_effectively_empty(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effectively_empty_comment_plus_real_content() {
|
||||||
|
let content = "<!-- comment -->\nActual task here";
|
||||||
|
assert!(!is_effectively_empty(content));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -6,6 +6,7 @@
|
|||||||
//! - Tool invocation with safety
|
//! - Tool invocation with safety
|
||||||
//! - Self-repair for stuck jobs
|
//! - Self-repair for stuck jobs
|
||||||
//! - Proactive heartbeat execution
|
//! - Proactive heartbeat execution
|
||||||
|
//! - Routine-based scheduled and reactive jobs
|
||||||
//! - Turn-based session management with undo
|
//! - Turn-based session management with undo
|
||||||
//! - Context compaction for long conversations
|
//! - Context compaction for long conversations
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ pub mod compaction;
|
|||||||
pub mod context_monitor;
|
pub mod context_monitor;
|
||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
mod router;
|
mod router;
|
||||||
|
pub mod routine;
|
||||||
|
pub mod routine_engine;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod self_repair;
|
mod self_repair;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -28,9 +31,11 @@ pub use compaction::{CompactionResult, ContextCompactor};
|
|||||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||||
pub use router::{MessageIntent, Router};
|
pub use router::{MessageIntent, Router};
|
||||||
|
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||||
|
pub use routine_engine::RoutineEngine;
|
||||||
pub use scheduler::Scheduler;
|
pub use scheduler::Scheduler;
|
||||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||||
pub use session::{PendingApproval, Session, Thread, ThreadState, Turn, TurnState};
|
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||||
pub use session_manager::SessionManager;
|
pub use session_manager::SessionManager;
|
||||||
pub use submission::{Submission, SubmissionParser, SubmissionResult};
|
pub use submission::{Submission, SubmissionParser, SubmissionResult};
|
||||||
pub use task::{Task, TaskContext, TaskHandler, TaskOutput, TaskStatus};
|
pub use task::{Task, TaskContext, TaskHandler, TaskOutput, TaskStatus};
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
//! Core types for the routines system.
|
||||||
|
//!
|
||||||
|
//! A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||||
|
//! Each routine fires independently when its trigger condition is met, with only
|
||||||
|
//! that routine's prompt and context sent to the LLM.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
|
||||||
|
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
|
||||||
|
//! │ cron/event│ │guardrail│ │lightweight│full_job│
|
||||||
|
//! │ webhook │ │ check │ └──────────────────┘
|
||||||
|
//! │ manual │ └─────────┘ │
|
||||||
|
//! └──────────┘ ▼
|
||||||
|
//! ┌──────────────┐
|
||||||
|
//! │ Notify user │
|
||||||
|
//! │ if needed │
|
||||||
|
//! └──────────────┘
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Routine {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub trigger: Trigger,
|
||||||
|
pub action: RoutineAction,
|
||||||
|
pub guardrails: RoutineGuardrails,
|
||||||
|
pub notify: NotifyConfig,
|
||||||
|
|
||||||
|
// Runtime state (DB-managed)
|
||||||
|
pub last_run_at: Option<DateTime<Utc>>,
|
||||||
|
pub next_fire_at: Option<DateTime<Utc>>,
|
||||||
|
pub run_count: u64,
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
pub state: serde_json::Value,
|
||||||
|
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a routine should fire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Trigger {
|
||||||
|
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
||||||
|
Cron { schedule: String },
|
||||||
|
/// Fire when a channel message matches a pattern.
|
||||||
|
Event {
|
||||||
|
/// Optional channel filter (e.g. "telegram", "slack").
|
||||||
|
channel: Option<String>,
|
||||||
|
/// Regex pattern to match against message content.
|
||||||
|
pattern: String,
|
||||||
|
},
|
||||||
|
/// Fire on incoming webhook POST to /hooks/routine/{id}.
|
||||||
|
Webhook {
|
||||||
|
/// Optional webhook path suffix (defaults to routine id).
|
||||||
|
path: Option<String>,
|
||||||
|
/// Optional shared secret for HMAC validation.
|
||||||
|
secret: Option<String>,
|
||||||
|
},
|
||||||
|
/// Only fires via tool call or CLI.
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trigger {
|
||||||
|
/// The string tag stored in the DB trigger_type column.
|
||||||
|
pub fn type_tag(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Trigger::Cron { .. } => "cron",
|
||||||
|
Trigger::Event { .. } => "event",
|
||||||
|
Trigger::Webhook { .. } => "webhook",
|
||||||
|
Trigger::Manual => "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a trigger from its DB representation.
|
||||||
|
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, String> {
|
||||||
|
match trigger_type {
|
||||||
|
"cron" => {
|
||||||
|
let schedule = config
|
||||||
|
.get("schedule")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("cron trigger missing 'schedule'")?
|
||||||
|
.to_string();
|
||||||
|
Ok(Trigger::Cron { schedule })
|
||||||
|
}
|
||||||
|
"event" => {
|
||||||
|
let pattern = config
|
||||||
|
.get("pattern")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("event trigger missing 'pattern'")?
|
||||||
|
.to_string();
|
||||||
|
let channel = config
|
||||||
|
.get("channel")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
Ok(Trigger::Event { channel, pattern })
|
||||||
|
}
|
||||||
|
"webhook" => {
|
||||||
|
let path = config
|
||||||
|
.get("path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let secret = config
|
||||||
|
.get("secret")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
Ok(Trigger::Webhook { path, secret })
|
||||||
|
}
|
||||||
|
"manual" => Ok(Trigger::Manual),
|
||||||
|
other => Err(format!("unknown trigger type: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize trigger-specific config to JSON for DB storage.
|
||||||
|
pub fn to_config_json(&self) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
|
||||||
|
Trigger::Event { channel, pattern } => serde_json::json!({
|
||||||
|
"pattern": pattern,
|
||||||
|
"channel": channel,
|
||||||
|
}),
|
||||||
|
Trigger::Webhook { path, secret } => serde_json::json!({
|
||||||
|
"path": path,
|
||||||
|
"secret": secret,
|
||||||
|
}),
|
||||||
|
Trigger::Manual => serde_json::json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens when a routine fires.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum RoutineAction {
|
||||||
|
/// Single LLM call, no tools. Cheap and fast.
|
||||||
|
Lightweight {
|
||||||
|
/// The prompt sent to the LLM.
|
||||||
|
prompt: String,
|
||||||
|
/// Workspace paths to load as context (e.g. ["context/priorities.md"]).
|
||||||
|
#[serde(default)]
|
||||||
|
context_paths: Vec<String>,
|
||||||
|
/// Max output tokens (default: 4096).
|
||||||
|
#[serde(default = "default_max_tokens")]
|
||||||
|
max_tokens: u32,
|
||||||
|
},
|
||||||
|
/// Full multi-turn worker job with tool access.
|
||||||
|
FullJob {
|
||||||
|
/// Job title for the scheduler.
|
||||||
|
title: String,
|
||||||
|
/// Job description / initial prompt.
|
||||||
|
description: String,
|
||||||
|
/// Max reasoning iterations (default: 10).
|
||||||
|
#[serde(default = "default_max_iterations")]
|
||||||
|
max_iterations: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_tokens() -> u32 {
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_iterations() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutineAction {
|
||||||
|
/// The string tag stored in the DB action_type column.
|
||||||
|
pub fn type_tag(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RoutineAction::Lightweight { .. } => "lightweight",
|
||||||
|
RoutineAction::FullJob { .. } => "full_job",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an action from its DB representation.
|
||||||
|
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, String> {
|
||||||
|
match action_type {
|
||||||
|
"lightweight" => {
|
||||||
|
let prompt = config
|
||||||
|
.get("prompt")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("lightweight action missing 'prompt'")?
|
||||||
|
.to_string();
|
||||||
|
let context_paths = config
|
||||||
|
.get("context_paths")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let max_tokens = config
|
||||||
|
.get("max_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(default_max_tokens() as u64) as u32;
|
||||||
|
Ok(RoutineAction::Lightweight {
|
||||||
|
prompt,
|
||||||
|
context_paths,
|
||||||
|
max_tokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"full_job" => {
|
||||||
|
let title = config
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("full_job action missing 'title'")?
|
||||||
|
.to_string();
|
||||||
|
let description = config
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("full_job action missing 'description'")?
|
||||||
|
.to_string();
|
||||||
|
let max_iterations = config
|
||||||
|
.get("max_iterations")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(default_max_iterations() as u64)
|
||||||
|
as u32;
|
||||||
|
Ok(RoutineAction::FullJob {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
max_iterations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => Err(format!("unknown action type: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize action config to JSON for DB storage.
|
||||||
|
pub fn to_config_json(&self) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
RoutineAction::Lightweight {
|
||||||
|
prompt,
|
||||||
|
context_paths,
|
||||||
|
max_tokens,
|
||||||
|
} => serde_json::json!({
|
||||||
|
"prompt": prompt,
|
||||||
|
"context_paths": context_paths,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}),
|
||||||
|
RoutineAction::FullJob {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
max_iterations,
|
||||||
|
} => serde_json::json!({
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"max_iterations": max_iterations,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guardrails to prevent runaway execution.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoutineGuardrails {
|
||||||
|
/// Minimum time between fires.
|
||||||
|
pub cooldown: Duration,
|
||||||
|
/// Max simultaneous runs of this routine.
|
||||||
|
pub max_concurrent: u32,
|
||||||
|
/// Window for content-hash dedup (event triggers). None = no dedup.
|
||||||
|
pub dedup_window: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RoutineGuardrails {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cooldown: Duration::from_secs(300),
|
||||||
|
max_concurrent: 1,
|
||||||
|
dedup_window: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notification preferences for a routine.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NotifyConfig {
|
||||||
|
/// Channel to notify on (None = default/broadcast all).
|
||||||
|
pub channel: Option<String>,
|
||||||
|
/// User to notify.
|
||||||
|
pub user: String,
|
||||||
|
/// Notify when routine produces actionable output.
|
||||||
|
pub on_attention: bool,
|
||||||
|
/// Notify when routine errors.
|
||||||
|
pub on_failure: bool,
|
||||||
|
/// Notify when routine runs with no findings.
|
||||||
|
pub on_success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotifyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
channel: None,
|
||||||
|
user: "default".to_string(),
|
||||||
|
on_attention: true,
|
||||||
|
on_failure: true,
|
||||||
|
on_success: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of a routine run.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RunStatus {
|
||||||
|
Running,
|
||||||
|
Ok,
|
||||||
|
Attention,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RunStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
RunStatus::Running => write!(f, "running"),
|
||||||
|
RunStatus::Ok => write!(f, "ok"),
|
||||||
|
RunStatus::Attention => write!(f, "attention"),
|
||||||
|
RunStatus::Failed => write!(f, "failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for RunStatus {
|
||||||
|
type Err = String;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"running" => Ok(RunStatus::Running),
|
||||||
|
"ok" => Ok(RunStatus::Ok),
|
||||||
|
"attention" => Ok(RunStatus::Attention),
|
||||||
|
"failed" => Ok(RunStatus::Failed),
|
||||||
|
other => Err(format!("unknown run status: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single execution of a routine.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoutineRun {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub routine_id: Uuid,
|
||||||
|
pub trigger_type: String,
|
||||||
|
pub trigger_detail: Option<String>,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
pub status: RunStatus,
|
||||||
|
pub result_summary: Option<String>,
|
||||||
|
pub tokens_used: Option<i32>,
|
||||||
|
pub job_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a content hash for event dedup.
|
||||||
|
pub fn content_hash(content: &str) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
content.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a cron expression and compute the next fire time from now.
|
||||||
|
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, String> {
|
||||||
|
let cron_schedule =
|
||||||
|
cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?;
|
||||||
|
Ok(cron_schedule.upcoming(Utc).next())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::agent::routine::{
|
||||||
|
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_roundtrip() {
|
||||||
|
let trigger = Trigger::Cron {
|
||||||
|
schedule: "0 9 * * MON-FRI".to_string(),
|
||||||
|
};
|
||||||
|
let json = trigger.to_config_json();
|
||||||
|
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
||||||
|
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_event_trigger_roundtrip() {
|
||||||
|
let trigger = Trigger::Event {
|
||||||
|
channel: Some("telegram".to_string()),
|
||||||
|
pattern: r"deploy\s+\w+".to_string(),
|
||||||
|
};
|
||||||
|
let json = trigger.to_config_json();
|
||||||
|
let parsed = Trigger::from_db("event", json).expect("parse event");
|
||||||
|
assert!(matches!(parsed, Trigger::Event { channel, pattern }
|
||||||
|
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_lightweight_roundtrip() {
|
||||||
|
let action = RoutineAction::Lightweight {
|
||||||
|
prompt: "Check PRs".to_string(),
|
||||||
|
context_paths: vec!["context/priorities.md".to_string()],
|
||||||
|
max_tokens: 2048,
|
||||||
|
};
|
||||||
|
let json = action.to_config_json();
|
||||||
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||||
|
assert!(
|
||||||
|
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
||||||
|
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_full_job_roundtrip() {
|
||||||
|
let action = RoutineAction::FullJob {
|
||||||
|
title: "Deploy review".to_string(),
|
||||||
|
description: "Review and deploy pending changes".to_string(),
|
||||||
|
max_iterations: 5,
|
||||||
|
};
|
||||||
|
let json = action.to_config_json();
|
||||||
|
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||||
|
assert!(
|
||||||
|
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||||
|
if title == "Deploy review" && max_iterations == 5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_status_display_parse() {
|
||||||
|
for status in [
|
||||||
|
RunStatus::Running,
|
||||||
|
RunStatus::Ok,
|
||||||
|
RunStatus::Attention,
|
||||||
|
RunStatus::Failed,
|
||||||
|
] {
|
||||||
|
let s = status.to_string();
|
||||||
|
let parsed: RunStatus = s.parse().expect("parse status");
|
||||||
|
assert_eq!(parsed, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_hash_deterministic() {
|
||||||
|
let h1 = content_hash("deploy production");
|
||||||
|
let h2 = content_hash("deploy production");
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
|
||||||
|
let h3 = content_hash("deploy staging");
|
||||||
|
assert_ne!(h1, h3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_cron_fire_valid() {
|
||||||
|
// Every minute should always have a next fire
|
||||||
|
let next = next_cron_fire("* * * * * *").expect("valid cron");
|
||||||
|
assert!(next.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_next_cron_fire_invalid() {
|
||||||
|
let result = next_cron_fire("not a cron");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_guardrails_default() {
|
||||||
|
let g = RoutineGuardrails::default();
|
||||||
|
assert_eq!(g.cooldown.as_secs(), 300);
|
||||||
|
assert_eq!(g.max_concurrent, 1);
|
||||||
|
assert!(g.dedup_window.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_type_tag() {
|
||||||
|
assert_eq!(
|
||||||
|
Trigger::Cron {
|
||||||
|
schedule: String::new()
|
||||||
|
}
|
||||||
|
.type_tag(),
|
||||||
|
"cron"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Trigger::Event {
|
||||||
|
channel: None,
|
||||||
|
pattern: String::new()
|
||||||
|
}
|
||||||
|
.type_tag(),
|
||||||
|
"event"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Trigger::Webhook {
|
||||||
|
path: None,
|
||||||
|
secret: None
|
||||||
|
}
|
||||||
|
.type_tag(),
|
||||||
|
"webhook"
|
||||||
|
);
|
||||||
|
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
//! Routine execution engine.
|
||||||
|
//!
|
||||||
|
//! Handles loading routines, checking triggers, enforcing guardrails,
|
||||||
|
//! and executing both lightweight (single LLM call) and full-job routines.
|
||||||
|
//!
|
||||||
|
//! The engine runs two independent loops:
|
||||||
|
//! - A **cron ticker** that polls the DB every N seconds for due cron routines
|
||||||
|
//! - An **event matcher** called synchronously from the agent main loop
|
||||||
|
//!
|
||||||
|
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
|
||||||
|
//! Full-job routines are delegated to the existing `Scheduler`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use regex::Regex;
|
||||||
|
use tokio::sync::{RwLock, mpsc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::agent::routine::{
|
||||||
|
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||||
|
};
|
||||||
|
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||||
|
use crate::config::RoutineConfig;
|
||||||
|
use crate::history::Store;
|
||||||
|
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||||
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
|
/// The routine execution engine.
|
||||||
|
pub struct RoutineEngine {
|
||||||
|
config: RoutineConfig,
|
||||||
|
store: Arc<Store>,
|
||||||
|
llm: Arc<dyn LlmProvider>,
|
||||||
|
workspace: Arc<Workspace>,
|
||||||
|
/// Sender for notifications (routed to channel manager).
|
||||||
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
|
/// Currently running routine count (across all routines).
|
||||||
|
running_count: Arc<RwLock<usize>>,
|
||||||
|
/// Compiled event regex cache: routine_id -> compiled regex.
|
||||||
|
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutineEngine {
|
||||||
|
pub fn new(
|
||||||
|
config: RoutineConfig,
|
||||||
|
store: Arc<Store>,
|
||||||
|
llm: Arc<dyn LlmProvider>,
|
||||||
|
workspace: Arc<Workspace>,
|
||||||
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
llm,
|
||||||
|
workspace,
|
||||||
|
notify_tx,
|
||||||
|
running_count: Arc::new(RwLock::new(0)),
|
||||||
|
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the in-memory event trigger cache from DB.
|
||||||
|
pub async fn refresh_event_cache(&self) {
|
||||||
|
match self.store.list_event_routines().await {
|
||||||
|
Ok(routines) => {
|
||||||
|
let mut cache = Vec::new();
|
||||||
|
for routine in routines {
|
||||||
|
if let Trigger::Event { ref pattern, .. } = routine.trigger {
|
||||||
|
match Regex::new(pattern) {
|
||||||
|
Ok(re) => cache.push((routine.id, routine.clone(), re)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
routine = %routine.name,
|
||||||
|
"Invalid event regex '{}': {}",
|
||||||
|
pattern, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let count = cache.len();
|
||||||
|
*self.event_cache.write().await = cache;
|
||||||
|
tracing::debug!("Refreshed event cache: {} routines", count);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to refresh event cache: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check incoming message against event triggers. Returns number of routines fired.
|
||||||
|
///
|
||||||
|
/// Called synchronously from the main loop after handle_message(). The actual
|
||||||
|
/// execution is spawned async so this returns quickly.
|
||||||
|
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
|
||||||
|
let cache = self.event_cache.read().await;
|
||||||
|
let mut fired = 0;
|
||||||
|
|
||||||
|
for (_, routine, re) in cache.iter() {
|
||||||
|
// Channel filter
|
||||||
|
if let Trigger::Event {
|
||||||
|
channel: Some(ch), ..
|
||||||
|
} = &routine.trigger
|
||||||
|
{
|
||||||
|
if ch != &message.channel {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex match
|
||||||
|
if !re.is_match(&message.content) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown check
|
||||||
|
if !self.check_cooldown(routine) {
|
||||||
|
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent run check
|
||||||
|
if !self.check_concurrent(routine).await {
|
||||||
|
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global capacity check
|
||||||
|
if *self.running_count.read().await >= self.config.max_concurrent_routines {
|
||||||
|
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = truncate(&message.content, 200);
|
||||||
|
self.spawn_fire(routine.clone(), "event", Some(detail));
|
||||||
|
fired += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fired
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check all due cron routines and fire them. Called by the cron ticker.
|
||||||
|
pub async fn check_cron_triggers(&self) {
|
||||||
|
let routines = match self.store.list_due_cron_routines().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to load due cron routines: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for routine in routines {
|
||||||
|
if *self.running_count.read().await >= self.config.max_concurrent_routines {
|
||||||
|
tracing::warn!("Global max concurrent routines reached, skipping remaining");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.check_cooldown(&routine) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.check_concurrent(&routine).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = if let Trigger::Cron { ref schedule } = routine.trigger {
|
||||||
|
Some(schedule.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.spawn_fire(routine, "cron", detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire a routine manually (from tool call or CLI).
|
||||||
|
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, String> {
|
||||||
|
let routine = self
|
||||||
|
.store
|
||||||
|
.get_routine(routine_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("DB error: {e}"))?
|
||||||
|
.ok_or_else(|| format!("routine {routine_id} not found"))?;
|
||||||
|
|
||||||
|
if !routine.enabled {
|
||||||
|
return Err(format!("routine '{}' is disabled", routine.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.check_concurrent(&routine).await {
|
||||||
|
return Err(format!(
|
||||||
|
"routine '{}' already at max concurrent runs",
|
||||||
|
routine.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_id = Uuid::new_v4();
|
||||||
|
let run = RoutineRun {
|
||||||
|
id: run_id,
|
||||||
|
routine_id: routine.id,
|
||||||
|
trigger_type: "manual".to_string(),
|
||||||
|
trigger_detail: None,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
completed_at: None,
|
||||||
|
status: RunStatus::Running,
|
||||||
|
result_summary: None,
|
||||||
|
tokens_used: None,
|
||||||
|
job_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = self.store.create_routine_run(&run).await {
|
||||||
|
return Err(format!("failed to create run record: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute inline for manual triggers (caller wants to wait)
|
||||||
|
let engine = EngineContext {
|
||||||
|
store: self.store.clone(),
|
||||||
|
llm: self.llm.clone(),
|
||||||
|
workspace: self.workspace.clone(),
|
||||||
|
notify_tx: self.notify_tx.clone(),
|
||||||
|
running_count: self.running_count.clone(),
|
||||||
|
max_lightweight_tokens: self.config.max_lightweight_tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
execute_routine(engine, routine, run).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(run_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a fire in a background task.
|
||||||
|
fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option<String>) {
|
||||||
|
let run = RoutineRun {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
routine_id: routine.id,
|
||||||
|
trigger_type: trigger_type.to_string(),
|
||||||
|
trigger_detail,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
completed_at: None,
|
||||||
|
status: RunStatus::Running,
|
||||||
|
result_summary: None,
|
||||||
|
tokens_used: None,
|
||||||
|
job_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let engine = EngineContext {
|
||||||
|
store: self.store.clone(),
|
||||||
|
llm: self.llm.clone(),
|
||||||
|
workspace: self.workspace.clone(),
|
||||||
|
notify_tx: self.notify_tx.clone(),
|
||||||
|
running_count: self.running_count.clone(),
|
||||||
|
max_lightweight_tokens: self.config.max_lightweight_tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record the run in DB, then spawn execution
|
||||||
|
let store = self.store.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = store.create_routine_run(&run).await {
|
||||||
|
tracing::error!(routine = %routine.name, "Failed to record run: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execute_routine(engine, routine, run).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_cooldown(&self, routine: &Routine) -> bool {
|
||||||
|
if let Some(last_run) = routine.last_run_at {
|
||||||
|
let elapsed = Utc::now().signed_duration_since(last_run);
|
||||||
|
let cooldown = chrono::Duration::from_std(routine.guardrails.cooldown)
|
||||||
|
.unwrap_or(chrono::Duration::seconds(300));
|
||||||
|
if elapsed < cooldown {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_concurrent(&self, routine: &Routine) -> bool {
|
||||||
|
match self.store.count_running_routine_runs(routine.id).await {
|
||||||
|
Ok(count) => count < routine.guardrails.max_concurrent as i64,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
routine = %routine.name,
|
||||||
|
"Failed to check concurrent runs: {}", e
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared context passed to the execution function.
|
||||||
|
struct EngineContext {
|
||||||
|
store: Arc<Store>,
|
||||||
|
llm: Arc<dyn LlmProvider>,
|
||||||
|
workspace: Arc<Workspace>,
|
||||||
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
|
running_count: Arc<RwLock<usize>>,
|
||||||
|
max_lightweight_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||||
|
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
|
||||||
|
// Increment running count
|
||||||
|
{
|
||||||
|
let mut count = ctx.running_count.write().await;
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match &routine.action {
|
||||||
|
RoutineAction::Lightweight {
|
||||||
|
prompt,
|
||||||
|
context_paths,
|
||||||
|
max_tokens,
|
||||||
|
} => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await,
|
||||||
|
RoutineAction::FullJob { description, .. } => {
|
||||||
|
// Full job mode: for now, execute as lightweight with the description
|
||||||
|
// as prompt. Full scheduler integration will come as a follow-up.
|
||||||
|
tracing::info!(
|
||||||
|
routine = %routine.name,
|
||||||
|
"FullJob mode executing as lightweight (scheduler integration pending)"
|
||||||
|
);
|
||||||
|
execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrement running count
|
||||||
|
{
|
||||||
|
let mut count = ctx.running_count.write().await;
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process result
|
||||||
|
let (status, summary, tokens) = match result {
|
||||||
|
Ok(execution) => execution,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(routine = %routine.name, "Execution failed: {}", e);
|
||||||
|
(RunStatus::Failed, Some(e), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Complete the run record
|
||||||
|
if let Err(e) = ctx
|
||||||
|
.store
|
||||||
|
.complete_routine_run(run.id, status, summary.as_deref(), tokens)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update routine runtime state
|
||||||
|
let now = Utc::now();
|
||||||
|
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger {
|
||||||
|
next_cron_fire(schedule).unwrap_or(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_failures = if status == RunStatus::Failed {
|
||||||
|
routine.consecutive_failures + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = ctx
|
||||||
|
.store
|
||||||
|
.update_routine_runtime(
|
||||||
|
routine.id,
|
||||||
|
now,
|
||||||
|
next_fire,
|
||||||
|
routine.run_count + 1,
|
||||||
|
new_failures,
|
||||||
|
&routine.state,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(routine = %routine.name, "Failed to update runtime state: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notifications based on config
|
||||||
|
send_notification(
|
||||||
|
&ctx.notify_tx,
|
||||||
|
&routine.notify,
|
||||||
|
&routine.name,
|
||||||
|
status,
|
||||||
|
summary.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a lightweight routine (single LLM call).
|
||||||
|
async fn execute_lightweight(
|
||||||
|
ctx: &EngineContext,
|
||||||
|
routine: &Routine,
|
||||||
|
prompt: &str,
|
||||||
|
context_paths: &[String],
|
||||||
|
max_tokens: u32,
|
||||||
|
) -> Result<(RunStatus, Option<String>, Option<i32>), String> {
|
||||||
|
// Load context from workspace
|
||||||
|
let mut context_parts = Vec::new();
|
||||||
|
for path in context_paths {
|
||||||
|
match ctx.workspace.read(path).await {
|
||||||
|
Ok(doc) => {
|
||||||
|
context_parts.push(format!("## {}\n\n{}", path, doc.content));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(
|
||||||
|
routine = %routine.name,
|
||||||
|
"Failed to read context path {}: {}", path, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load routine state from workspace
|
||||||
|
let state_path = format!("routines/{}/state.md", routine.name);
|
||||||
|
let state_content = match ctx.workspace.read(&state_path).await {
|
||||||
|
Ok(doc) => Some(doc.content),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the prompt
|
||||||
|
let mut full_prompt = String::new();
|
||||||
|
full_prompt.push_str(prompt);
|
||||||
|
|
||||||
|
if !context_parts.is_empty() {
|
||||||
|
full_prompt.push_str("\n\n---\n\n# Context\n\n");
|
||||||
|
full_prompt.push_str(&context_parts.join("\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(state) = &state_content {
|
||||||
|
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
|
||||||
|
full_prompt.push_str(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
full_prompt.push_str(
|
||||||
|
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
|
||||||
|
If something needs attention, provide a concise summary.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get system prompt
|
||||||
|
let system_prompt = match ctx.workspace.system_prompt().await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(routine = %routine.name, "Failed to get system prompt: {}", e);
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = if system_prompt.is_empty() {
|
||||||
|
vec![ChatMessage::user(&full_prompt)]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
ChatMessage::system(&system_prompt),
|
||||||
|
ChatMessage::user(&full_prompt),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine max_tokens from model metadata with fallback
|
||||||
|
let effective_max_tokens = match ctx.llm.model_metadata().await {
|
||||||
|
Ok(meta) => {
|
||||||
|
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(max_tokens);
|
||||||
|
from_api.max(max_tokens)
|
||||||
|
}
|
||||||
|
Err(_) => max_tokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = CompletionRequest::new(messages)
|
||||||
|
.with_max_tokens(effective_max_tokens)
|
||||||
|
.with_temperature(0.3);
|
||||||
|
|
||||||
|
let response = ctx
|
||||||
|
.llm
|
||||||
|
.complete(request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("LLM call failed: {e}"))?;
|
||||||
|
|
||||||
|
let content = response.content.trim();
|
||||||
|
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
||||||
|
|
||||||
|
// Empty content guard (same as heartbeat)
|
||||||
|
if content.is_empty() {
|
||||||
|
return if response.finish_reason == FinishReason::Length {
|
||||||
|
Err(
|
||||||
|
"LLM response truncated (finish_reason=length) with no content. \
|
||||||
|
Model may have exhausted token budget on reasoning."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Err("LLM returned empty content.".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the "nothing to do" sentinel
|
||||||
|
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
||||||
|
return Ok((RunStatus::Ok, None, tokens_used));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a notification based on the routine's notify config and run status.
|
||||||
|
async fn send_notification(
|
||||||
|
tx: &mpsc::Sender<OutgoingResponse>,
|
||||||
|
notify: &NotifyConfig,
|
||||||
|
routine_name: &str,
|
||||||
|
status: RunStatus,
|
||||||
|
summary: Option<&str>,
|
||||||
|
) {
|
||||||
|
let should_notify = match status {
|
||||||
|
RunStatus::Ok => notify.on_success,
|
||||||
|
RunStatus::Attention => notify.on_attention,
|
||||||
|
RunStatus::Failed => notify.on_failure,
|
||||||
|
RunStatus::Running => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_notify {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = match status {
|
||||||
|
RunStatus::Ok => "✅",
|
||||||
|
RunStatus::Attention => "🔔",
|
||||||
|
RunStatus::Failed => "❌",
|
||||||
|
RunStatus::Running => "⏳",
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = match summary {
|
||||||
|
Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s),
|
||||||
|
None => format!("{} *Routine '{}'*: {}", icon, routine_name, status),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = OutgoingResponse {
|
||||||
|
content: message,
|
||||||
|
thread_id: None,
|
||||||
|
metadata: serde_json::json!({
|
||||||
|
"source": "routine",
|
||||||
|
"routine_name": routine_name,
|
||||||
|
"status": status.to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = tx.send(response).await {
|
||||||
|
tracing::error!(routine = %routine_name, "Failed to send notification: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the cron ticker background task.
|
||||||
|
pub fn spawn_cron_ticker(
|
||||||
|
engine: Arc<RoutineEngine>,
|
||||||
|
interval: Duration,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
// Skip immediate first tick
|
||||||
|
ticker.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
engine.check_cron_triggers().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}...", &s[..max])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_notification_gating() {
|
||||||
|
let config = NotifyConfig {
|
||||||
|
on_success: false,
|
||||||
|
on_failure: true,
|
||||||
|
on_attention: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// on_success = false means Ok status should not notify
|
||||||
|
assert!(!config.on_success);
|
||||||
|
assert!(config.on_failure);
|
||||||
|
assert!(config.on_attention);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_status_icons() {
|
||||||
|
// Just verify the mapping doesn't panic
|
||||||
|
for status in [
|
||||||
|
RunStatus::Ok,
|
||||||
|
RunStatus::Attention,
|
||||||
|
RunStatus::Failed,
|
||||||
|
RunStatus::Running,
|
||||||
|
] {
|
||||||
|
let _ = status.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-17
@@ -373,23 +373,23 @@ impl Scheduler {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute with timeout
|
// Execute with per-tool timeout
|
||||||
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
let tool_timeout = tool.execution_timeout();
|
||||||
tool.execute(params, &job_ctx).await
|
let result =
|
||||||
})
|
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
Error::Tool(crate::error::ToolError::Timeout {
|
Error::Tool(crate::error::ToolError::Timeout {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
timeout: Duration::from_secs(60),
|
timeout: tool_timeout,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
reason: e.to_string(),
|
reason: e.to_string(),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(TaskOutput::new(result.result, start.elapsed()))
|
Ok(TaskOutput::new(result.result, start.elapsed()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ pub enum ThreadState {
|
|||||||
Interrupted,
|
Interrupted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pending auth token request.
|
||||||
|
///
|
||||||
|
/// When `tool_auth` returns `awaiting_token`, the thread enters auth mode.
|
||||||
|
/// The next user message is intercepted before entering the normal pipeline
|
||||||
|
/// (no logging, no turn creation, no history) and routed directly to the
|
||||||
|
/// credential store.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PendingAuth {
|
||||||
|
/// Extension name to authenticate.
|
||||||
|
pub extension_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Pending tool approval request stored on a thread.
|
/// Pending tool approval request stored on a thread.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PendingApproval {
|
pub struct PendingApproval {
|
||||||
@@ -158,6 +170,13 @@ pub struct Thread {
|
|||||||
/// Pending approval request (when state is AwaitingApproval).
|
/// Pending approval request (when state is AwaitingApproval).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pending_approval: Option<PendingApproval>,
|
pub pending_approval: Option<PendingApproval>,
|
||||||
|
/// Pending auth token request (thread is in auth mode).
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_auth: Option<PendingAuth>,
|
||||||
|
/// Last NEAR AI response ID for response chaining. Persisted to DB
|
||||||
|
/// metadata so we can resume chaining across restarts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_response_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Thread {
|
impl Thread {
|
||||||
@@ -173,6 +192,25 @@ impl Thread {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
pending_approval: None,
|
pending_approval: None,
|
||||||
|
pending_auth: None,
|
||||||
|
last_response_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a thread with a specific ID (for DB hydration).
|
||||||
|
pub fn with_id(id: Uuid, session_id: Uuid) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
session_id,
|
||||||
|
state: ThreadState::Idle,
|
||||||
|
turns: Vec::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metadata: serde_json::Value::Null,
|
||||||
|
pending_approval: None,
|
||||||
|
pending_auth: None,
|
||||||
|
last_response_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +276,18 @@ impl Thread {
|
|||||||
self.updated_at = Utc::now();
|
self.updated_at = Utc::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enter auth mode: next user message will be routed directly to
|
||||||
|
/// the credential store, bypassing the normal pipeline entirely.
|
||||||
|
pub fn enter_auth_mode(&mut self, extension_name: String) {
|
||||||
|
self.pending_auth = Some(PendingAuth { extension_name });
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the pending auth (clearing auth mode).
|
||||||
|
pub fn take_pending_auth(&mut self) -> Option<PendingAuth> {
|
||||||
|
self.pending_auth.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Interrupt the current turn.
|
/// Interrupt the current turn.
|
||||||
pub fn interrupt(&mut self) {
|
pub fn interrupt(&mut self) {
|
||||||
if let Some(turn) = self.turns.last_mut() {
|
if let Some(turn) = self.turns.last_mut() {
|
||||||
@@ -511,4 +561,440 @@ mod tests {
|
|||||||
assert_eq!(thread.turns[1].user_input, "How are you?");
|
assert_eq!(thread.turns[1].user_input, "How are you?");
|
||||||
assert!(thread.turns[1].response.is_none());
|
assert!(thread.turns[1].response.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enter_auth_mode() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
assert!(thread.pending_auth.is_none());
|
||||||
|
|
||||||
|
thread.enter_auth_mode("telegram".to_string());
|
||||||
|
assert!(thread.pending_auth.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
thread.pending_auth.as_ref().unwrap().extension_name,
|
||||||
|
"telegram"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_take_pending_auth() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
thread.enter_auth_mode("notion".to_string());
|
||||||
|
|
||||||
|
let pending = thread.take_pending_auth();
|
||||||
|
assert!(pending.is_some());
|
||||||
|
assert_eq!(pending.unwrap().extension_name, "notion");
|
||||||
|
|
||||||
|
// Should be cleared after take
|
||||||
|
assert!(thread.pending_auth.is_none());
|
||||||
|
assert!(thread.take_pending_auth().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pending_auth_serialization() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
thread.enter_auth_mode("openai".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&thread).expect("should serialize");
|
||||||
|
assert!(json.contains("pending_auth"));
|
||||||
|
assert!(json.contains("openai"));
|
||||||
|
|
||||||
|
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
||||||
|
assert!(restored.pending_auth.is_some());
|
||||||
|
assert_eq!(restored.pending_auth.unwrap().extension_name, "openai");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pending_auth_default_none() {
|
||||||
|
// Deserialization of old data without pending_auth should default to None
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
thread.pending_auth = None;
|
||||||
|
let json = serde_json::to_string(&thread).expect("serialize");
|
||||||
|
|
||||||
|
// Remove the pending_auth field to simulate old data
|
||||||
|
let json = json.replace(",\"pending_auth\":null", "");
|
||||||
|
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
||||||
|
assert!(restored.pending_auth.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thread_with_id() {
|
||||||
|
let specific_id = Uuid::new_v4();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let thread = Thread::with_id(specific_id, session_id);
|
||||||
|
|
||||||
|
assert_eq!(thread.id, specific_id);
|
||||||
|
assert_eq!(thread.session_id, session_id);
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
assert!(thread.turns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thread_with_id_restore_messages() {
|
||||||
|
let thread_id = Uuid::new_v4();
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let mut thread = Thread::with_id(thread_id, session_id);
|
||||||
|
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("Hello from DB"),
|
||||||
|
ChatMessage::assistant("Restored response"),
|
||||||
|
];
|
||||||
|
thread.restore_from_messages(messages);
|
||||||
|
|
||||||
|
assert_eq!(thread.id, thread_id);
|
||||||
|
assert_eq!(thread.turns.len(), 1);
|
||||||
|
assert_eq!(thread.turns[0].user_input, "Hello from DB");
|
||||||
|
assert_eq!(
|
||||||
|
thread.turns[0].response,
|
||||||
|
Some("Restored response".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restore_from_messages_empty() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Add a turn first, then restore with empty vec
|
||||||
|
thread.start_turn("hello");
|
||||||
|
thread.complete_turn("hi");
|
||||||
|
assert_eq!(thread.turns.len(), 1);
|
||||||
|
|
||||||
|
thread.restore_from_messages(Vec::new());
|
||||||
|
|
||||||
|
// Should clear all turns and stay idle
|
||||||
|
assert!(thread.turns.is_empty());
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restore_from_messages_only_assistant_messages() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Only assistant messages (no user messages to anchor turns)
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::assistant("I'm here"),
|
||||||
|
ChatMessage::assistant("Still here"),
|
||||||
|
];
|
||||||
|
|
||||||
|
thread.restore_from_messages(messages);
|
||||||
|
|
||||||
|
// Assistant-only messages have no user turn to attach to, so
|
||||||
|
// they should be skipped entirely.
|
||||||
|
assert!(thread.turns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restore_from_messages_multiple_user_messages_in_a_row() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Two user messages with no assistant response between them
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("first"),
|
||||||
|
ChatMessage::user("second"),
|
||||||
|
ChatMessage::assistant("reply to second"),
|
||||||
|
];
|
||||||
|
|
||||||
|
thread.restore_from_messages(messages);
|
||||||
|
|
||||||
|
// First user message becomes a turn with no response,
|
||||||
|
// second user message pairs with the assistant response.
|
||||||
|
assert_eq!(thread.turns.len(), 2);
|
||||||
|
assert_eq!(thread.turns[0].user_input, "first");
|
||||||
|
assert!(thread.turns[0].response.is_none());
|
||||||
|
assert_eq!(thread.turns[1].user_input, "second");
|
||||||
|
assert_eq!(
|
||||||
|
thread.turns[1].response,
|
||||||
|
Some("reply to second".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thread_switch() {
|
||||||
|
let mut session = Session::new("user-1");
|
||||||
|
|
||||||
|
let t1_id = session.create_thread().id;
|
||||||
|
let t2_id = session.create_thread().id;
|
||||||
|
|
||||||
|
// After creating two threads, active should be the last one
|
||||||
|
assert_eq!(session.active_thread, Some(t2_id));
|
||||||
|
|
||||||
|
// Switch back to the first
|
||||||
|
assert!(session.switch_thread(t1_id));
|
||||||
|
assert_eq!(session.active_thread, Some(t1_id));
|
||||||
|
|
||||||
|
// Switching to a nonexistent thread should fail
|
||||||
|
let fake_id = Uuid::new_v4();
|
||||||
|
assert!(!session.switch_thread(fake_id));
|
||||||
|
// Active thread should remain unchanged
|
||||||
|
assert_eq!(session.active_thread, Some(t1_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_or_create_thread_idempotent() {
|
||||||
|
let mut session = Session::new("user-1");
|
||||||
|
|
||||||
|
let tid1 = session.get_or_create_thread().id;
|
||||||
|
let tid2 = session.get_or_create_thread().id;
|
||||||
|
|
||||||
|
// Should return the same thread (not create a new one each time)
|
||||||
|
assert_eq!(tid1, tid2);
|
||||||
|
assert_eq!(session.threads.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_turns() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
thread.start_turn(format!("msg-{}", i));
|
||||||
|
thread.complete_turn(format!("resp-{}", i));
|
||||||
|
}
|
||||||
|
assert_eq!(thread.turns.len(), 5);
|
||||||
|
|
||||||
|
thread.truncate_turns(3);
|
||||||
|
assert_eq!(thread.turns.len(), 3);
|
||||||
|
|
||||||
|
// Should keep the most recent turns
|
||||||
|
assert_eq!(thread.turns[0].user_input, "msg-2");
|
||||||
|
assert_eq!(thread.turns[1].user_input, "msg-3");
|
||||||
|
assert_eq!(thread.turns[2].user_input, "msg-4");
|
||||||
|
|
||||||
|
// Turn numbers should be re-indexed
|
||||||
|
assert_eq!(thread.turns[0].turn_number, 0);
|
||||||
|
assert_eq!(thread.turns[1].turn_number, 1);
|
||||||
|
assert_eq!(thread.turns[2].turn_number, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_turns_noop_when_fewer() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
thread.start_turn("only one");
|
||||||
|
thread.complete_turn("response");
|
||||||
|
|
||||||
|
thread.truncate_turns(10);
|
||||||
|
assert_eq!(thread.turns.len(), 1);
|
||||||
|
assert_eq!(thread.turns[0].user_input, "only one");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thread_interrupt_and_resume() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
thread.start_turn("do something");
|
||||||
|
assert_eq!(thread.state, ThreadState::Processing);
|
||||||
|
|
||||||
|
thread.interrupt();
|
||||||
|
assert_eq!(thread.state, ThreadState::Interrupted);
|
||||||
|
|
||||||
|
let last_turn = thread.last_turn().unwrap();
|
||||||
|
assert_eq!(last_turn.state, TurnState::Interrupted);
|
||||||
|
assert!(last_turn.completed_at.is_some());
|
||||||
|
|
||||||
|
thread.resume();
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resume_only_from_interrupted() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Idle thread: resume should be a no-op
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
thread.resume();
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
|
||||||
|
// Processing thread: resume should not change state
|
||||||
|
thread.start_turn("work");
|
||||||
|
assert_eq!(thread.state, ThreadState::Processing);
|
||||||
|
thread.resume();
|
||||||
|
assert_eq!(thread.state, ThreadState::Processing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_fail() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
thread.start_turn("risky operation");
|
||||||
|
thread.fail_turn("connection timed out");
|
||||||
|
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
|
||||||
|
let turn = thread.last_turn().unwrap();
|
||||||
|
assert_eq!(turn.state, TurnState::Failed);
|
||||||
|
assert_eq!(turn.error, Some("connection timed out".to_string()));
|
||||||
|
assert!(turn.response.is_none());
|
||||||
|
assert!(turn.completed_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_messages_with_incomplete_last_turn() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
thread.start_turn("first");
|
||||||
|
thread.complete_turn("first reply");
|
||||||
|
thread.start_turn("second (in progress)");
|
||||||
|
|
||||||
|
let messages = thread.messages();
|
||||||
|
// Should have 3 messages: user, assistant, user (no assistant for in-progress)
|
||||||
|
assert_eq!(messages.len(), 3);
|
||||||
|
assert_eq!(messages[0].content, "first");
|
||||||
|
assert_eq!(messages[1].content, "first reply");
|
||||||
|
assert_eq!(messages[2].content, "second (in progress)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thread_serialization_round_trip() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
thread.start_turn("hello");
|
||||||
|
thread.complete_turn("world");
|
||||||
|
thread.last_response_id = Some("resp_abc123".to_string());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&thread).unwrap();
|
||||||
|
let restored: Thread = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restored.id, thread.id);
|
||||||
|
assert_eq!(restored.session_id, thread.session_id);
|
||||||
|
assert_eq!(restored.turns.len(), 1);
|
||||||
|
assert_eq!(restored.turns[0].user_input, "hello");
|
||||||
|
assert_eq!(restored.turns[0].response, Some("world".to_string()));
|
||||||
|
assert_eq!(restored.last_response_id, Some("resp_abc123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_serialization_round_trip() {
|
||||||
|
let mut session = Session::new("user-ser");
|
||||||
|
session.create_thread();
|
||||||
|
session.auto_approve_tool("echo");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
let restored: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restored.user_id, "user-ser");
|
||||||
|
assert_eq!(restored.threads.len(), 1);
|
||||||
|
assert!(restored.is_tool_auto_approved("echo"));
|
||||||
|
assert!(!restored.is_tool_auto_approved("shell"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auto_approved_tools() {
|
||||||
|
let mut session = Session::new("user-1");
|
||||||
|
|
||||||
|
assert!(!session.is_tool_auto_approved("shell"));
|
||||||
|
session.auto_approve_tool("shell");
|
||||||
|
assert!(session.is_tool_auto_approved("shell"));
|
||||||
|
|
||||||
|
// Idempotent
|
||||||
|
session.auto_approve_tool("shell");
|
||||||
|
assert_eq!(session.auto_approved_tools.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_tool_call_error() {
|
||||||
|
let mut turn = Turn::new(0, "test");
|
||||||
|
turn.record_tool_call("http", serde_json::json!({"url": "example.com"}));
|
||||||
|
turn.record_tool_error("timeout");
|
||||||
|
|
||||||
|
assert_eq!(turn.tool_calls.len(), 1);
|
||||||
|
assert_eq!(turn.tool_calls[0].error, Some("timeout".to_string()));
|
||||||
|
assert!(turn.tool_calls[0].result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_turn_number_increments() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Before any turns, turn_number() is 1 (1-indexed for display)
|
||||||
|
assert_eq!(thread.turn_number(), 1);
|
||||||
|
|
||||||
|
thread.start_turn("first");
|
||||||
|
thread.complete_turn("done");
|
||||||
|
assert_eq!(thread.turn_number(), 2);
|
||||||
|
|
||||||
|
thread.start_turn("second");
|
||||||
|
assert_eq!(thread.turn_number(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complete_turn_on_empty_thread() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Completing a turn when there are no turns should be a safe no-op
|
||||||
|
thread.complete_turn("phantom response");
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
assert!(thread.turns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fail_turn_on_empty_thread() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
// Failing a turn when there are no turns should be a safe no-op
|
||||||
|
thread.fail_turn("phantom error");
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
assert!(thread.turns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pending_approval_flow() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
let approval = PendingApproval {
|
||||||
|
request_id: Uuid::new_v4(),
|
||||||
|
tool_name: "shell".to_string(),
|
||||||
|
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||||
|
description: "dangerous command".to_string(),
|
||||||
|
tool_call_id: "call_123".to_string(),
|
||||||
|
context_messages: vec![ChatMessage::user("do it")],
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.await_approval(approval);
|
||||||
|
assert_eq!(thread.state, ThreadState::AwaitingApproval);
|
||||||
|
assert!(thread.pending_approval.is_some());
|
||||||
|
|
||||||
|
let taken = thread.take_pending_approval();
|
||||||
|
assert!(taken.is_some());
|
||||||
|
assert_eq!(taken.unwrap().tool_name, "shell");
|
||||||
|
assert!(thread.pending_approval.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_pending_approval() {
|
||||||
|
let mut thread = Thread::new(Uuid::new_v4());
|
||||||
|
|
||||||
|
let approval = PendingApproval {
|
||||||
|
request_id: Uuid::new_v4(),
|
||||||
|
tool_name: "http".to_string(),
|
||||||
|
parameters: serde_json::json!({}),
|
||||||
|
description: "test".to_string(),
|
||||||
|
tool_call_id: "call_456".to_string(),
|
||||||
|
context_messages: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
thread.await_approval(approval);
|
||||||
|
thread.clear_pending_approval();
|
||||||
|
|
||||||
|
assert_eq!(thread.state, ThreadState::Idle);
|
||||||
|
assert!(thread.pending_approval.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_active_thread_accessors() {
|
||||||
|
let mut session = Session::new("user-1");
|
||||||
|
|
||||||
|
assert!(session.active_thread().is_none());
|
||||||
|
assert!(session.active_thread_mut().is_none());
|
||||||
|
|
||||||
|
let tid = session.create_thread().id;
|
||||||
|
|
||||||
|
assert!(session.active_thread().is_some());
|
||||||
|
assert_eq!(session.active_thread().unwrap().id, tid);
|
||||||
|
|
||||||
|
// Mutably modify through accessor
|
||||||
|
session.active_thread_mut().unwrap().start_turn("test");
|
||||||
|
assert_eq!(
|
||||||
|
session.active_thread().unwrap().state,
|
||||||
|
ThreadState::Processing
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,41 @@ impl SessionManager {
|
|||||||
(session, thread_id)
|
(session, thread_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register a hydrated thread so subsequent `resolve_thread` calls find it.
|
||||||
|
///
|
||||||
|
/// Inserts into the thread_map and creates an undo manager for the thread.
|
||||||
|
pub async fn register_thread(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
channel: &str,
|
||||||
|
thread_id: Uuid,
|
||||||
|
session: Arc<Mutex<Session>>,
|
||||||
|
) {
|
||||||
|
let key = ThreadKey {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
channel: channel.to_string(),
|
||||||
|
external_thread_id: Some(thread_id.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut thread_map = self.thread_map.write().await;
|
||||||
|
thread_map.insert(key, thread_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut undo_managers = self.undo_managers.write().await;
|
||||||
|
undo_managers
|
||||||
|
.entry(thread_id)
|
||||||
|
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the session is tracked
|
||||||
|
{
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.entry(user_id.to_string()).or_insert(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get undo manager for a thread.
|
/// Get undo manager for a thread.
|
||||||
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
||||||
// Fast path
|
// Fast path
|
||||||
@@ -296,4 +331,344 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(pruned, 0);
|
assert_eq!(pruned, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_thread() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let thread_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// Create a session with a hydrated thread
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(thread_id, sess.id);
|
||||||
|
sess.threads.insert(thread_id, thread);
|
||||||
|
sess.active_thread = Some(thread_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the thread
|
||||||
|
manager
|
||||||
|
.register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// resolve_thread should find it (using the UUID as external_thread_id)
|
||||||
|
let (resolved_session, resolved_tid) = manager
|
||||||
|
.resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string()))
|
||||||
|
.await;
|
||||||
|
assert_eq!(resolved_tid, thread_id);
|
||||||
|
|
||||||
|
// Should be the same session object
|
||||||
|
let sess = resolved_session.lock().await;
|
||||||
|
assert!(sess.threads.contains_key(&thread_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_with_explicit_external_id() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
// Two calls with the same explicit external thread ID should resolve
|
||||||
|
// to the same internal thread.
|
||||||
|
let (_, t1) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
||||||
|
.await;
|
||||||
|
let (_, t2) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(t1, t2);
|
||||||
|
|
||||||
|
// A different external ID on the same channel/user gets a new thread.
|
||||||
|
let (_, t3) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-xyz"))
|
||||||
|
.await;
|
||||||
|
assert_ne!(t1, t3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_none_vs_some_external_id() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
// None external_thread_id is a distinct key from Some("ext-1").
|
||||||
|
let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await;
|
||||||
|
let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await;
|
||||||
|
assert_ne!(t_none, t_some);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_different_users_isolated() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
let (_, t1) = manager
|
||||||
|
.resolve_thread("user-a", "gateway", Some("same-ext"))
|
||||||
|
.await;
|
||||||
|
let (_, t2) = manager
|
||||||
|
.resolve_thread("user-b", "gateway", Some("same-ext"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Same channel + same external ID but different users = different threads
|
||||||
|
assert_ne!(t1, t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_different_channels_isolated() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
let (_, t1) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("thread-x"))
|
||||||
|
.await;
|
||||||
|
let (_, t2) = manager
|
||||||
|
.resolve_thread("user-1", "telegram", Some("thread-x"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Same user + same external ID but different channels = different threads
|
||||||
|
assert_ne!(t1, t2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_stale_mapping_creates_new_thread() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
// Create a thread normally
|
||||||
|
let (session, original_tid) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Simulate the thread being removed from the session (e.g. pruned)
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
sess.threads.remove(&original_tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next resolve should detect the stale mapping and create a fresh thread
|
||||||
|
let (_, new_tid) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||||
|
.await;
|
||||||
|
assert_ne!(original_tid, new_tid);
|
||||||
|
|
||||||
|
// The new thread should actually exist in the session
|
||||||
|
let sess = session.lock().await;
|
||||||
|
assert!(sess.threads.contains_key(&new_tid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_thread_preserves_uuid_on_resolve() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let known_uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-web")));
|
||||||
|
let session_id = {
|
||||||
|
let sess = session.lock().await;
|
||||||
|
sess.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate hydration: create thread with a known UUID
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(known_uuid, session_id);
|
||||||
|
sess.threads.insert(known_uuid, thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register it
|
||||||
|
manager
|
||||||
|
.register_thread("user-web", "gateway", known_uuid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// resolve_thread with UUID as external_thread_id MUST return the same UUID,
|
||||||
|
// not mint a new one (this was the root cause of the "wrong conversation" bug)
|
||||||
|
let (_, resolved) = manager
|
||||||
|
.resolve_thread("user-web", "gateway", Some(&known_uuid.to_string()))
|
||||||
|
.await;
|
||||||
|
assert_eq!(resolved, known_uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_thread_idempotent() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let tid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-idem")));
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(tid, sess.id);
|
||||||
|
sess.threads.insert(tid, thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register twice
|
||||||
|
manager
|
||||||
|
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
manager
|
||||||
|
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Should still resolve to the same thread
|
||||||
|
let (_, resolved) = manager
|
||||||
|
.resolve_thread("user-idem", "gateway", Some(&tid.to_string()))
|
||||||
|
.await;
|
||||||
|
assert_eq!(resolved, tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_thread_creates_undo_manager() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let tid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-undo")));
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(tid, sess.id);
|
||||||
|
sess.threads.insert(tid, thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.register_thread("user-undo", "gateway", tid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Undo manager should exist for the registered thread
|
||||||
|
let undo = manager.get_undo_manager(tid).await;
|
||||||
|
let undo2 = manager.get_undo_manager(tid).await;
|
||||||
|
assert!(Arc::ptr_eq(&undo, &undo2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_thread_stores_session() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let tid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-new")));
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(tid, sess.id);
|
||||||
|
sess.threads.insert(tid, thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user has no session yet in the manager
|
||||||
|
{
|
||||||
|
let sessions = manager.sessions.read().await;
|
||||||
|
assert!(!sessions.contains_key("user-new"));
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.register_thread("user-new", "gateway", tid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Now the session should be tracked
|
||||||
|
{
|
||||||
|
let sessions = manager.sessions.read().await;
|
||||||
|
assert!(sessions.contains_key("user-new"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multiple_threads_per_user() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
let (_, t1) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("thread-a"))
|
||||||
|
.await;
|
||||||
|
let (_, t2) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("thread-b"))
|
||||||
|
.await;
|
||||||
|
let (session, t3) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("thread-c"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// All three should be distinct
|
||||||
|
assert_ne!(t1, t2);
|
||||||
|
assert_ne!(t2, t3);
|
||||||
|
assert_ne!(t1, t3);
|
||||||
|
|
||||||
|
// All three should exist in the same session
|
||||||
|
let sess = session.lock().await;
|
||||||
|
assert!(sess.threads.contains_key(&t1));
|
||||||
|
assert!(sess.threads.contains_key(&t2));
|
||||||
|
assert!(sess.threads.contains_key(&t3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_prune_cleans_thread_map_and_undo_managers() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await;
|
||||||
|
|
||||||
|
// Backdate the session
|
||||||
|
{
|
||||||
|
let mut sess = stale_session.lock().await;
|
||||||
|
sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify thread_map and undo_managers have entries
|
||||||
|
{
|
||||||
|
let tm = manager.thread_map.read().await;
|
||||||
|
assert!(!tm.is_empty());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let um = manager.undo_managers.read().await;
|
||||||
|
assert!(um.contains_key(&stale_tid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruned = manager
|
||||||
|
.prune_stale_sessions(std::time::Duration::from_secs(86400 * 7))
|
||||||
|
.await;
|
||||||
|
assert_eq!(pruned, 1);
|
||||||
|
|
||||||
|
// Thread map and undo managers should be cleaned up
|
||||||
|
{
|
||||||
|
let tm = manager.thread_map.read().await;
|
||||||
|
assert!(tm.is_empty());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let um = manager.undo_managers.read().await;
|
||||||
|
assert!(!um.contains_key(&stale_tid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resolve_thread_active_thread_set() {
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
|
||||||
|
let (session, thread_id) = manager
|
||||||
|
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The resolved thread should be set as the active thread
|
||||||
|
let sess = session.lock().await;
|
||||||
|
assert_eq!(sess.active_thread, Some(thread_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_register_then_resolve_different_channel_creates_new() {
|
||||||
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|
||||||
|
let manager = SessionManager::new();
|
||||||
|
let tid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||||
|
{
|
||||||
|
let mut sess = session.lock().await;
|
||||||
|
let thread = Thread::with_id(tid, sess.id);
|
||||||
|
sess.threads.insert(tid, thread);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register on "gateway" channel
|
||||||
|
manager
|
||||||
|
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Resolve on a different channel with the same UUID string should NOT
|
||||||
|
// find the registered thread (channel is part of the key)
|
||||||
|
let (_, resolved) = manager
|
||||||
|
.resolve_thread("user-cross", "telegram", Some(&tid.to_string()))
|
||||||
|
.await;
|
||||||
|
assert_ne!(resolved, tid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+212
-243
@@ -11,16 +11,7 @@ pub struct SubmissionParser;
|
|||||||
|
|
||||||
impl SubmissionParser {
|
impl SubmissionParser {
|
||||||
/// Parse message content into a Submission.
|
/// Parse message content into a Submission.
|
||||||
///
|
|
||||||
/// If `skill_commands` is provided (list of registered skill command names),
|
|
||||||
/// unrecognized `/foo` commands will be checked against it to enable
|
|
||||||
/// `/review <args>` style skill activation.
|
|
||||||
pub fn parse(content: &str) -> Submission {
|
pub fn parse(content: &str) -> Submission {
|
||||||
Self::parse_with_skill_commands(content, &[])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse with awareness of registered skill slash commands.
|
|
||||||
pub fn parse_with_skill_commands(content: &str, skill_commands: &[String]) -> Submission {
|
|
||||||
let trimmed = content.trim();
|
let trimmed = content.trim();
|
||||||
let lower = trimmed.to_lowercase();
|
let lower = trimmed.to_lowercase();
|
||||||
|
|
||||||
@@ -52,6 +43,52 @@ impl SubmissionParser {
|
|||||||
if lower == "/thread new" || lower == "/new" {
|
if lower == "/thread new" || lower == "/new" {
|
||||||
return Submission::NewThread;
|
return Submission::NewThread;
|
||||||
}
|
}
|
||||||
|
// System commands (bypass thread-state checks)
|
||||||
|
if lower == "/help" || lower == "/?" {
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "help".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if lower == "/version" {
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "version".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if lower == "/tools" {
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "tools".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if lower == "/ping" {
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "ping".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if lower == "/debug" {
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "debug".to_string(),
|
||||||
|
args: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if lower.starts_with("/model") {
|
||||||
|
let args: Vec<String> = trimmed
|
||||||
|
.split_whitespace()
|
||||||
|
.skip(1)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
return Submission::SystemCommand {
|
||||||
|
command: "model".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower == "/quit" || lower == "/exit" || lower == "/shutdown" {
|
||||||
|
return Submission::Quit;
|
||||||
|
}
|
||||||
|
|
||||||
// /thread <uuid> - switch thread
|
// /thread <uuid> - switch thread
|
||||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||||
@@ -70,20 +107,12 @@ impl SubmissionParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill commands
|
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||||
if let Some(rest) = lower.strip_prefix("/skill ") {
|
if trimmed.starts_with('{') {
|
||||||
let rest = rest.trim();
|
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
|
||||||
if let Some(submission) = Self::parse_skill_command(rest, trimmed) {
|
if matches!(submission, Submission::ExecApproval { .. }) {
|
||||||
return submission;
|
return submission;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a dynamic skill slash command (e.g. /review <args>)
|
|
||||||
if lower.starts_with('/') {
|
|
||||||
if let Some(submission) =
|
|
||||||
Self::parse_dynamic_skill_command(&lower, trimmed, skill_commands)
|
|
||||||
{
|
|
||||||
return submission;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,110 +145,6 @@ impl SubmissionParser {
|
|||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `/skill <subcommand>` forms.
|
|
||||||
fn parse_skill_command(rest: &str, _original: &str) -> Option<Submission> {
|
|
||||||
// /skill list
|
|
||||||
if rest == "list" {
|
|
||||||
return Some(Submission::SkillList);
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill deactivate
|
|
||||||
if rest == "deactivate" || rest == "off" {
|
|
||||||
return Some(Submission::SkillDeactivate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill load <url>
|
|
||||||
if let Some(url) = rest.strip_prefix("load ") {
|
|
||||||
let url = url.trim();
|
|
||||||
if !url.is_empty() {
|
|
||||||
return Some(Submission::SkillLoad {
|
|
||||||
url: url.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill remove <name>
|
|
||||||
if let Some(name) = rest.strip_prefix("remove ") {
|
|
||||||
let name = name.trim();
|
|
||||||
if !name.is_empty() {
|
|
||||||
return Some(Submission::SkillRemove {
|
|
||||||
name: name.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill info <name>
|
|
||||||
if let Some(name) = rest.strip_prefix("info ") {
|
|
||||||
let name = name.trim();
|
|
||||||
if !name.is_empty() {
|
|
||||||
return Some(Submission::SkillInfo {
|
|
||||||
name: name.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill activate <name> [args]
|
|
||||||
if let Some(rest) = rest.strip_prefix("activate ") {
|
|
||||||
let rest = rest.trim();
|
|
||||||
if !rest.is_empty() {
|
|
||||||
let (name, args) = split_first_word(rest);
|
|
||||||
return Some(Submission::SkillActivate {
|
|
||||||
name: name.to_string(),
|
|
||||||
args: args.map(|s| s.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /skill <name> [args] (shorthand for activate)
|
|
||||||
if !rest.is_empty() {
|
|
||||||
let (name, args) = split_first_word(rest);
|
|
||||||
return Some(Submission::SkillActivate {
|
|
||||||
name: name.to_string(),
|
|
||||||
args: args.map(|s| s.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a `/command args` matches a registered skill command.
|
|
||||||
fn parse_dynamic_skill_command(
|
|
||||||
lower: &str,
|
|
||||||
original: &str,
|
|
||||||
skill_commands: &[String],
|
|
||||||
) -> Option<Submission> {
|
|
||||||
// Extract the command word (without the leading /)
|
|
||||||
let without_slash = &lower[1..];
|
|
||||||
let (cmd, _) = split_first_word(without_slash);
|
|
||||||
|
|
||||||
if skill_commands.iter().any(|sc| sc == cmd) {
|
|
||||||
// Get args from the original (preserving case)
|
|
||||||
let original_without_slash = &original.trim()[1..];
|
|
||||||
let (_, args) = split_first_word(original_without_slash);
|
|
||||||
return Some(Submission::SkillActivateByCommand {
|
|
||||||
command: cmd.to_string(),
|
|
||||||
args: args.map(|s| s.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a string into the first word and the rest.
|
|
||||||
fn split_first_word(s: &str) -> (&str, Option<&str>) {
|
|
||||||
match s.find(char::is_whitespace) {
|
|
||||||
Some(idx) => {
|
|
||||||
let rest = s[idx..].trim();
|
|
||||||
if rest.is_empty() {
|
|
||||||
(&s[..idx], None)
|
|
||||||
} else {
|
|
||||||
(&s[..idx], Some(rest))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => (s, None),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A submission to the agent.
|
/// A submission to the agent.
|
||||||
@@ -288,44 +213,16 @@ pub enum Submission {
|
|||||||
/// Suggest next steps based on the current thread.
|
/// Suggest next steps based on the current thread.
|
||||||
Suggest,
|
Suggest,
|
||||||
|
|
||||||
/// Load a skill from a URL.
|
/// Quit the agent. Bypasses thread-state checks.
|
||||||
SkillLoad {
|
Quit,
|
||||||
/// URL to load the skill manifest from.
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Activate a skill by name.
|
/// System command (help, model, version, tools, ping, debug).
|
||||||
SkillActivate {
|
/// Bypasses thread-state checks and safety validation.
|
||||||
/// Skill name.
|
SystemCommand {
|
||||||
name: String,
|
/// The command name (e.g. "help", "model", "version").
|
||||||
/// Optional arguments.
|
|
||||||
args: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Activate a skill via its registered slash command.
|
|
||||||
SkillActivateByCommand {
|
|
||||||
/// The slash command that matched.
|
|
||||||
command: String,
|
command: String,
|
||||||
/// Optional arguments.
|
/// Arguments to the command.
|
||||||
args: Option<String>,
|
args: Vec<String>,
|
||||||
},
|
|
||||||
|
|
||||||
/// Deactivate the currently active skill.
|
|
||||||
SkillDeactivate,
|
|
||||||
|
|
||||||
/// List installed skills.
|
|
||||||
SkillList,
|
|
||||||
|
|
||||||
/// Remove an installed skill.
|
|
||||||
SkillRemove {
|
|
||||||
/// Skill name.
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Show info about an installed skill.
|
|
||||||
SkillInfo {
|
|
||||||
/// Skill name.
|
|
||||||
name: String,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,11 +290,7 @@ impl Submission {
|
|||||||
| Self::Heartbeat
|
| Self::Heartbeat
|
||||||
| Self::Summarize
|
| Self::Summarize
|
||||||
| Self::Suggest
|
| Self::Suggest
|
||||||
| Self::SkillLoad { .. }
|
| Self::SystemCommand { .. }
|
||||||
| Self::SkillDeactivate
|
|
||||||
| Self::SkillList
|
|
||||||
| Self::SkillRemove { .. }
|
|
||||||
| Self::SkillInfo { .. }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,97 +477,173 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parser_skill_list() {
|
fn test_parser_json_exec_approval() {
|
||||||
let submission = SubmissionParser::parse("/skill list");
|
let req_id = Uuid::new_v4();
|
||||||
assert!(matches!(submission, Submission::SkillList));
|
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||||
}
|
request_id: req_id,
|
||||||
|
approved: true,
|
||||||
|
always: false,
|
||||||
|
})
|
||||||
|
.expect("serialize");
|
||||||
|
|
||||||
#[test]
|
let submission = SubmissionParser::parse(&json);
|
||||||
fn test_parser_skill_load() {
|
|
||||||
let submission = SubmissionParser::parse(
|
|
||||||
"/skill load https://github.com/alice/skills/blob/main/review.toml",
|
|
||||||
);
|
|
||||||
assert!(matches!(submission, Submission::SkillLoad { url } if url.contains("github.com")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_skill_activate() {
|
|
||||||
let submission = SubmissionParser::parse("/skill activate pr-review");
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(submission, Submission::SkillActivate { name, args } if name == "pr-review" && args.is_none())
|
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||||
|
if request_id == req_id && approved && !always)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parser_skill_activate_with_args() {
|
fn test_parser_json_exec_approval_always() {
|
||||||
let submission = SubmissionParser::parse(
|
let req_id = Uuid::new_v4();
|
||||||
"/skill activate pr-review https://github.com/org/repo/pull/123",
|
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||||
);
|
request_id: req_id,
|
||||||
|
approved: true,
|
||||||
|
always: true,
|
||||||
|
})
|
||||||
|
.expect("serialize");
|
||||||
|
|
||||||
|
let submission = SubmissionParser::parse(&json);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(submission, Submission::SkillActivate { name, args } if name == "pr-review" && args.is_some())
|
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||||
|
if request_id == req_id && approved && always)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parser_skill_shorthand() {
|
fn test_parser_json_exec_approval_deny() {
|
||||||
// /skill <name> is shorthand for /skill activate <name>
|
let req_id = Uuid::new_v4();
|
||||||
let submission = SubmissionParser::parse("/skill pr-review");
|
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||||
|
request_id: req_id,
|
||||||
|
approved: false,
|
||||||
|
always: false,
|
||||||
|
})
|
||||||
|
.expect("serialize");
|
||||||
|
|
||||||
|
let submission = SubmissionParser::parse(&json);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(submission, Submission::SkillActivate { name, .. } if name == "pr-review")
|
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||||
|
if request_id == req_id && !approved && !always)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parser_skill_deactivate() {
|
fn test_parser_json_non_approval_stays_user_input() {
|
||||||
let submission = SubmissionParser::parse("/skill deactivate");
|
// A JSON UserInput should NOT be intercepted, it should be treated as text
|
||||||
assert!(matches!(submission, Submission::SkillDeactivate));
|
let json = r#"{"UserInput":{"content":"hello"}}"#;
|
||||||
|
let submission = SubmissionParser::parse(json);
|
||||||
let submission = SubmissionParser::parse("/skill off");
|
|
||||||
assert!(matches!(submission, Submission::SkillDeactivate));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_skill_remove() {
|
|
||||||
let submission = SubmissionParser::parse("/skill remove pr-review");
|
|
||||||
assert!(matches!(submission, Submission::SkillRemove { name } if name == "pr-review"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_skill_info() {
|
|
||||||
let submission = SubmissionParser::parse("/skill info pr-review");
|
|
||||||
assert!(matches!(submission, Submission::SkillInfo { name } if name == "pr-review"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_dynamic_skill_command() {
|
|
||||||
let skill_commands = vec!["review".to_string(), "debug".to_string()];
|
|
||||||
let submission = SubmissionParser::parse_with_skill_commands(
|
|
||||||
"/review https://github.com/org/repo/pull/123",
|
|
||||||
&skill_commands,
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
submission,
|
|
||||||
Submission::SkillActivateByCommand { command, args }
|
|
||||||
if command == "review" && args.as_deref() == Some("https://github.com/org/repo/pull/123")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_dynamic_skill_command_no_args() {
|
|
||||||
let skill_commands = vec!["debug".to_string()];
|
|
||||||
let submission = SubmissionParser::parse_with_skill_commands("/debug", &skill_commands);
|
|
||||||
assert!(matches!(
|
|
||||||
submission,
|
|
||||||
Submission::SkillActivateByCommand { command, args }
|
|
||||||
if command == "debug" && args.is_none()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parser_unknown_slash_not_skill() {
|
|
||||||
let skill_commands = vec!["review".to_string()];
|
|
||||||
// /unknown is not a skill command, becomes user input
|
|
||||||
let submission = SubmissionParser::parse_with_skill_commands("/unknown", &skill_commands);
|
|
||||||
assert!(matches!(submission, Submission::UserInput { .. }));
|
assert!(matches!(submission, Submission::UserInput { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_json_roundtrip_matches_approval_handler() {
|
||||||
|
// Simulate exactly what chat_approval_handler does: serialize a Submission::ExecApproval
|
||||||
|
// and verify the parser picks it up correctly.
|
||||||
|
let request_id = Uuid::new_v4();
|
||||||
|
let approval = Submission::ExecApproval {
|
||||||
|
request_id,
|
||||||
|
approved: true,
|
||||||
|
always: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&approval).expect("serialize");
|
||||||
|
eprintln!("Serialized approval JSON: {}", json);
|
||||||
|
|
||||||
|
let parsed = SubmissionParser::parse(&json);
|
||||||
|
assert!(
|
||||||
|
matches!(parsed, Submission::ExecApproval { request_id: rid, approved, always }
|
||||||
|
if rid == request_id && approved && !always),
|
||||||
|
"Expected ExecApproval, got {:?}",
|
||||||
|
parsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_help() {
|
||||||
|
let submission = SubmissionParser::parse("/help");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty())
|
||||||
|
);
|
||||||
|
|
||||||
|
let submission = SubmissionParser::parse("/?");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
|
||||||
|
);
|
||||||
|
|
||||||
|
let submission = SubmissionParser::parse("/HELP");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_model() {
|
||||||
|
// No args: show current model
|
||||||
|
let submission = SubmissionParser::parse("/model");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty())
|
||||||
|
);
|
||||||
|
|
||||||
|
// With args: switch model
|
||||||
|
let submission = SubmissionParser::parse("/model gpt-4o");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Case insensitive command, preserves arg case
|
||||||
|
let submission = SubmissionParser::parse("/MODEL Claude-3.5");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_version() {
|
||||||
|
let submission = SubmissionParser::parse("/version");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_tools() {
|
||||||
|
let submission = SubmissionParser::parse("/tools");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_ping() {
|
||||||
|
let submission = SubmissionParser::parse("/ping");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_debug() {
|
||||||
|
let submission = SubmissionParser::parse("/debug");
|
||||||
|
assert!(
|
||||||
|
matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_system_command_is_control() {
|
||||||
|
let submission = SubmissionParser::parse("/help");
|
||||||
|
assert!(submission.is_control());
|
||||||
|
assert!(!submission.starts_turn());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parser_quit() {
|
||||||
|
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||||
|
assert!(matches!(SubmissionParser::parse("/exit"), Submission::Quit));
|
||||||
|
assert!(matches!(
|
||||||
|
SubmissionParser::parse("/shutdown"),
|
||||||
|
Submission::Quit
|
||||||
|
));
|
||||||
|
assert!(matches!(SubmissionParser::parse("/QUIT"), Submission::Quit));
|
||||||
|
assert!(matches!(SubmissionParser::parse("/Exit"), Submission::Quit));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -272,7 +272,10 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RespondResult::ToolCalls(tool_calls) => {
|
RespondResult::ToolCalls {
|
||||||
|
tool_calls,
|
||||||
|
content,
|
||||||
|
} => {
|
||||||
// Model returned tool calls - execute them
|
// Model returned tool calls - execute them
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Job {} respond_with_tools returned {} tool calls",
|
"Job {} respond_with_tools returned {} tool calls",
|
||||||
@@ -280,6 +283,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
tool_calls.len()
|
tool_calls.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add assistant message with tool_calls (OpenAI protocol)
|
||||||
|
reason_ctx
|
||||||
|
.messages
|
||||||
|
.push(ChatMessage::assistant_with_tool_calls(
|
||||||
|
content,
|
||||||
|
tool_calls.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
||||||
|
|
||||||
@@ -417,14 +428,51 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute with timeout and timing
|
tracing::debug!(
|
||||||
|
tool = %tool_name,
|
||||||
|
params = %params,
|
||||||
|
job = %job_id,
|
||||||
|
"Tool call started"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute with per-tool timeout and timing
|
||||||
|
let tool_timeout = tool.execution_timeout();
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
let result = tokio::time::timeout(tool_timeout, async {
|
||||||
tool.execute(params.clone(), &job_ctx).await
|
tool.execute(params.clone(), &job_ctx).await
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(Ok(output)) => {
|
||||||
|
let result_str = serde_json::to_string(&output.result)
|
||||||
|
.unwrap_or_else(|_| "<serialize error>".to_string());
|
||||||
|
tracing::debug!(
|
||||||
|
tool = %tool_name,
|
||||||
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
|
result = %result_str,
|
||||||
|
"Tool call succeeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::debug!(
|
||||||
|
tool = %tool_name,
|
||||||
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
|
error = %e,
|
||||||
|
"Tool call failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!(
|
||||||
|
tool = %tool_name,
|
||||||
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
|
timeout_secs = tool_timeout.as_secs(),
|
||||||
|
"Tool call timed out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record action in memory and get the ActionRecord for persistence
|
// Record action in memory and get the ActionRecord for persistence
|
||||||
let action = match &result {
|
let action = match &result {
|
||||||
Ok(Ok(output)) => {
|
Ok(Ok(output)) => {
|
||||||
@@ -479,7 +527,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
let output = result
|
let output = result
|
||||||
.map_err(|_| crate::error::ToolError::Timeout {
|
.map_err(|_| crate::error::ToolError::Timeout {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
timeout: Duration::from_secs(60),
|
timeout: tool_timeout,
|
||||||
})?
|
})?
|
||||||
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
//! Bootstrap configuration for IronClaw.
|
||||||
|
//!
|
||||||
|
//! These are the only settings that MUST live on disk because they're needed
|
||||||
|
//! before the database connection is established. Everything else lives in the
|
||||||
|
//! `settings` table in PostgreSQL.
|
||||||
|
//!
|
||||||
|
//! File: `~/.ironclaw/bootstrap.json`
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::settings::KeySource;
|
||||||
|
|
||||||
|
/// Minimal config needed to connect to the database and decrypt secrets.
|
||||||
|
///
|
||||||
|
/// This is the only JSON file IronClaw reads from disk at startup.
|
||||||
|
/// All other configuration lives in the `settings` table in PostgreSQL.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BootstrapConfig {
|
||||||
|
/// Database connection URL (postgres://...).
|
||||||
|
#[serde(default)]
|
||||||
|
pub database_url: Option<String>,
|
||||||
|
|
||||||
|
/// Database connection pool size.
|
||||||
|
#[serde(default)]
|
||||||
|
pub database_pool_size: Option<usize>,
|
||||||
|
|
||||||
|
/// Source for the secrets master key.
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets_master_key_source: KeySource,
|
||||||
|
|
||||||
|
/// Whether onboarding wizard has been completed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub onboard_completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BootstrapConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
database_url: None,
|
||||||
|
database_pool_size: None,
|
||||||
|
secrets_master_key_source: KeySource::None,
|
||||||
|
onboard_completed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapConfig {
|
||||||
|
/// Default bootstrap file path: `~/.ironclaw/bootstrap.json`.
|
||||||
|
pub fn default_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("bootstrap.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy settings.json path (for migration detection).
|
||||||
|
pub fn legacy_settings_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from the default path, falling back to legacy settings.json,
|
||||||
|
/// then to defaults if neither exists.
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let bootstrap_path = Self::default_path();
|
||||||
|
if bootstrap_path.exists() {
|
||||||
|
return Self::load_from(&bootstrap_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to legacy settings.json (extract just the 4 bootstrap fields)
|
||||||
|
let legacy_path = Self::legacy_settings_path();
|
||||||
|
if legacy_path.exists() {
|
||||||
|
return Self::load_from_legacy(&legacy_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from a specific path.
|
||||||
|
pub fn load_from(path: &PathBuf) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract bootstrap fields from a legacy settings.json.
|
||||||
|
fn load_from_legacy(path: &PathBuf) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(data) => {
|
||||||
|
// The legacy Settings struct is a superset; serde will ignore extra fields.
|
||||||
|
serde_json::from_str(&data).unwrap_or_default()
|
||||||
|
}
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save to the default path.
|
||||||
|
pub fn save(&self) -> std::io::Result<()> {
|
||||||
|
self.save_to(&Self::default_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save to a specific path.
|
||||||
|
pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(self)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||||
|
std::fs::write(path, json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time migration from disk config files to the database settings table.
|
||||||
|
///
|
||||||
|
/// On first boot after upgrade, checks if:
|
||||||
|
/// 1. `~/.ironclaw/settings.json` exists
|
||||||
|
/// 2. The DB settings table is empty for this user
|
||||||
|
///
|
||||||
|
/// If both conditions hold, migrates settings, MCP servers, and session data
|
||||||
|
/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`.
|
||||||
|
pub async fn migrate_disk_to_db(
|
||||||
|
store: &crate::history::Store,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<(), MigrationError> {
|
||||||
|
let legacy_settings_path = BootstrapConfig::legacy_settings_path();
|
||||||
|
if !legacy_settings_path.exists() {
|
||||||
|
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only migrate if DB is empty for this user
|
||||||
|
let has_settings = store.has_settings(user_id).await.map_err(|e| {
|
||||||
|
MigrationError::Database(format!("Failed to check existing settings: {}", e))
|
||||||
|
})?;
|
||||||
|
if has_settings {
|
||||||
|
tracing::debug!(
|
||||||
|
"DB already has settings for user '{}', skipping migration",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Migrating disk settings to database...");
|
||||||
|
|
||||||
|
// 1. Load and migrate settings.json
|
||||||
|
let settings = crate::settings::Settings::load_from(&legacy_settings_path);
|
||||||
|
let db_map = settings.to_db_map();
|
||||||
|
if !db_map.is_empty() {
|
||||||
|
store
|
||||||
|
.set_all_settings(user_id, &db_map)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
MigrationError::Database(format!("Failed to write settings to DB: {}", e))
|
||||||
|
})?;
|
||||||
|
tracing::info!("Migrated {} settings to database", db_map.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Write bootstrap.json with the 4 essential fields
|
||||||
|
let bootstrap = BootstrapConfig {
|
||||||
|
database_url: settings.database_url.clone(),
|
||||||
|
database_pool_size: settings.database_pool_size,
|
||||||
|
secrets_master_key_source: settings.secrets_master_key_source,
|
||||||
|
onboard_completed: settings.onboard_completed,
|
||||||
|
};
|
||||||
|
bootstrap
|
||||||
|
.save()
|
||||||
|
.map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?;
|
||||||
|
tracing::info!("Wrote bootstrap.json");
|
||||||
|
|
||||||
|
// 3. Migrate mcp-servers.json if it exists
|
||||||
|
let ironclaw_dir = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw");
|
||||||
|
let mcp_path = ironclaw_dir.join("mcp-servers.json");
|
||||||
|
if mcp_path.exists() {
|
||||||
|
match std::fs::read_to_string(&mcp_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
|
Ok(value) => {
|
||||||
|
store
|
||||||
|
.set_setting(user_id, "mcp_servers", &value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
MigrationError::Database(format!(
|
||||||
|
"Failed to write MCP servers to DB: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
tracing::info!("Migrated mcp-servers.json to database");
|
||||||
|
|
||||||
|
rename_to_migrated(&mcp_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse mcp-servers.json: {}", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to read mcp-servers.json: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Migrate session.json if it exists
|
||||||
|
let session_path = ironclaw_dir.join("session.json");
|
||||||
|
if session_path.exists() {
|
||||||
|
match std::fs::read_to_string(&session_path) {
|
||||||
|
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
|
Ok(value) => {
|
||||||
|
store
|
||||||
|
.set_setting(user_id, "nearai.session", &value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
MigrationError::Database(format!(
|
||||||
|
"Failed to write session to DB: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
tracing::info!("Migrated session.json to database");
|
||||||
|
|
||||||
|
rename_to_migrated(&session_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse session.json: {}", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to read session.json: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Rename settings.json to .migrated (don't delete, safety net)
|
||||||
|
rename_to_migrated(&legacy_settings_path);
|
||||||
|
|
||||||
|
tracing::info!("Disk-to-DB migration complete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a file to `<name>.migrated` as a safety net.
|
||||||
|
fn rename_to_migrated(path: &PathBuf) {
|
||||||
|
let mut migrated = path.as_os_str().to_owned();
|
||||||
|
migrated.push(".migrated");
|
||||||
|
if let Err(e) = std::fs::rename(path, &migrated) {
|
||||||
|
tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during disk-to-DB migration.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum MigrationError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(String),
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bootstrap_save_load() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("bootstrap.json");
|
||||||
|
|
||||||
|
let config = BootstrapConfig {
|
||||||
|
database_url: Some("postgres://localhost/test".to_string()),
|
||||||
|
database_pool_size: Some(5),
|
||||||
|
secrets_master_key_source: KeySource::Keychain,
|
||||||
|
onboard_completed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
config.save_to(&path).unwrap();
|
||||||
|
|
||||||
|
let loaded = BootstrapConfig::load_from(&path);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.database_url,
|
||||||
|
Some("postgres://localhost/test".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.database_pool_size, Some(5));
|
||||||
|
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
|
||||||
|
assert!(loaded.onboard_completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bootstrap_from_legacy_settings() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("settings.json");
|
||||||
|
|
||||||
|
// Write a legacy settings.json with many extra fields
|
||||||
|
let legacy = serde_json::json!({
|
||||||
|
"database_url": "postgres://localhost/ironclaw",
|
||||||
|
"database_pool_size": 10,
|
||||||
|
"secrets_master_key_source": "keychain",
|
||||||
|
"onboard_completed": true,
|
||||||
|
"selected_model": "claude-3-5-sonnet",
|
||||||
|
"agent": { "name": "testbot", "max_parallel_jobs": 3 },
|
||||||
|
"heartbeat": { "enabled": true }
|
||||||
|
});
|
||||||
|
std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap();
|
||||||
|
|
||||||
|
let config = BootstrapConfig::load_from_legacy(&path);
|
||||||
|
assert_eq!(
|
||||||
|
config.database_url,
|
||||||
|
Some("postgres://localhost/ironclaw".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(config.database_pool_size, Some(10));
|
||||||
|
assert_eq!(config.secrets_master_key_source, KeySource::Keychain);
|
||||||
|
assert!(config.onboard_completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bootstrap_defaults() {
|
||||||
|
let config = BootstrapConfig::default();
|
||||||
|
assert!(config.database_url.is_none());
|
||||||
|
assert!(config.database_pool_size.is_none());
|
||||||
|
assert_eq!(config.secrets_master_key_source, KeySource::None);
|
||||||
|
assert!(!config.onboard_completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,10 +108,38 @@ pub enum StatusUpdate {
|
|||||||
ToolStarted { name: String },
|
ToolStarted { name: String },
|
||||||
/// Tool execution completed.
|
/// Tool execution completed.
|
||||||
ToolCompleted { name: String, success: bool },
|
ToolCompleted { name: String, success: bool },
|
||||||
|
/// Brief preview of tool execution output.
|
||||||
|
ToolResult { name: String, preview: String },
|
||||||
/// Streaming text chunk.
|
/// Streaming text chunk.
|
||||||
StreamChunk(String),
|
StreamChunk(String),
|
||||||
/// General status message.
|
/// General status message.
|
||||||
Status(String),
|
Status(String),
|
||||||
|
/// A sandbox job has started (shown as a clickable card in the UI).
|
||||||
|
JobStarted {
|
||||||
|
job_id: String,
|
||||||
|
title: String,
|
||||||
|
browse_url: String,
|
||||||
|
},
|
||||||
|
/// Tool requires user approval before execution.
|
||||||
|
ApprovalNeeded {
|
||||||
|
request_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
description: String,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
},
|
||||||
|
/// Extension needs user authentication (token or OAuth).
|
||||||
|
AuthRequired {
|
||||||
|
extension_name: String,
|
||||||
|
instructions: Option<String>,
|
||||||
|
auth_url: Option<String>,
|
||||||
|
setup_url: Option<String>,
|
||||||
|
},
|
||||||
|
/// Extension authentication completed.
|
||||||
|
AuthCompleted {
|
||||||
|
extension_name: String,
|
||||||
|
success: bool,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for message channels.
|
/// Trait for message channels.
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
//! Application state for the TUI.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
use crate::channels::cli::composer::ChatComposer;
|
|
||||||
use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
|
|
||||||
use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest};
|
|
||||||
|
|
||||||
/// Events that can occur in the TUI.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum AppEvent {
|
|
||||||
/// Keyboard/mouse input event.
|
|
||||||
Input(crossterm::event::Event),
|
|
||||||
/// Response from the agent.
|
|
||||||
Response(String),
|
|
||||||
/// Tool execution started.
|
|
||||||
ToolStarted { name: String },
|
|
||||||
/// Tool execution completed.
|
|
||||||
ToolCompleted { name: String, success: bool },
|
|
||||||
/// Request approval for a tool.
|
|
||||||
ApprovalRequested(ApprovalRequest),
|
|
||||||
/// Streaming chunk received.
|
|
||||||
StreamChunk(String),
|
|
||||||
/// Log message from the application (shown in status line).
|
|
||||||
LogMessage(String),
|
|
||||||
/// Thinking/status message (shown in chat window).
|
|
||||||
ThinkingMessage(String),
|
|
||||||
/// Error message (shown in chat window).
|
|
||||||
ErrorMessage(String),
|
|
||||||
/// Available models fetched from API.
|
|
||||||
AvailableModels(Vec<String>),
|
|
||||||
/// Force a redraw.
|
|
||||||
Redraw,
|
|
||||||
/// Quit the application.
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current input mode.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum InputMode {
|
|
||||||
/// Normal input mode.
|
|
||||||
Normal,
|
|
||||||
/// Editing input.
|
|
||||||
Editing,
|
|
||||||
/// Approval overlay is active.
|
|
||||||
Approval,
|
|
||||||
/// Model selector overlay is active.
|
|
||||||
ModelSelector,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Message in the chat history.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ChatMessage {
|
|
||||||
/// Who sent this message.
|
|
||||||
pub role: MessageRole,
|
|
||||||
/// The message content.
|
|
||||||
pub content: String,
|
|
||||||
/// Optional status indicator.
|
|
||||||
pub status: Option<MessageStatus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Who sent a message.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum MessageRole {
|
|
||||||
User,
|
|
||||||
Agent,
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status of a message (for in-progress indicators).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum MessageStatus {
|
|
||||||
Pending,
|
|
||||||
InProgress,
|
|
||||||
Complete,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChatMessage {
|
|
||||||
pub fn user(content: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
role: MessageRole::User,
|
|
||||||
content: content.into(),
|
|
||||||
status: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn agent(content: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
role: MessageRole::Agent,
|
|
||||||
content: content.into(),
|
|
||||||
status: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn system(content: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
role: MessageRole::System,
|
|
||||||
content: content.into(),
|
|
||||||
status: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_status(mut self, status: MessageStatus) -> Self {
|
|
||||||
self.status = Some(status);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application state.
|
|
||||||
pub struct AppState {
|
|
||||||
/// Current input mode.
|
|
||||||
pub mode: InputMode,
|
|
||||||
/// Chat message history.
|
|
||||||
pub messages: Vec<ChatMessage>,
|
|
||||||
/// Input composer.
|
|
||||||
pub composer: ChatComposer,
|
|
||||||
/// Approval overlay (if active).
|
|
||||||
pub approval: Option<ApprovalOverlay>,
|
|
||||||
/// Model selector overlay (if active).
|
|
||||||
pub model_selector: Option<ModelSelectorOverlay>,
|
|
||||||
/// Scroll offset for messages.
|
|
||||||
pub scroll_offset: u16,
|
|
||||||
/// Whether the app should quit.
|
|
||||||
pub should_quit: bool,
|
|
||||||
/// Pending approvals queue.
|
|
||||||
pub pending_approvals: VecDeque<ApprovalRequest>,
|
|
||||||
/// Current streaming response buffer.
|
|
||||||
pub streaming_buffer: Option<String>,
|
|
||||||
/// Status line message.
|
|
||||||
pub status_message: Option<String>,
|
|
||||||
/// Whether Ctrl+D was pressed (waiting for second press to quit).
|
|
||||||
pub ctrl_d_pending: bool,
|
|
||||||
/// Currently selected model.
|
|
||||||
pub current_model: String,
|
|
||||||
/// Available models (fetched from API).
|
|
||||||
pub available_models: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
/// Create a new app state.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
// Load saved model from settings
|
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
let current_model = settings.model_or("claude-3-5-sonnet-20241022");
|
|
||||||
|
|
||||||
Self {
|
|
||||||
mode: InputMode::Editing,
|
|
||||||
messages: vec![ChatMessage::system(
|
|
||||||
"Welcome to IronClaw. Type a message or /help for commands.",
|
|
||||||
)],
|
|
||||||
composer: ChatComposer::new(),
|
|
||||||
approval: None,
|
|
||||||
model_selector: None,
|
|
||||||
scroll_offset: 0,
|
|
||||||
should_quit: false,
|
|
||||||
pending_approvals: VecDeque::new(),
|
|
||||||
streaming_buffer: None,
|
|
||||||
status_message: None,
|
|
||||||
ctrl_d_pending: false,
|
|
||||||
current_model,
|
|
||||||
available_models: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show the model selector.
|
|
||||||
pub fn show_model_selector(&mut self) {
|
|
||||||
let request = ModelSelectorRequest {
|
|
||||||
current_model: self.current_model.clone(),
|
|
||||||
available_models: self.available_models.clone(),
|
|
||||||
};
|
|
||||||
self.model_selector = Some(ModelSelectorOverlay::new(request));
|
|
||||||
self.mode = InputMode::ModelSelector;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle model selection.
|
|
||||||
pub fn handle_model_selection(&mut self, selected: Option<String>) {
|
|
||||||
self.model_selector = None;
|
|
||||||
self.mode = InputMode::Editing;
|
|
||||||
|
|
||||||
if let Some(model) = selected {
|
|
||||||
if model != self.current_model {
|
|
||||||
self.current_model = model.clone();
|
|
||||||
// Save to settings
|
|
||||||
let mut settings = crate::settings::Settings::load();
|
|
||||||
if let Err(e) = settings.set_model(&model) {
|
|
||||||
tracing::warn!("Failed to save model setting: {}", e);
|
|
||||||
}
|
|
||||||
self.messages.push(ChatMessage::system(format!(
|
|
||||||
"Switched to model: {}",
|
|
||||||
ModelSelectorOverlay::format_model_name(&model)
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set available models (also updates selector if open).
|
|
||||||
pub fn set_available_models(&mut self, models: Vec<String>) {
|
|
||||||
self.available_models = models.clone();
|
|
||||||
|
|
||||||
// Update the selector if it's currently open
|
|
||||||
if let Some(ref mut selector) = self.model_selector {
|
|
||||||
selector.request.available_models = models;
|
|
||||||
// Reset selection index if it's out of bounds
|
|
||||||
if selector.selection_index >= selector.request.available_models.len() {
|
|
||||||
selector.selection_index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a user message to history.
|
|
||||||
pub fn add_user_message(&mut self, content: impl Into<String>) {
|
|
||||||
self.messages.push(ChatMessage::user(content));
|
|
||||||
self.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an agent response to history.
|
|
||||||
pub fn add_agent_message(&mut self, content: impl Into<String>) {
|
|
||||||
// If we were streaming, finalize it
|
|
||||||
if self.streaming_buffer.is_some() {
|
|
||||||
self.streaming_buffer = None;
|
|
||||||
}
|
|
||||||
// Remove any pending thinking message before adding the response
|
|
||||||
self.clear_thinking();
|
|
||||||
self.messages.push(ChatMessage::agent(content));
|
|
||||||
self.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an error message to the chat.
|
|
||||||
pub fn add_error_message(&mut self, content: impl Into<String>) {
|
|
||||||
self.messages.push(
|
|
||||||
ChatMessage::system(format!("Error: {}", content.into()))
|
|
||||||
.with_status(MessageStatus::Error),
|
|
||||||
);
|
|
||||||
self.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add or update a thinking/status message (shown as system message).
|
|
||||||
pub fn set_thinking(&mut self, content: impl Into<String>) {
|
|
||||||
let content = content.into();
|
|
||||||
// Check if last message is a thinking message (system with InProgress status)
|
|
||||||
if let Some(last) = self.messages.last_mut() {
|
|
||||||
if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) {
|
|
||||||
last.content = content;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add new thinking message
|
|
||||||
self.messages
|
|
||||||
.push(ChatMessage::system(content).with_status(MessageStatus::InProgress));
|
|
||||||
self.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear any thinking/status message.
|
|
||||||
pub fn clear_thinking(&mut self) {
|
|
||||||
// Remove any thinking messages (system with InProgress status)
|
|
||||||
self.messages.retain(|msg| {
|
|
||||||
!(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start streaming a response.
|
|
||||||
pub fn start_streaming(&mut self) {
|
|
||||||
self.streaming_buffer = Some(String::new());
|
|
||||||
self.messages
|
|
||||||
.push(ChatMessage::agent("").with_status(MessageStatus::InProgress));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append to the streaming buffer.
|
|
||||||
pub fn append_stream(&mut self, chunk: &str) {
|
|
||||||
if let Some(ref mut buffer) = self.streaming_buffer {
|
|
||||||
buffer.push_str(chunk);
|
|
||||||
// Update the last message
|
|
||||||
if let Some(last) = self.messages.last_mut() {
|
|
||||||
if last.role == MessageRole::Agent {
|
|
||||||
last.content = buffer.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finalize streaming.
|
|
||||||
pub fn finish_streaming(&mut self) {
|
|
||||||
if let Some(last) = self.messages.last_mut() {
|
|
||||||
if last.role == MessageRole::Agent {
|
|
||||||
last.status = Some(MessageStatus::Complete);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.streaming_buffer = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show an approval request.
|
|
||||||
pub fn show_approval(&mut self, request: ApprovalRequest) {
|
|
||||||
self.approval = Some(ApprovalOverlay::new(request));
|
|
||||||
self.mode = InputMode::Approval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Queue an approval request.
|
|
||||||
pub fn queue_approval(&mut self, request: ApprovalRequest) {
|
|
||||||
if self.approval.is_none() {
|
|
||||||
self.show_approval(request);
|
|
||||||
} else {
|
|
||||||
self.pending_approvals.push_back(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle approval response.
|
|
||||||
pub fn handle_approval_response(&mut self, approved: bool) -> Option<ApprovalRequest> {
|
|
||||||
let request = self.approval.take().map(|o| o.request);
|
|
||||||
|
|
||||||
// Show next pending approval if any
|
|
||||||
if let Some(next) = self.pending_approvals.pop_front() {
|
|
||||||
self.show_approval(next);
|
|
||||||
} else {
|
|
||||||
self.mode = InputMode::Editing;
|
|
||||||
}
|
|
||||||
|
|
||||||
if approved { request } else { None }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all pending approvals.
|
|
||||||
pub fn clear_approvals(&mut self) {
|
|
||||||
self.approval = None;
|
|
||||||
self.pending_approvals.clear();
|
|
||||||
self.mode = InputMode::Editing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the status message.
|
|
||||||
pub fn set_status(&mut self, message: impl Into<String>) {
|
|
||||||
self.status_message = Some(message.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the status message.
|
|
||||||
pub fn clear_status(&mut self) {
|
|
||||||
self.status_message = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll to the bottom of messages.
|
|
||||||
pub fn scroll_to_bottom(&mut self) {
|
|
||||||
// Will be calculated based on render area in render.rs
|
|
||||||
self.scroll_offset = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up.
|
|
||||||
pub fn scroll_up(&mut self, amount: u16) {
|
|
||||||
self.scroll_offset = self.scroll_offset.saturating_add(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down.
|
|
||||||
pub fn scroll_down(&mut self, amount: u16) {
|
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
//! Input composer with history and completion.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
/// Maximum number of history entries to keep.
|
|
||||||
const MAX_HISTORY: usize = 100;
|
|
||||||
|
|
||||||
/// Available slash commands for completion.
|
|
||||||
const SLASH_COMMANDS: &[&str] = &[
|
|
||||||
"/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Chat input composer with history navigation and slash command completion.
|
|
||||||
pub struct ChatComposer {
|
|
||||||
/// Current input buffer.
|
|
||||||
buffer: String,
|
|
||||||
/// Cursor position in the buffer.
|
|
||||||
cursor: usize,
|
|
||||||
/// Input history.
|
|
||||||
history: VecDeque<String>,
|
|
||||||
/// Current position in history (-1 = current input).
|
|
||||||
history_index: Option<usize>,
|
|
||||||
/// Saved current input when navigating history.
|
|
||||||
saved_input: String,
|
|
||||||
/// Completion candidates.
|
|
||||||
completions: Vec<String>,
|
|
||||||
/// Current completion index.
|
|
||||||
completion_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChatComposer {
|
|
||||||
/// Create a new composer.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
buffer: String::new(),
|
|
||||||
cursor: 0,
|
|
||||||
history: VecDeque::with_capacity(MAX_HISTORY),
|
|
||||||
history_index: None,
|
|
||||||
saved_input: String::new(),
|
|
||||||
completions: Vec::new(),
|
|
||||||
completion_index: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current input buffer.
|
|
||||||
pub fn buffer(&self) -> &str {
|
|
||||||
&self.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the cursor position.
|
|
||||||
pub fn cursor(&self) -> usize {
|
|
||||||
self.cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the buffer is empty.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.buffer.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a character at the cursor.
|
|
||||||
pub fn insert(&mut self, c: char) {
|
|
||||||
self.clear_completion();
|
|
||||||
self.buffer.insert(self.cursor, c);
|
|
||||||
self.cursor += c.len_utf8();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a string at the cursor.
|
|
||||||
pub fn insert_str(&mut self, s: &str) {
|
|
||||||
self.clear_completion();
|
|
||||||
self.buffer.insert_str(self.cursor, s);
|
|
||||||
self.cursor += s.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete the character before the cursor (backspace).
|
|
||||||
pub fn backspace(&mut self) {
|
|
||||||
self.clear_completion();
|
|
||||||
if self.cursor > 0 {
|
|
||||||
// Find the previous character boundary
|
|
||||||
let prev = self.buffer[..self.cursor]
|
|
||||||
.char_indices()
|
|
||||||
.next_back()
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(0);
|
|
||||||
self.buffer.drain(prev..self.cursor);
|
|
||||||
self.cursor = prev;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete the character at the cursor (delete).
|
|
||||||
pub fn delete(&mut self) {
|
|
||||||
self.clear_completion();
|
|
||||||
if self.cursor < self.buffer.len() {
|
|
||||||
// Find the next character boundary
|
|
||||||
let next = self.buffer[self.cursor..]
|
|
||||||
.char_indices()
|
|
||||||
.nth(1)
|
|
||||||
.map(|(i, _)| self.cursor + i)
|
|
||||||
.unwrap_or(self.buffer.len());
|
|
||||||
self.buffer.drain(self.cursor..next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor left.
|
|
||||||
pub fn move_left(&mut self) {
|
|
||||||
if self.cursor > 0 {
|
|
||||||
self.cursor = self.buffer[..self.cursor]
|
|
||||||
.char_indices()
|
|
||||||
.next_back()
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor right.
|
|
||||||
pub fn move_right(&mut self) {
|
|
||||||
if self.cursor < self.buffer.len() {
|
|
||||||
self.cursor = self.buffer[self.cursor..]
|
|
||||||
.char_indices()
|
|
||||||
.nth(1)
|
|
||||||
.map(|(i, _)| self.cursor + i)
|
|
||||||
.unwrap_or(self.buffer.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor to start.
|
|
||||||
pub fn move_home(&mut self) {
|
|
||||||
self.cursor = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor to end.
|
|
||||||
pub fn move_end(&mut self) {
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete from cursor to end of line.
|
|
||||||
pub fn kill_line(&mut self) {
|
|
||||||
self.clear_completion();
|
|
||||||
self.buffer.truncate(self.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete from start to cursor.
|
|
||||||
pub fn kill_to_start(&mut self) {
|
|
||||||
self.clear_completion();
|
|
||||||
self.buffer.drain(..self.cursor);
|
|
||||||
self.cursor = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the entire buffer.
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.buffer.clear();
|
|
||||||
self.cursor = 0;
|
|
||||||
self.clear_completion();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Submit the current input and return it.
|
|
||||||
pub fn submit(&mut self) -> String {
|
|
||||||
let input = std::mem::take(&mut self.buffer);
|
|
||||||
self.cursor = 0;
|
|
||||||
self.clear_completion();
|
|
||||||
|
|
||||||
// Add to history if non-empty and different from last entry
|
|
||||||
if !input.is_empty() && self.history.front() != Some(&input) {
|
|
||||||
self.history.push_front(input.clone());
|
|
||||||
if self.history.len() > MAX_HISTORY {
|
|
||||||
self.history.pop_back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.history_index = None;
|
|
||||||
self.saved_input.clear();
|
|
||||||
|
|
||||||
input
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to previous history entry.
|
|
||||||
pub fn history_prev(&mut self) {
|
|
||||||
if self.history.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.history_index {
|
|
||||||
None => {
|
|
||||||
// Save current input and go to first history entry
|
|
||||||
self.saved_input = std::mem::take(&mut self.buffer);
|
|
||||||
self.history_index = Some(0);
|
|
||||||
self.buffer = self.history[0].clone();
|
|
||||||
}
|
|
||||||
Some(i) if i + 1 < self.history.len() => {
|
|
||||||
self.history_index = Some(i + 1);
|
|
||||||
self.buffer = self.history[i + 1].clone();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
self.clear_completion();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to next history entry.
|
|
||||||
pub fn history_next(&mut self) {
|
|
||||||
match self.history_index {
|
|
||||||
Some(0) => {
|
|
||||||
// Go back to saved input
|
|
||||||
self.history_index = None;
|
|
||||||
self.buffer = std::mem::take(&mut self.saved_input);
|
|
||||||
}
|
|
||||||
Some(i) => {
|
|
||||||
self.history_index = Some(i - 1);
|
|
||||||
self.buffer = self.history[i - 1].clone();
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
self.clear_completion();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt tab completion.
|
|
||||||
pub fn complete(&mut self) {
|
|
||||||
// Only complete slash commands for now
|
|
||||||
if !self.buffer.starts_with('/') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.completions.is_empty() {
|
|
||||||
// Generate completions
|
|
||||||
let prefix = &self.buffer;
|
|
||||||
self.completions = SLASH_COMMANDS
|
|
||||||
.iter()
|
|
||||||
.filter(|cmd| cmd.starts_with(prefix))
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !self.completions.is_empty() {
|
|
||||||
self.completion_index = Some(0);
|
|
||||||
}
|
|
||||||
} else if let Some(i) = self.completion_index {
|
|
||||||
// Cycle through completions
|
|
||||||
self.completion_index = Some((i + 1) % self.completions.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply completion
|
|
||||||
if let Some(i) = self.completion_index {
|
|
||||||
if let Some(completion) = self.completions.get(i) {
|
|
||||||
self.buffer = completion.clone();
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear completion state.
|
|
||||||
fn clear_completion(&mut self) {
|
|
||||||
self.completions.clear();
|
|
||||||
self.completion_index = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current completion hint (for display).
|
|
||||||
pub fn completion_hint(&self) -> Option<&str> {
|
|
||||||
if let Some(i) = self.completion_index {
|
|
||||||
self.completions.get(i).map(|s| s.as_str())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of completions available.
|
|
||||||
pub fn completion_count(&self) -> usize {
|
|
||||||
self.completions.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ChatComposer {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_insert_and_backspace() {
|
|
||||||
let mut composer = ChatComposer::new();
|
|
||||||
composer.insert('h');
|
|
||||||
composer.insert('i');
|
|
||||||
assert_eq!(composer.buffer(), "hi");
|
|
||||||
composer.backspace();
|
|
||||||
assert_eq!(composer.buffer(), "h");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_history_navigation() {
|
|
||||||
let mut composer = ChatComposer::new();
|
|
||||||
composer.insert_str("first");
|
|
||||||
composer.submit();
|
|
||||||
composer.insert_str("second");
|
|
||||||
composer.submit();
|
|
||||||
|
|
||||||
composer.insert_str("current");
|
|
||||||
composer.history_prev();
|
|
||||||
assert_eq!(composer.buffer(), "second");
|
|
||||||
composer.history_prev();
|
|
||||||
assert_eq!(composer.buffer(), "first");
|
|
||||||
composer.history_next();
|
|
||||||
assert_eq!(composer.buffer(), "second");
|
|
||||||
composer.history_next();
|
|
||||||
assert_eq!(composer.buffer(), "current");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_completion() {
|
|
||||||
let mut composer = ChatComposer::new();
|
|
||||||
composer.insert_str("/hel");
|
|
||||||
composer.complete();
|
|
||||||
assert_eq!(composer.buffer(), "/help");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
//! Event handling for the TUI.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use crate::channels::IncomingMessage;
|
|
||||||
use crate::channels::cli::app::{AppEvent, AppState, InputMode};
|
|
||||||
use crate::channels::cli::render;
|
|
||||||
|
|
||||||
/// Tick rate for the event loop (50ms = 20fps).
|
|
||||||
const TICK_RATE: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// Run the main event loop.
|
|
||||||
pub fn run_event_loop(
|
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
||||||
app: &mut AppState,
|
|
||||||
msg_tx: mpsc::Sender<IncomingMessage>,
|
|
||||||
mut event_rx: mpsc::Receiver<AppEvent>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
loop {
|
|
||||||
// Render
|
|
||||||
terminal.draw(|f| render::render(f, app))?;
|
|
||||||
|
|
||||||
// Check for quit - send shutdown signal and exit
|
|
||||||
if app.should_quit {
|
|
||||||
// Send a shutdown message so the agent loop knows to exit
|
|
||||||
let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown");
|
|
||||||
let _ = msg_tx.blocking_send(shutdown_msg);
|
|
||||||
// Explicitly drop to close the channel
|
|
||||||
drop(msg_tx);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for terminal events
|
|
||||||
if event::poll(TICK_RATE)? {
|
|
||||||
let evt = event::read()?;
|
|
||||||
if let Err(e) = handle_event(app, evt, &msg_tx) {
|
|
||||||
tracing::error!("Event handling error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for app events from agent (non-blocking)
|
|
||||||
while let Ok(app_event) = event_rx.try_recv() {
|
|
||||||
handle_app_event(app, app_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a crossterm event.
|
|
||||||
fn handle_event(
|
|
||||||
app: &mut AppState,
|
|
||||||
event: Event,
|
|
||||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
match event {
|
|
||||||
Event::Key(key) => handle_key(app, key, msg_tx),
|
|
||||||
Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here
|
|
||||||
Event::Resize(_, _) => Ok(()), // Terminal will handle resize
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a key event.
|
|
||||||
fn handle_key(
|
|
||||||
app: &mut AppState,
|
|
||||||
key: KeyEvent,
|
|
||||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
// Global keybindings
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('c') => {
|
|
||||||
if app.mode == InputMode::Approval {
|
|
||||||
// Cancel all pending approvals
|
|
||||||
app.clear_approvals();
|
|
||||||
} else {
|
|
||||||
// Quit
|
|
||||||
app.should_quit = true;
|
|
||||||
}
|
|
||||||
app.ctrl_d_pending = false;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
KeyCode::Char('d') => {
|
|
||||||
if app.ctrl_d_pending {
|
|
||||||
// Second Ctrl+D, quit now
|
|
||||||
app.should_quit = true;
|
|
||||||
} else {
|
|
||||||
// First Ctrl+D, show hint
|
|
||||||
app.ctrl_d_pending = true;
|
|
||||||
app.set_status("Press Ctrl+D again to quit");
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Any other Ctrl+ combo clears the Ctrl+D pending state
|
|
||||||
app.ctrl_d_pending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Any non-Ctrl key clears the Ctrl+D pending state
|
|
||||||
app.ctrl_d_pending = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match app.mode {
|
|
||||||
InputMode::Normal => handle_normal_mode(app, key),
|
|
||||||
InputMode::Editing => handle_editing_mode(app, key, msg_tx),
|
|
||||||
InputMode::Approval => handle_approval_mode(app, key),
|
|
||||||
InputMode::ModelSelector => handle_model_selector_mode(app, key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keys in normal mode.
|
|
||||||
fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('i') | KeyCode::Char('a') => {
|
|
||||||
app.mode = InputMode::Editing;
|
|
||||||
}
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.should_quit = true;
|
|
||||||
}
|
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
|
||||||
app.scroll_up(1);
|
|
||||||
}
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
|
||||||
app.scroll_down(1);
|
|
||||||
}
|
|
||||||
KeyCode::PageUp => {
|
|
||||||
app.scroll_up(10);
|
|
||||||
}
|
|
||||||
KeyCode::PageDown => {
|
|
||||||
app.scroll_down(10);
|
|
||||||
}
|
|
||||||
KeyCode::Char('G') => {
|
|
||||||
app.scroll_to_bottom();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keys in editing mode.
|
|
||||||
fn handle_editing_mode(
|
|
||||||
app: &mut AppState,
|
|
||||||
key: KeyEvent,
|
|
||||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
if !app.composer.is_empty() {
|
|
||||||
let input = app.composer.submit();
|
|
||||||
|
|
||||||
// Handle /model command locally (TUI-specific)
|
|
||||||
if input.trim().eq_ignore_ascii_case("/model") {
|
|
||||||
app.show_model_selector();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
app.add_user_message(&input);
|
|
||||||
|
|
||||||
// Send message to agent
|
|
||||||
let msg = IncomingMessage::new("tui", "local-user", &input);
|
|
||||||
let _ = msg_tx.blocking_send(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.mode = InputMode::Normal;
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
app.composer.backspace();
|
|
||||||
}
|
|
||||||
KeyCode::Delete => {
|
|
||||||
app.composer.delete();
|
|
||||||
}
|
|
||||||
KeyCode::Left => {
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
||||||
// Move word left (simplified: just move to start)
|
|
||||||
app.composer.move_home();
|
|
||||||
} else {
|
|
||||||
app.composer.move_left();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Right => {
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
||||||
// Move word right (simplified: just move to end)
|
|
||||||
app.composer.move_end();
|
|
||||||
} else {
|
|
||||||
app.composer.move_right();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Home => {
|
|
||||||
app.composer.move_home();
|
|
||||||
}
|
|
||||||
KeyCode::End => {
|
|
||||||
app.composer.move_end();
|
|
||||||
}
|
|
||||||
KeyCode::Up => {
|
|
||||||
app.composer.history_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Down => {
|
|
||||||
app.composer.history_next();
|
|
||||||
}
|
|
||||||
KeyCode::Tab => {
|
|
||||||
app.composer.complete();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
||||||
match c {
|
|
||||||
'a' => app.composer.move_home(),
|
|
||||||
'e' => app.composer.move_end(),
|
|
||||||
'k' => app.composer.kill_line(),
|
|
||||||
'u' => app.composer.kill_to_start(),
|
|
||||||
'w' => {
|
|
||||||
// Delete word backwards (simplified: clear)
|
|
||||||
app.composer.clear();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.composer.insert(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keys in approval mode.
|
|
||||||
fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
|
|
||||||
if let Some(ref mut overlay) = app.approval {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Left | KeyCode::Char('h') => {
|
|
||||||
overlay.select_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Right | KeyCode::Char('l') => {
|
|
||||||
overlay.select_next();
|
|
||||||
}
|
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
||||||
let (approved, _always) = overlay.confirm();
|
|
||||||
app.handle_approval_response(approved);
|
|
||||||
// TODO: If always, remember to auto-approve this tool
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
if let Some(approved) = overlay.handle_shortcut(c) {
|
|
||||||
app.handle_approval_response(approved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
// Deny this approval
|
|
||||||
app.handle_approval_response(false);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keys in model selector mode.
|
|
||||||
fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
|
|
||||||
if let Some(ref mut overlay) = app.model_selector {
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Left | KeyCode::Char('h') => {
|
|
||||||
overlay.select_prev();
|
|
||||||
}
|
|
||||||
KeyCode::Right | KeyCode::Char('l') => {
|
|
||||||
overlay.select_next();
|
|
||||||
}
|
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
||||||
let selected = overlay.selected_model().map(|s| s.to_string());
|
|
||||||
app.handle_model_selection(selected);
|
|
||||||
}
|
|
||||||
KeyCode::Esc => {
|
|
||||||
// Cancel without changing model
|
|
||||||
app.handle_model_selection(None);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an application event.
|
|
||||||
fn handle_app_event(app: &mut AppState, event: AppEvent) {
|
|
||||||
match event {
|
|
||||||
AppEvent::Response(content) => {
|
|
||||||
app.add_agent_message(content);
|
|
||||||
}
|
|
||||||
AppEvent::ToolStarted { name } => {
|
|
||||||
app.set_thinking(format!("⚙️ Running tool: {}...", name));
|
|
||||||
}
|
|
||||||
AppEvent::ToolCompleted { name, success } => {
|
|
||||||
if success {
|
|
||||||
app.set_thinking(format!("✓ Tool {} completed", name));
|
|
||||||
} else {
|
|
||||||
app.set_thinking(format!("✗ Tool {} failed", name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppEvent::ApprovalRequested(request) => {
|
|
||||||
app.queue_approval(request);
|
|
||||||
}
|
|
||||||
AppEvent::StreamChunk(chunk) => {
|
|
||||||
if app.streaming_buffer.is_none() {
|
|
||||||
app.start_streaming();
|
|
||||||
}
|
|
||||||
app.append_stream(&chunk);
|
|
||||||
}
|
|
||||||
AppEvent::Redraw => {
|
|
||||||
// Just triggers a redraw on next loop iteration
|
|
||||||
}
|
|
||||||
AppEvent::Quit => {
|
|
||||||
app.should_quit = true;
|
|
||||||
}
|
|
||||||
AppEvent::Input(_) => {
|
|
||||||
// Already handled directly
|
|
||||||
}
|
|
||||||
AppEvent::LogMessage(msg) => {
|
|
||||||
app.set_status(msg);
|
|
||||||
}
|
|
||||||
AppEvent::ThinkingMessage(msg) => {
|
|
||||||
app.set_thinking(msg);
|
|
||||||
}
|
|
||||||
AppEvent::ErrorMessage(msg) => {
|
|
||||||
app.add_error_message(msg);
|
|
||||||
}
|
|
||||||
AppEvent::AvailableModels(models) => {
|
|
||||||
app.set_available_models(models);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
//! Interactive TUI channel using Ratatui.
|
|
||||||
//!
|
|
||||||
//! Provides a rich terminal interface with:
|
|
||||||
//! - Input history navigation
|
|
||||||
//! - Slash command completion
|
|
||||||
//! - Approval overlays for tool execution
|
|
||||||
//! - Streaming response display
|
|
||||||
|
|
||||||
mod app;
|
|
||||||
mod composer;
|
|
||||||
mod events;
|
|
||||||
mod model_selector;
|
|
||||||
mod overlay;
|
|
||||||
mod render;
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crossterm::{
|
|
||||||
execute,
|
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
|
||||||
};
|
|
||||||
use ratatui::Terminal;
|
|
||||||
use ratatui::backend::CrosstermBackend;
|
|
||||||
use tokio::sync::{Mutex, mpsc};
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
|
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
||||||
use crate::error::ChannelError;
|
|
||||||
|
|
||||||
pub use app::{AppEvent, AppState, InputMode};
|
|
||||||
pub use composer::ChatComposer;
|
|
||||||
pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
|
|
||||||
pub use overlay::{ApprovalOverlay, ApprovalRequest};
|
|
||||||
|
|
||||||
/// TUI channel for interactive terminal input with Ratatui.
|
|
||||||
pub struct TuiChannel {
|
|
||||||
/// Channel for sending events to the TUI (created upfront for logging).
|
|
||||||
event_tx: mpsc::Sender<AppEvent>,
|
|
||||||
/// Receiver end, taken when start() is called.
|
|
||||||
event_rx: Arc<Mutex<Option<mpsc::Receiver<AppEvent>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiChannel {
|
|
||||||
/// Create a new TUI channel.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (event_tx, event_rx) = mpsc::channel(64);
|
|
||||||
Self {
|
|
||||||
event_tx,
|
|
||||||
event_rx: Arc::new(Mutex::new(Some(event_rx))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a log writer that sends messages to the TUI status line.
|
|
||||||
/// Use this to redirect tracing output to the TUI.
|
|
||||||
pub fn log_writer(&self) -> TuiLogWriter {
|
|
||||||
TuiLogWriter::new(self.event_tx.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a sender for sending events to the TUI.
|
|
||||||
/// Use this to send available models or other events from outside the channel.
|
|
||||||
pub fn event_sender(&self) -> mpsc::Sender<AppEvent> {
|
|
||||||
self.event_tx.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TuiChannel {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Channel for TuiChannel {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"tui"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
||||||
let (msg_tx, msg_rx) = mpsc::channel(32);
|
|
||||||
|
|
||||||
// Take the event receiver (can only start once)
|
|
||||||
let event_rx = {
|
|
||||||
let mut guard = self.event_rx.lock().await;
|
|
||||||
guard.take().ok_or_else(|| ChannelError::StartupFailed {
|
|
||||||
name: "tui".to_string(),
|
|
||||||
reason: "TUI channel already started".to_string(),
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
if let Err(e) = run_tui(msg_tx, event_rx) {
|
|
||||||
// Try to restore terminal even on error
|
|
||||||
let _ = disable_raw_mode();
|
|
||||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
|
||||||
eprintln!("TUI error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Box::pin(ReceiverStream::new(msg_rx)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn respond(
|
|
||||||
&self,
|
|
||||||
_msg: &IncomingMessage,
|
|
||||||
response: OutgoingResponse,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
self.event_tx
|
|
||||||
.send(AppEvent::Response(response.content))
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::SendFailed {
|
|
||||||
name: "tui".to_string(),
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_status(
|
|
||||||
&self,
|
|
||||||
status: StatusUpdate,
|
|
||||||
_metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
let event = match status {
|
|
||||||
StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)),
|
|
||||||
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name },
|
|
||||||
StatusUpdate::ToolCompleted { name, success } => {
|
|
||||||
AppEvent::ToolCompleted { name, success }
|
|
||||||
}
|
|
||||||
StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk),
|
|
||||||
StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg),
|
|
||||||
};
|
|
||||||
self.event_tx
|
|
||||||
.send(event)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::SendFailed {
|
|
||||||
name: "tui".to_string(),
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast(
|
|
||||||
&self,
|
|
||||||
_user_id: &str,
|
|
||||||
response: OutgoingResponse,
|
|
||||||
) -> Result<(), ChannelError> {
|
|
||||||
// For TUI, broadcasts appear as regular agent responses with a notification indicator
|
|
||||||
self.event_tx
|
|
||||||
.send(AppEvent::Response(response.content))
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::SendFailed {
|
|
||||||
name: "tui".to_string(),
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
||||||
// Channel is healthy if we haven't been closed
|
|
||||||
if self.event_tx.is_closed() {
|
|
||||||
Err(ChannelError::HealthCheckFailed {
|
|
||||||
name: "tui".to_string(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
||||||
let _ = self.event_tx.send(AppEvent::Quit).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the TUI event loop (blocking).
|
|
||||||
fn run_tui(
|
|
||||||
msg_tx: mpsc::Sender<IncomingMessage>,
|
|
||||||
event_rx: mpsc::Receiver<AppEvent>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
// Setup terminal
|
|
||||||
// Note: We don't enable mouse capture so users can select text normally
|
|
||||||
enable_raw_mode()?;
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
// Create app state
|
|
||||||
let mut app = AppState::new();
|
|
||||||
|
|
||||||
// Run event loop
|
|
||||||
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx);
|
|
||||||
|
|
||||||
// Restore terminal
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TUI-compatible tracing writer that sends log messages to the TUI status line.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TuiLogWriter {
|
|
||||||
tx: mpsc::Sender<AppEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TuiLogWriter {
|
|
||||||
pub fn new(tx: mpsc::Sender<AppEvent>) -> Self {
|
|
||||||
Self { tx }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::io::Write for TuiLogWriter {
|
|
||||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
||||||
if let Ok(s) = std::str::from_utf8(buf) {
|
|
||||||
let s = s.trim();
|
|
||||||
if !s.is_empty() {
|
|
||||||
// Fire and forget - don't block on logging
|
|
||||||
let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(buf.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&mut self) -> io::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter {
|
|
||||||
type Writer = Self;
|
|
||||||
|
|
||||||
fn make_writer(&'a self) -> Self::Writer {
|
|
||||||
self.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//! Model selector overlay for switching LLM models.
|
|
||||||
|
|
||||||
/// Request to show the model selector.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ModelSelectorRequest {
|
|
||||||
/// Currently selected model.
|
|
||||||
pub current_model: String,
|
|
||||||
/// Available models to choose from.
|
|
||||||
pub available_models: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Model selector overlay state.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ModelSelectorOverlay {
|
|
||||||
/// The request that triggered this overlay.
|
|
||||||
pub request: ModelSelectorRequest,
|
|
||||||
/// Currently highlighted index.
|
|
||||||
pub selection_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelSelectorOverlay {
|
|
||||||
/// Create a new model selector overlay.
|
|
||||||
pub fn new(request: ModelSelectorRequest) -> Self {
|
|
||||||
// Find the current model in the list, default to 0
|
|
||||||
let selection_index = request
|
|
||||||
.available_models
|
|
||||||
.iter()
|
|
||||||
.position(|m| m == &request.current_model)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
request,
|
|
||||||
selection_index,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the list of available models.
|
|
||||||
pub fn models(&self) -> &[String] {
|
|
||||||
&self.request.available_models
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move selection up.
|
|
||||||
pub fn select_prev(&mut self) {
|
|
||||||
let len = self.request.available_models.len();
|
|
||||||
if len == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.selection_index > 0 {
|
|
||||||
self.selection_index -= 1;
|
|
||||||
} else {
|
|
||||||
// Wrap to bottom
|
|
||||||
self.selection_index = len - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move selection down.
|
|
||||||
pub fn select_next(&mut self) {
|
|
||||||
let len = self.request.available_models.len();
|
|
||||||
if len == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.selection_index < len - 1 {
|
|
||||||
self.selection_index += 1;
|
|
||||||
} else {
|
|
||||||
// Wrap to top
|
|
||||||
self.selection_index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the currently selected model name.
|
|
||||||
pub fn selected_model(&self) -> Option<&str> {
|
|
||||||
self.request
|
|
||||||
.available_models
|
|
||||||
.get(self.selection_index)
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the selection is the current model.
|
|
||||||
pub fn is_current(&self) -> bool {
|
|
||||||
self.selected_model() == Some(&self.request.current_model)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a model name for display (shorten long names).
|
|
||||||
pub fn format_model_name(model: &str) -> String {
|
|
||||||
// Shorten fireworks model names
|
|
||||||
if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") {
|
|
||||||
return format!("fireworks/{}", rest);
|
|
||||||
}
|
|
||||||
// Shorten other long prefixes
|
|
||||||
if let Some(rest) = model.strip_prefix("accounts/") {
|
|
||||||
return rest.to_string();
|
|
||||||
}
|
|
||||||
model.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_model_selector_navigation() {
|
|
||||||
let request = ModelSelectorRequest {
|
|
||||||
current_model: "gpt-4o".to_string(),
|
|
||||||
available_models: vec![
|
|
||||||
"claude-3-5-sonnet".to_string(),
|
|
||||||
"gpt-4o".to_string(),
|
|
||||||
"gpt-4o-mini".to_string(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let mut overlay = ModelSelectorOverlay::new(request);
|
|
||||||
|
|
||||||
// Should start at gpt-4o index (1)
|
|
||||||
assert_eq!(overlay.selected_model(), Some("gpt-4o"));
|
|
||||||
|
|
||||||
// Navigate down
|
|
||||||
overlay.select_next();
|
|
||||||
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
|
|
||||||
|
|
||||||
// Navigate down (wrap)
|
|
||||||
overlay.select_next();
|
|
||||||
assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet"));
|
|
||||||
|
|
||||||
// Navigate up
|
|
||||||
overlay.select_prev();
|
|
||||||
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_format_model_name() {
|
|
||||||
assert_eq!(
|
|
||||||
ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"),
|
|
||||||
"claude-3-5-sonnet-20241022"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ModelSelectorOverlay::format_model_name(
|
|
||||||
"fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct"
|
|
||||||
),
|
|
||||||
"fireworks/llama-v3p1-405b-instruct"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_models() {
|
|
||||||
let request = ModelSelectorRequest {
|
|
||||||
current_model: "unknown".to_string(),
|
|
||||||
available_models: vec![],
|
|
||||||
};
|
|
||||||
let mut overlay = ModelSelectorOverlay::new(request);
|
|
||||||
assert_eq!(overlay.selected_model(), None);
|
|
||||||
|
|
||||||
// Should not panic
|
|
||||||
overlay.select_next();
|
|
||||||
overlay.select_prev();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
//! Approval overlay modal.
|
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// A request for user approval before executing a tool.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ApprovalRequest {
|
|
||||||
/// Unique ID for this request.
|
|
||||||
pub id: Uuid,
|
|
||||||
/// Name of the tool requesting approval.
|
|
||||||
pub tool_name: String,
|
|
||||||
/// Description of what the tool will do.
|
|
||||||
pub description: String,
|
|
||||||
/// Parameters being passed to the tool.
|
|
||||||
pub parameters: serde_json::Value,
|
|
||||||
/// Whether this is a destructive operation.
|
|
||||||
pub destructive: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApprovalRequest {
|
|
||||||
/// Create a new approval request.
|
|
||||||
pub fn new(
|
|
||||||
tool_name: impl Into<String>,
|
|
||||||
description: impl Into<String>,
|
|
||||||
parameters: serde_json::Value,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
tool_name: tool_name.into(),
|
|
||||||
description: description.into(),
|
|
||||||
parameters,
|
|
||||||
destructive: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark as destructive operation.
|
|
||||||
pub fn destructive(mut self) -> Self {
|
|
||||||
self.destructive = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current selection in the approval overlay.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ApprovalSelection {
|
|
||||||
/// Yes, approve this action.
|
|
||||||
Yes,
|
|
||||||
/// No, deny this action.
|
|
||||||
No,
|
|
||||||
/// Always approve this tool (for this session).
|
|
||||||
Always,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApprovalSelection {
|
|
||||||
/// Get the next selection (cycling).
|
|
||||||
pub fn next(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::Yes => Self::No,
|
|
||||||
Self::No => Self::Always,
|
|
||||||
Self::Always => Self::Yes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the previous selection (cycling).
|
|
||||||
pub fn prev(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::Yes => Self::Always,
|
|
||||||
Self::No => Self::Yes,
|
|
||||||
Self::Always => Self::No,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approval overlay state.
|
|
||||||
pub struct ApprovalOverlay {
|
|
||||||
/// The request being shown.
|
|
||||||
pub request: ApprovalRequest,
|
|
||||||
/// Current selection.
|
|
||||||
pub selection: ApprovalSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApprovalOverlay {
|
|
||||||
/// Create a new approval overlay.
|
|
||||||
pub fn new(request: ApprovalRequest) -> Self {
|
|
||||||
Self {
|
|
||||||
request,
|
|
||||||
selection: ApprovalSelection::Yes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move selection left.
|
|
||||||
pub fn select_prev(&mut self) {
|
|
||||||
self.selection = self.selection.prev();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move selection right.
|
|
||||||
pub fn select_next(&mut self) {
|
|
||||||
self.selection = self.selection.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle keyboard shortcut.
|
|
||||||
pub fn handle_shortcut(&mut self, c: char) -> Option<bool> {
|
|
||||||
match c.to_ascii_lowercase() {
|
|
||||||
'y' => Some(true),
|
|
||||||
'n' => Some(false),
|
|
||||||
'a' => {
|
|
||||||
self.selection = ApprovalSelection::Always;
|
|
||||||
Some(true)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Confirm the current selection.
|
|
||||||
pub fn confirm(&self) -> (bool, bool) {
|
|
||||||
match self.selection {
|
|
||||||
ApprovalSelection::Yes => (true, false),
|
|
||||||
ApprovalSelection::No => (false, false),
|
|
||||||
ApprovalSelection::Always => (true, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_approval_selection_cycle() {
|
|
||||||
let sel = ApprovalSelection::Yes;
|
|
||||||
assert_eq!(sel.next(), ApprovalSelection::No);
|
|
||||||
assert_eq!(sel.next().next(), ApprovalSelection::Always);
|
|
||||||
assert_eq!(sel.next().next().next(), ApprovalSelection::Yes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_approval_shortcuts() {
|
|
||||||
let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({}));
|
|
||||||
let mut overlay = ApprovalOverlay::new(request);
|
|
||||||
|
|
||||||
assert_eq!(overlay.handle_shortcut('y'), Some(true));
|
|
||||||
assert_eq!(overlay.handle_shortcut('n'), Some(false));
|
|
||||||
assert_eq!(overlay.handle_shortcut('x'), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
//! TUI rendering with Ratatui.
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
Frame,
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
text::{Line, Span, Text},
|
|
||||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus};
|
|
||||||
use crate::channels::cli::model_selector::ModelSelectorOverlay;
|
|
||||||
use crate::channels::cli::overlay::ApprovalSelection;
|
|
||||||
|
|
||||||
/// Render the entire UI.
|
|
||||||
pub fn render(frame: &mut Frame, app: &AppState) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Min(3), // Messages
|
|
||||||
Constraint::Length(3), // Input
|
|
||||||
Constraint::Length(1), // Status
|
|
||||||
])
|
|
||||||
.split(frame.area());
|
|
||||||
|
|
||||||
render_messages(frame, app, chunks[0]);
|
|
||||||
render_input(frame, app, chunks[1]);
|
|
||||||
render_status(frame, app, chunks[2]);
|
|
||||||
|
|
||||||
// Render approval overlay if active
|
|
||||||
if app.mode == InputMode::Approval {
|
|
||||||
render_approval_overlay(frame, app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the message history.
|
|
||||||
fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) {
|
|
||||||
// Build all lines from all messages
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
for msg in &app.messages {
|
|
||||||
let (prefix, style) = match msg.role {
|
|
||||||
MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)),
|
|
||||||
MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)),
|
|
||||||
MessageRole::System => (
|
|
||||||
"",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::DarkGray)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let status_indicator = match msg.status {
|
|
||||||
Some(MessageStatus::Pending) => " ⏳",
|
|
||||||
Some(MessageStatus::InProgress) => " ⚙️",
|
|
||||||
Some(MessageStatus::Complete) => " ✓",
|
|
||||||
Some(MessageStatus::Error) => " ✗",
|
|
||||||
None => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Split content by newlines and create a line for each
|
|
||||||
let content_lines: Vec<&str> = msg.content.lines().collect();
|
|
||||||
for (i, line_text) in content_lines.iter().enumerate() {
|
|
||||||
if i == 0 {
|
|
||||||
// First line gets the prefix
|
|
||||||
let line_content = if status_indicator.is_empty() {
|
|
||||||
format!("{}{}", prefix, line_text)
|
|
||||||
} else if content_lines.len() == 1 {
|
|
||||||
format!("{}{}{}", prefix, line_text, status_indicator)
|
|
||||||
} else {
|
|
||||||
format!("{}{}", prefix, line_text)
|
|
||||||
};
|
|
||||||
lines.push(Line::styled(line_content, style));
|
|
||||||
} else if i == content_lines.len() - 1 && !status_indicator.is_empty() {
|
|
||||||
// Last line gets status indicator
|
|
||||||
lines.push(Line::styled(
|
|
||||||
format!("{}{}", line_text, status_indicator),
|
|
||||||
style,
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// Middle lines just get the content
|
|
||||||
lines.push(Line::styled(line_text.to_string(), style));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add empty line between messages for readability
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate scroll - show most recent messages
|
|
||||||
let visible_height = area.height.saturating_sub(2) as usize; // Account for borders
|
|
||||||
let total_lines = lines.len();
|
|
||||||
let scroll_offset = total_lines.saturating_sub(visible_height);
|
|
||||||
|
|
||||||
let text = Text::from(lines);
|
|
||||||
let messages = Paragraph::new(text)
|
|
||||||
.block(Block::default().borders(Borders::ALL).title("Chat"))
|
|
||||||
.wrap(Wrap { trim: false })
|
|
||||||
.scroll((scroll_offset as u16, 0));
|
|
||||||
|
|
||||||
frame.render_widget(messages, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the input area (or model selector when in ModelSelector mode).
|
|
||||||
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
|
|
||||||
// In ModelSelector mode, render inline selector instead of input
|
|
||||||
if app.mode == InputMode::ModelSelector {
|
|
||||||
render_model_selector_inline(frame, app, area);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_style = match app.mode {
|
|
||||||
InputMode::Editing => Style::default().fg(Color::Yellow),
|
|
||||||
InputMode::Normal => Style::default(),
|
|
||||||
InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray),
|
|
||||||
};
|
|
||||||
|
|
||||||
let buffer = app.composer.buffer();
|
|
||||||
let cursor = app.composer.cursor();
|
|
||||||
|
|
||||||
// Build the input text with cursor
|
|
||||||
let (before, after) = buffer.split_at(cursor.min(buffer.len()));
|
|
||||||
let cursor_char = after.chars().next().unwrap_or(' ');
|
|
||||||
let after_cursor = if after.is_empty() {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
&after[cursor_char.len_utf8()..]
|
|
||||||
};
|
|
||||||
|
|
||||||
let input = Paragraph::new(Line::from(vec![
|
|
||||||
Span::raw(before),
|
|
||||||
Span::styled(
|
|
||||||
cursor_char.to_string(),
|
|
||||||
Style::default().bg(Color::White).fg(Color::Black),
|
|
||||||
),
|
|
||||||
Span::raw(after_cursor),
|
|
||||||
]))
|
|
||||||
.style(input_style)
|
|
||||||
.block(Block::default().borders(Borders::ALL).title("Input"));
|
|
||||||
|
|
||||||
frame.render_widget(input, area);
|
|
||||||
|
|
||||||
// Show cursor in editing mode
|
|
||||||
if app.mode == InputMode::Editing {
|
|
||||||
// Calculate cursor position accounting for the block border
|
|
||||||
let cursor_x = area.x + 1 + cursor as u16;
|
|
||||||
let cursor_y = area.y + 1;
|
|
||||||
frame.set_cursor_position((cursor_x, cursor_y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render inline model selector in the input area.
|
|
||||||
fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) {
|
|
||||||
let Some(ref overlay) = app.model_selector else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let models = overlay.models();
|
|
||||||
|
|
||||||
// Build horizontal list of models
|
|
||||||
let mut spans: Vec<Span> = Vec::new();
|
|
||||||
|
|
||||||
if models.is_empty() {
|
|
||||||
spans.push(Span::styled(
|
|
||||||
"Loading models...",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::DarkGray)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
for (i, model) in models.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
spans.push(Span::raw(" "));
|
|
||||||
}
|
|
||||||
|
|
||||||
let display_name = ModelSelectorOverlay::format_model_name(model);
|
|
||||||
let is_selected = i == overlay.selection_index;
|
|
||||||
let is_current = model == &overlay.request.current_model;
|
|
||||||
|
|
||||||
let style = if is_selected {
|
|
||||||
Style::default().bg(Color::Blue).fg(Color::White)
|
|
||||||
} else if is_current {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
};
|
|
||||||
|
|
||||||
let prefix = if is_current { "●" } else { " " };
|
|
||||||
spans.push(Span::styled(format!("{}{}", prefix, display_name), style));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = Paragraph::new(Line::from(spans))
|
|
||||||
.block(Block::default().borders(Borders::ALL).title(Span::styled(
|
|
||||||
"Select Model",
|
|
||||||
Style::default().fg(Color::Cyan),
|
|
||||||
)))
|
|
||||||
.scroll((
|
|
||||||
0,
|
|
||||||
calculate_model_scroll(overlay, area.width.saturating_sub(2)),
|
|
||||||
));
|
|
||||||
|
|
||||||
frame.render_widget(content, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate horizontal scroll offset to keep selected model visible.
|
|
||||||
fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 {
|
|
||||||
let models = overlay.models();
|
|
||||||
if models.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate position of selected model (rough calculation)
|
|
||||||
let mut pos: u16 = 0;
|
|
||||||
for (i, model) in models.iter().enumerate() {
|
|
||||||
let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing
|
|
||||||
if i == overlay.selection_index {
|
|
||||||
// Check if selection is beyond visible area
|
|
||||||
if pos > visible_width {
|
|
||||||
return pos.saturating_sub(visible_width / 2);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
pos += name_len;
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the status line.
|
|
||||||
fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
|
|
||||||
let status_text = if let Some(ref msg) = app.status_message {
|
|
||||||
msg.clone()
|
|
||||||
} else {
|
|
||||||
match app.mode {
|
|
||||||
InputMode::Normal | InputMode::Editing => {
|
|
||||||
let model = ModelSelectorOverlay::format_model_name(&app.current_model);
|
|
||||||
format!("{} | /model to switch", model)
|
|
||||||
}
|
|
||||||
InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(),
|
|
||||||
InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray));
|
|
||||||
|
|
||||||
frame.render_widget(status, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the approval overlay.
|
|
||||||
fn render_approval_overlay(frame: &mut Frame, app: &AppState) {
|
|
||||||
let Some(ref overlay) = app.approval else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let area = frame.area();
|
|
||||||
|
|
||||||
// Calculate overlay size and position
|
|
||||||
let overlay_width = (area.width * 60 / 100).min(60);
|
|
||||||
let overlay_height = 12;
|
|
||||||
let overlay_x = (area.width - overlay_width) / 2;
|
|
||||||
let overlay_y = (area.height - overlay_height) / 2;
|
|
||||||
|
|
||||||
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
|
|
||||||
|
|
||||||
// Clear the area behind the overlay
|
|
||||||
frame.render_widget(Clear, overlay_area);
|
|
||||||
|
|
||||||
// Build overlay content
|
|
||||||
let title = if overlay.request.destructive {
|
|
||||||
"⚠️ Approval Required (Destructive)"
|
|
||||||
} else {
|
|
||||||
"Approval Required"
|
|
||||||
};
|
|
||||||
|
|
||||||
let title_style = if overlay.request.destructive {
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the text content
|
|
||||||
let mut lines = vec![
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw(&overlay.request.tool_name),
|
|
||||||
]),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(overlay.request.description.as_str()),
|
|
||||||
Line::from(""),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add parameters preview (truncated)
|
|
||||||
let params_str = serde_json::to_string_pretty(&overlay.request.parameters)
|
|
||||||
.unwrap_or_else(|_| "{}".to_string());
|
|
||||||
let params_preview: String = params_str.chars().take(100).collect();
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)),
|
|
||||||
Span::styled(params_preview, Style::default().fg(Color::DarkGray)),
|
|
||||||
]));
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
|
|
||||||
// Add selection buttons
|
|
||||||
let yes_style = if overlay.selection == ApprovalSelection::Yes {
|
|
||||||
Style::default().bg(Color::Green).fg(Color::Black)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
};
|
|
||||||
|
|
||||||
let no_style = if overlay.selection == ApprovalSelection::No {
|
|
||||||
Style::default().bg(Color::Red).fg(Color::Black)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Red)
|
|
||||||
};
|
|
||||||
|
|
||||||
let always_style = if overlay.selection == ApprovalSelection::Always {
|
|
||||||
Style::default().bg(Color::Blue).fg(Color::Black)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Blue)
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" [Y]es ", yes_style),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" [N]o ", no_style),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" [A]lways ", always_style),
|
|
||||||
]));
|
|
||||||
|
|
||||||
let content = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(Span::styled(title, title_style)),
|
|
||||||
)
|
|
||||||
.wrap(Wrap { trim: true });
|
|
||||||
|
|
||||||
frame.render_widget(content, overlay_area);
|
|
||||||
}
|
|
||||||
+23
-59
@@ -1,6 +1,5 @@
|
|||||||
//! HTTP webhook channel for receiving messages via HTTP POST.
|
//! HTTP webhook channel for receiving messages via HTTP POST.
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -32,8 +31,6 @@ struct HttpChannelState {
|
|||||||
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||||
/// Pending responses keyed by message ID.
|
/// Pending responses keyed by message ID.
|
||||||
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
|
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
|
||||||
/// Server shutdown signal.
|
|
||||||
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
|
|
||||||
/// Expected webhook secret for authentication (if configured).
|
/// Expected webhook secret for authentication (if configured).
|
||||||
webhook_secret: Option<String>,
|
webhook_secret: Option<String>,
|
||||||
/// Fixed user ID for this HTTP channel.
|
/// Fixed user ID for this HTTP channel.
|
||||||
@@ -74,7 +71,6 @@ impl HttpChannel {
|
|||||||
state: Arc::new(HttpChannelState {
|
state: Arc::new(HttpChannelState {
|
||||||
tx: RwLock::new(None),
|
tx: RwLock::new(None),
|
||||||
pending_responses: RwLock::new(std::collections::HashMap::new()),
|
pending_responses: RwLock::new(std::collections::HashMap::new()),
|
||||||
shutdown_tx: RwLock::new(None),
|
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
user_id,
|
user_id,
|
||||||
rate_limit: tokio::sync::Mutex::new(RateLimitState {
|
rate_limit: tokio::sync::Mutex::new(RateLimitState {
|
||||||
@@ -84,6 +80,24 @@ impl HttpChannel {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the channel's axum routes with state applied.
|
||||||
|
///
|
||||||
|
/// The returned `Router` shares the same `Arc<HttpChannelState>` that
|
||||||
|
/// `start()` later populates. Before `start()` is called the webhook
|
||||||
|
/// handler returns 503 ("Channel not started").
|
||||||
|
pub fn routes(&self) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health_handler))
|
||||||
|
.route("/webhook", post(webhook_handler))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||||
|
.with_state(self.state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the configured host and port for this channel.
|
||||||
|
pub fn addr(&self) -> (&str, u16) {
|
||||||
|
(&self.config.host, self.config.port)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -303,53 +317,11 @@ impl Channel for HttpChannel {
|
|||||||
let (tx, rx) = mpsc::channel(256);
|
let (tx, rx) = mpsc::channel(256);
|
||||||
*self.state.tx.write().await = Some(tx);
|
*self.state.tx.write().await = Some(tx);
|
||||||
|
|
||||||
let state = self.state.clone();
|
tracing::info!(
|
||||||
let host = self.config.host.clone();
|
"HTTP channel ready ({}:{})",
|
||||||
let port = self.config.port;
|
self.config.host,
|
||||||
|
self.config.port
|
||||||
// Parse address before spawning so we can return errors
|
);
|
||||||
let addr: SocketAddr =
|
|
||||||
format!("{}:{}", host, port)
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| ChannelError::StartupFailed {
|
|
||||||
name: "http".to_string(),
|
|
||||||
reason: format!("Invalid address '{}:{}': {}", host, port, e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Bind listener before spawning so we can return errors
|
|
||||||
let listener =
|
|
||||||
tokio::net::TcpListener::bind(addr)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::StartupFailed {
|
|
||||||
name: "http".to_string(),
|
|
||||||
reason: format!("Failed to bind to {}: {}", addr, e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!("HTTP channel listening on {}", addr);
|
|
||||||
|
|
||||||
// Create router
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/health", get(health_handler))
|
|
||||||
.route("/webhook", post(webhook_handler))
|
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
|
||||||
.with_state(state.clone());
|
|
||||||
|
|
||||||
// Create shutdown channel
|
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
||||||
*self.state.shutdown_tx.write().await = Some(shutdown_tx);
|
|
||||||
|
|
||||||
// Spawn server (listener is already bound, serve errors are logged)
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = axum::serve(listener, app)
|
|
||||||
.with_graceful_shutdown(async {
|
|
||||||
let _ = shutdown_rx.await;
|
|
||||||
tracing::info!("HTTP channel shutting down");
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!("HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
@@ -363,13 +335,10 @@ impl Channel for HttpChannel {
|
|||||||
if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) {
|
if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) {
|
||||||
let _ = tx.send(response.content);
|
let _ = tx.send(response.content);
|
||||||
}
|
}
|
||||||
// For async webhooks, we'd need to make an HTTP callback here
|
|
||||||
// but that requires the caller to provide a callback URL
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||||
// Check if we have an active sender
|
|
||||||
if self.state.tx.read().await.is_some() {
|
if self.state.tx.read().await.is_some() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -380,11 +349,6 @@ impl Channel for HttpChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||||
// Send shutdown signal
|
|
||||||
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
|
|
||||||
let _ = tx.send(());
|
|
||||||
}
|
|
||||||
// Clear the message sender
|
|
||||||
*self.state.tx.write().await = None;
|
*self.state.tx.write().await = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -9,9 +9,9 @@
|
|||||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||||
//! │ ChannelManager │
|
//! │ ChannelManager │
|
||||||
//! │ │
|
//! │ │
|
||||||
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
//! │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │
|
//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │
|
||||||
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
//! │ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
//! │ │ │ │ │
|
//! │ │ │ │ │
|
||||||
//! │ └─────────────────┴─────────────────┘ │
|
//! │ └─────────────────┴─────────────────┘ │
|
||||||
//! │ │ │
|
//! │ │ │
|
||||||
@@ -28,14 +28,16 @@
|
|||||||
//! See the [`wasm`] module for details.
|
//! See the [`wasm`] module for details.
|
||||||
|
|
||||||
mod channel;
|
mod channel;
|
||||||
pub mod cli;
|
|
||||||
mod http;
|
mod http;
|
||||||
mod manager;
|
mod manager;
|
||||||
mod repl;
|
mod repl;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
pub mod web;
|
||||||
|
mod webhook_server;
|
||||||
|
|
||||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
pub use cli::{AppEvent, TuiChannel};
|
|
||||||
pub use http::HttpChannel;
|
pub use http::HttpChannel;
|
||||||
pub use manager::ChannelManager;
|
pub use manager::ChannelManager;
|
||||||
pub use repl::ReplChannel;
|
pub use repl::ReplChannel;
|
||||||
|
pub use web::GatewayChannel;
|
||||||
|
pub use webhook_server::{WebhookServer, WebhookServerConfig};
|
||||||
|
|||||||
+380
-78
@@ -1,6 +1,8 @@
|
|||||||
//! Interactive REPL channel for debugging and testing.
|
//! Interactive REPL channel with line editing and markdown rendering.
|
||||||
//!
|
//!
|
||||||
//! Provides a command-line interface for interacting with the agent.
|
//! Provides the primary CLI interface for interacting with the agent.
|
||||||
|
//! Uses rustyline for line editing, history, and tab-completion.
|
||||||
|
//! Uses termimad for rendering markdown responses inline.
|
||||||
//!
|
//!
|
||||||
//! ## Commands
|
//! ## Commands
|
||||||
//!
|
//!
|
||||||
@@ -14,23 +16,167 @@
|
|||||||
//! - `/new` - Start a new thread
|
//! - `/new` - Start a new thread
|
||||||
//! - `yes`/`no`/`always` - Respond to tool approval prompts
|
//! - `yes`/`no`/`always` - Respond to tool approval prompts
|
||||||
|
|
||||||
use std::io::{self, BufRead, Write};
|
use std::borrow::Cow;
|
||||||
|
use std::io::{self, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use rustyline::completion::Completer;
|
||||||
|
use rustyline::config::Config;
|
||||||
|
use rustyline::error::ReadlineError;
|
||||||
|
use rustyline::highlight::Highlighter;
|
||||||
|
use rustyline::hint::Hinter;
|
||||||
|
use rustyline::validate::Validator;
|
||||||
|
use rustyline::{CompletionType, Editor, Helper};
|
||||||
|
use termimad::MadSkin;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
/// REPL channel for interactive agent debugging.
|
/// Slash commands available in the REPL.
|
||||||
|
const SLASH_COMMANDS: &[&str] = &[
|
||||||
|
"/help",
|
||||||
|
"/quit",
|
||||||
|
"/exit",
|
||||||
|
"/debug",
|
||||||
|
"/model",
|
||||||
|
"/undo",
|
||||||
|
"/redo",
|
||||||
|
"/clear",
|
||||||
|
"/compact",
|
||||||
|
"/new",
|
||||||
|
"/interrupt",
|
||||||
|
"/version",
|
||||||
|
"/tools",
|
||||||
|
"/ping",
|
||||||
|
"/job",
|
||||||
|
"/status",
|
||||||
|
"/cancel",
|
||||||
|
"/list",
|
||||||
|
"/heartbeat",
|
||||||
|
"/summarize",
|
||||||
|
"/suggest",
|
||||||
|
"/thread",
|
||||||
|
"/resume",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rustyline helper for slash-command tab completion.
|
||||||
|
struct ReplHelper;
|
||||||
|
|
||||||
|
impl Completer for ReplHelper {
|
||||||
|
type Candidate = String;
|
||||||
|
|
||||||
|
fn complete(
|
||||||
|
&self,
|
||||||
|
line: &str,
|
||||||
|
pos: usize,
|
||||||
|
_ctx: &rustyline::Context<'_>,
|
||||||
|
) -> rustyline::Result<(usize, Vec<String>)> {
|
||||||
|
if !line.starts_with('/') {
|
||||||
|
return Ok((0, vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = &line[..pos];
|
||||||
|
let matches: Vec<String> = SLASH_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter(|cmd| cmd.starts_with(prefix))
|
||||||
|
.map(|cmd| cmd.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok((0, matches))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hinter for ReplHelper {
|
||||||
|
type Hint = String;
|
||||||
|
|
||||||
|
fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
|
||||||
|
if !line.starts_with('/') || pos < line.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
SLASH_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.find(|cmd| cmd.starts_with(line) && **cmd != line)
|
||||||
|
.map(|cmd| cmd[line.len()..].to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Highlighter for ReplHelper {
|
||||||
|
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
||||||
|
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Validator for ReplHelper {}
|
||||||
|
impl Helper for ReplHelper {}
|
||||||
|
|
||||||
|
/// Build a termimad skin with our color scheme.
|
||||||
|
fn make_skin() -> MadSkin {
|
||||||
|
let mut skin = MadSkin::default();
|
||||||
|
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
|
||||||
|
skin.bold.set_fg(termimad::crossterm::style::Color::White);
|
||||||
|
skin.italic
|
||||||
|
.set_fg(termimad::crossterm::style::Color::Magenta);
|
||||||
|
skin.inline_code
|
||||||
|
.set_fg(termimad::crossterm::style::Color::Green);
|
||||||
|
skin.code_block
|
||||||
|
.set_fg(termimad::crossterm::style::Color::Green);
|
||||||
|
skin.code_block.left_margin = 2;
|
||||||
|
skin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format JSON params as `key: value` lines for the approval card.
|
||||||
|
fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
|
||||||
|
match params {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (key, value) in map {
|
||||||
|
let val_str = match value {
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
let display = if s.len() > 120 { &s[..120] } else { s };
|
||||||
|
format!("\x1b[32m\"{display}\"\x1b[0m")
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let rendered = other.to_string();
|
||||||
|
if rendered.len() > 120 {
|
||||||
|
format!("{}...", &rendered[..120])
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}"));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string());
|
||||||
|
let truncated = if pretty.len() > 300 {
|
||||||
|
format!("{}...", &pretty[..300])
|
||||||
|
} else {
|
||||||
|
pretty
|
||||||
|
};
|
||||||
|
truncated
|
||||||
|
.lines()
|
||||||
|
.map(|l| format!("{indent}\x1b[90m{l}\x1b[0m"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// REPL channel with line editing and markdown rendering.
|
||||||
pub struct ReplChannel {
|
pub struct ReplChannel {
|
||||||
/// Optional single message to send (for -m flag).
|
/// Optional single message to send (for -m flag).
|
||||||
single_message: Option<String>,
|
single_message: Option<String>,
|
||||||
/// Debug mode flag (shared with input thread).
|
/// Debug mode flag (shared with input thread).
|
||||||
debug_mode: Arc<AtomicBool>,
|
debug_mode: Arc<AtomicBool>,
|
||||||
|
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||||
|
is_streaming: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplChannel {
|
impl ReplChannel {
|
||||||
@@ -39,6 +185,7 @@ impl ReplChannel {
|
|||||||
Self {
|
Self {
|
||||||
single_message: None,
|
single_message: None,
|
||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +194,7 @@ impl ReplChannel {
|
|||||||
Self {
|
Self {
|
||||||
single_message: Some(message),
|
single_message: Some(message),
|
||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,32 +210,41 @@ impl Default for ReplChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
// Bold white for section headers, bold cyan for commands, dim gray for descriptions
|
||||||
r#"
|
let h = "\x1b[1m"; // bold (section headers)
|
||||||
IronClaw REPL - Interactive debugging mode
|
let c = "\x1b[1;36m"; // bold cyan (commands)
|
||||||
|
let d = "\x1b[90m"; // dim gray (descriptions)
|
||||||
|
let r = "\x1b[0m"; // reset
|
||||||
|
|
||||||
Commands:
|
println!();
|
||||||
/help Show this help message
|
println!(" {h}IronClaw REPL{r}");
|
||||||
/quit, /exit Exit the REPL
|
println!();
|
||||||
/debug Toggle debug mode (verbose output)
|
println!(" {h}Commands{r}");
|
||||||
/undo Undo the last turn
|
println!(" {c}/help{r} {d}show this help{r}");
|
||||||
/redo Redo an undone turn
|
println!(" {c}/debug{r} {d}toggle verbose output{r}");
|
||||||
/clear Clear the conversation
|
println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}");
|
||||||
/compact Compact the context window
|
println!();
|
||||||
/new Start a new conversation thread
|
println!(" {h}Conversation{r}");
|
||||||
/interrupt Stop the current operation
|
println!(" {c}/undo{r} {d}undo the last turn{r}");
|
||||||
|
println!(" {c}/redo{r} {d}redo an undone turn{r}");
|
||||||
|
println!(" {c}/clear{r} {d}clear conversation{r}");
|
||||||
|
println!(" {c}/compact{r} {d}compact context window{r}");
|
||||||
|
println!(" {c}/new{r} {d}new conversation thread{r}");
|
||||||
|
println!(" {c}/interrupt{r} {d}stop current operation{r}");
|
||||||
|
println!();
|
||||||
|
println!(" {h}Approval responses{r}");
|
||||||
|
println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}");
|
||||||
|
println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}");
|
||||||
|
println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
Approval responses (when prompted):
|
/// Get the history file path (~/.ironclaw/history).
|
||||||
yes, y Approve the tool execution
|
fn history_path() -> std::path::PathBuf {
|
||||||
no, n Deny the tool execution
|
dirs::home_dir()
|
||||||
always Approve and auto-approve this tool for the session
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
Tips:
|
.join("history")
|
||||||
- Tool calls requiring approval will pause and wait for your response
|
|
||||||
- Use /debug to see detailed tool inputs and outputs
|
|
||||||
- Press Ctrl+C to interrupt a long-running operation
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -102,47 +259,60 @@ impl Channel for ReplChannel {
|
|||||||
let debug_mode = Arc::clone(&self.debug_mode);
|
let debug_mode = Arc::clone(&self.debug_mode);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// If single message mode, send it and exit
|
// Single message mode: send it and return
|
||||||
if let Some(msg) = single_message {
|
if let Some(msg) = single_message {
|
||||||
let incoming = IncomingMessage::new("repl", "user", &msg);
|
let incoming = IncomingMessage::new("repl", "user", &msg);
|
||||||
if tx.blocking_send(incoming).is_err() {
|
let _ = tx.blocking_send(incoming);
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Wait a bit for response, then the channel will close
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive REPL mode
|
// Set up rustyline
|
||||||
let stdin = io::stdin();
|
let config = Config::builder()
|
||||||
let mut stdout = io::stdout();
|
.history_ignore_dups(true)
|
||||||
|
.expect("valid config")
|
||||||
|
.auto_add_history(true)
|
||||||
|
.completion_type(CompletionType::List)
|
||||||
|
.build();
|
||||||
|
|
||||||
println!("IronClaw REPL - Type /help for commands, /quit to exit");
|
let mut rl = match Editor::with_config(config) {
|
||||||
|
Ok(editor) => editor,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to initialize line editor: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.set_helper(Some(ReplHelper));
|
||||||
|
|
||||||
|
// Load history
|
||||||
|
let hist_path = history_path();
|
||||||
|
if let Some(parent) = hist_path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let _ = rl.load_history(&hist_path);
|
||||||
|
|
||||||
|
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Print prompt
|
|
||||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||||
"[debug] > "
|
"\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m "
|
||||||
} else {
|
} else {
|
||||||
"> "
|
"\x1b[1;36m\u{203A}\x1b[0m "
|
||||||
};
|
};
|
||||||
print!("{}", prompt);
|
|
||||||
let _ = stdout.flush();
|
|
||||||
|
|
||||||
// Read line
|
match rl.readline(prompt) {
|
||||||
let mut line = String::new();
|
Ok(line) => {
|
||||||
match stdin.lock().read_line(&mut line) {
|
|
||||||
Ok(0) => break, // EOF
|
|
||||||
Ok(_) => {
|
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle local REPL commands
|
// Handle local REPL commands (only commands that need
|
||||||
|
// immediate local handling stay here)
|
||||||
match line.to_lowercase().as_str() {
|
match line.to_lowercase().as_str() {
|
||||||
"/quit" | "/exit" => break,
|
"/quit" | "/exit" => break,
|
||||||
"/help" | "/?" => {
|
"/help" => {
|
||||||
print_help();
|
print_help();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -150,9 +320,9 @@ impl Channel for ReplChannel {
|
|||||||
let current = debug_mode.load(Ordering::Relaxed);
|
let current = debug_mode.load(Ordering::Relaxed);
|
||||||
debug_mode.store(!current, Ordering::Relaxed);
|
debug_mode.store(!current, Ordering::Relaxed);
|
||||||
if !current {
|
if !current {
|
||||||
println!("Debug mode ON - showing verbose tool output");
|
println!("\x1b[90mdebug mode on\x1b[0m");
|
||||||
} else {
|
} else {
|
||||||
println!("Debug mode OFF");
|
println!("\x1b[90mdebug mode off\x1b[0m");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -164,9 +334,28 @@ impl Channel for ReplChannel {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(ReadlineError::Interrupted) => {
|
||||||
|
// Ctrl+C: send /interrupt
|
||||||
|
let msg = IncomingMessage::new("repl", "user", "/interrupt");
|
||||||
|
if tx.blocking_send(msg).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ReadlineError::Eof) => {
|
||||||
|
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
||||||
|
let msg = IncomingMessage::new("repl", "user", "/quit");
|
||||||
|
let _ = tx.blocking_send(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Input error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save history on exit
|
||||||
|
let _ = rl.save_history(&history_path());
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
@@ -177,8 +366,27 @@ impl Channel for ReplChannel {
|
|||||||
_msg: &IncomingMessage,
|
_msg: &IncomingMessage,
|
||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
println!();
|
let width = crossterm::terminal::size()
|
||||||
println!("{}", response.content);
|
.map(|(w, _)| w as usize)
|
||||||
|
.unwrap_or(80);
|
||||||
|
|
||||||
|
// If we were streaming, the content was already printed via StreamChunk.
|
||||||
|
// Just finish the line and reset.
|
||||||
|
if self.is_streaming.swap(false, Ordering::Relaxed) {
|
||||||
|
println!();
|
||||||
|
println!();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dim separator line before the response
|
||||||
|
let sep_width = width.min(80);
|
||||||
|
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
||||||
|
|
||||||
|
// Render markdown
|
||||||
|
let skin = make_skin();
|
||||||
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
||||||
|
|
||||||
|
print!("{text}");
|
||||||
println!();
|
println!();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -192,38 +400,126 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
StatusUpdate::Thinking(msg) => {
|
StatusUpdate::Thinking(msg) => {
|
||||||
if debug {
|
eprintln!(" \x1b[90m\u{25CB} {msg}\x1b[0m");
|
||||||
eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg);
|
|
||||||
} else {
|
|
||||||
eprint!(".");
|
|
||||||
let _ = io::stderr().flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolStarted { name } => {
|
StatusUpdate::ToolStarted { name } => {
|
||||||
if debug {
|
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||||
eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name);
|
|
||||||
} else {
|
|
||||||
eprintln!("\x1b[33m⚡ {}\x1b[0m", name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolCompleted { name, success } => {
|
StatusUpdate::ToolCompleted { name, success } => {
|
||||||
if debug {
|
if success {
|
||||||
if success {
|
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
||||||
eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name);
|
} else {
|
||||||
} else {
|
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
|
||||||
eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name);
|
|
||||||
}
|
|
||||||
} else if !success {
|
|
||||||
eprintln!("\x1b[31m✗ {} failed\x1b[0m", name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
StatusUpdate::ToolResult { name: _, preview } => {
|
||||||
|
eprintln!(" \x1b[90m{preview}\x1b[0m");
|
||||||
|
}
|
||||||
StatusUpdate::StreamChunk(chunk) => {
|
StatusUpdate::StreamChunk(chunk) => {
|
||||||
print!("{}", chunk);
|
// Print separator on the false-to-true transition
|
||||||
|
if !self.is_streaming.swap(true, Ordering::Relaxed) {
|
||||||
|
let width = crossterm::terminal::size()
|
||||||
|
.map(|(w, _)| w as usize)
|
||||||
|
.unwrap_or(80);
|
||||||
|
let sep_width = width.min(80);
|
||||||
|
eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width));
|
||||||
|
}
|
||||||
|
print!("{chunk}");
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
}
|
}
|
||||||
|
StatusUpdate::JobStarted {
|
||||||
|
job_id,
|
||||||
|
title,
|
||||||
|
browse_url,
|
||||||
|
} => {
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
||||||
|
);
|
||||||
|
}
|
||||||
StatusUpdate::Status(msg) => {
|
StatusUpdate::Status(msg) => {
|
||||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||||
eprintln!("\x1b[90m[status] {}\x1b[0m", msg);
|
eprintln!(" \x1b[90m{msg}\x1b[0m");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id,
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
} => {
|
||||||
|
let term_width = crossterm::terminal::size()
|
||||||
|
.map(|(w, _)| w as usize)
|
||||||
|
.unwrap_or(80);
|
||||||
|
let box_width = (term_width.saturating_sub(4)).clamp(40, 60);
|
||||||
|
|
||||||
|
// Short request ID for the bottom border
|
||||||
|
let short_id = if request_id.len() > 8 {
|
||||||
|
&request_id[..8]
|
||||||
|
} else {
|
||||||
|
&request_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top border: ┌ tool_name requires approval ───
|
||||||
|
let top_label = format!(" {tool_name} requires approval ");
|
||||||
|
let top_fill = box_width.saturating_sub(top_label.len() + 1);
|
||||||
|
let top_border = format!(
|
||||||
|
"\u{250C}\x1b[33m{top_label}\x1b[0m{}",
|
||||||
|
"\u{2500}".repeat(top_fill)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bottom border: └─ short_id ─────
|
||||||
|
let bot_label = format!(" {short_id} ");
|
||||||
|
let bot_fill = box_width.saturating_sub(bot_label.len() + 2);
|
||||||
|
let bot_border = format!(
|
||||||
|
"\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}",
|
||||||
|
"\u{2500}".repeat(bot_fill)
|
||||||
|
);
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" {top_border}");
|
||||||
|
eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m");
|
||||||
|
eprintln!(" \u{2502}");
|
||||||
|
|
||||||
|
// Params
|
||||||
|
let param_lines = format_json_params(¶meters, " \u{2502} ");
|
||||||
|
// The format_json_params already includes the indent prefix
|
||||||
|
// but we need to handle the case where each line already starts with it
|
||||||
|
for line in param_lines.lines() {
|
||||||
|
eprintln!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" \u{2502}");
|
||||||
|
eprintln!(
|
||||||
|
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||||
|
);
|
||||||
|
eprintln!(" {bot_border}");
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
StatusUpdate::AuthRequired {
|
||||||
|
extension_name,
|
||||||
|
instructions,
|
||||||
|
setup_url,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
|
||||||
|
if let Some(ref instr) = instructions {
|
||||||
|
eprintln!(" {instr}");
|
||||||
|
}
|
||||||
|
if let Some(ref url) = setup_url {
|
||||||
|
eprintln!(" \x1b[4m{url}\x1b[0m");
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
StatusUpdate::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
} => {
|
||||||
|
if success {
|
||||||
|
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
|
||||||
|
} else {
|
||||||
|
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,9 +531,15 @@ impl Channel for ReplChannel {
|
|||||||
_user_id: &str,
|
_user_id: &str,
|
||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
println!();
|
let skin = make_skin();
|
||||||
println!("\x1b[36m[notification]\x1b[0m {}", response.content);
|
let width = crossterm::terminal::size()
|
||||||
println!();
|
.map(|(w, _)| w as usize)
|
||||||
|
.unwrap_or(80);
|
||||||
|
|
||||||
|
eprintln!("\x1b[34m\u{25CF}\x1b[0m notification");
|
||||||
|
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
|
||||||
|
eprint!("{text}");
|
||||||
|
eprintln!();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! Known WASM channels that can be installed from build artifacts.
|
||||||
|
//!
|
||||||
|
//! Instead of embedding WASM binaries in the host binary via include_bytes!,
|
||||||
|
//! channels are compiled separately and installed from their build output
|
||||||
|
//! directories during onboarding.
|
||||||
|
//!
|
||||||
|
//! Channel source layout:
|
||||||
|
//! channels-src/<name>/
|
||||||
|
//! target/wasm32-wasip2/release/<name>_channel.wasm
|
||||||
|
//! <name>.capabilities.json
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
/// Compile-time project root, used to locate channels-src/ in dev builds.
|
||||||
|
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
||||||
|
|
||||||
|
/// Known channel names and their crate names (for locating build artifacts).
|
||||||
|
const KNOWN_CHANNELS: &[(&str, &str)] = &[
|
||||||
|
("telegram", "telegram_channel"),
|
||||||
|
("slack", "slack_channel"),
|
||||||
|
("whatsapp", "whatsapp_channel"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Names of known channels that can be installed.
|
||||||
|
pub fn bundled_channel_names() -> Vec<&'static str> {
|
||||||
|
KNOWN_CHANNELS.iter().map(|(name, _)| *name).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the channels source directory.
|
||||||
|
///
|
||||||
|
/// Checks (in order):
|
||||||
|
/// 1. `IRONCLAW_CHANNELS_SRC` env var
|
||||||
|
/// 2. `<CARGO_MANIFEST_DIR>/channels-src/` (dev builds)
|
||||||
|
fn channels_src_dir() -> PathBuf {
|
||||||
|
if let Ok(dir) = std::env::var("IRONCLAW_CHANNELS_SRC") {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
PathBuf::from(CARGO_MANIFEST_DIR).join("channels-src")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the build artifacts for a channel.
|
||||||
|
///
|
||||||
|
/// Returns (wasm_path, capabilities_path) or an error if files are missing.
|
||||||
|
fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||||
|
let (_, crate_name) = KNOWN_CHANNELS
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| *n == name)
|
||||||
|
.ok_or_else(|| format!("Unknown channel '{}'", name))?;
|
||||||
|
|
||||||
|
let src_dir = channels_src_dir();
|
||||||
|
let channel_dir = src_dir.join(name);
|
||||||
|
|
||||||
|
let wasm_path = channel_dir
|
||||||
|
.join("target/wasm32-wasip2/release")
|
||||||
|
.join(format!("{}.wasm", crate_name));
|
||||||
|
|
||||||
|
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
|
||||||
|
|
||||||
|
if !wasm_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Channel '{}' WASM not found at {}. Build it first:\n \
|
||||||
|
cd {} && cargo build --target wasm32-wasip2 --release",
|
||||||
|
name,
|
||||||
|
wasm_path.display(),
|
||||||
|
channel_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !caps_path.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"Channel '{}' capabilities not found at {}",
|
||||||
|
name,
|
||||||
|
caps_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((wasm_path, caps_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a channel from build artifacts into the channels directory.
|
||||||
|
pub async fn install_bundled_channel(
|
||||||
|
name: &str,
|
||||||
|
target_dir: &Path,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (wasm_src, caps_src) = locate_channel_artifacts(name)?;
|
||||||
|
|
||||||
|
fs::create_dir_all(target_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
|
||||||
|
|
||||||
|
let wasm_dst = target_dir.join(format!("{}.wasm", name));
|
||||||
|
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
|
||||||
|
|
||||||
|
let has_existing = wasm_dst.exists() || caps_dst.exists();
|
||||||
|
if has_existing && !force {
|
||||||
|
return Err(format!(
|
||||||
|
"Channel '{}' already exists at {}",
|
||||||
|
name,
|
||||||
|
target_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::copy(&wasm_src, &wasm_dst)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?;
|
||||||
|
fs::copy(&caps_src, &caps_dst)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check which known channels have build artifacts available.
|
||||||
|
pub fn available_channel_names() -> Vec<&'static str> {
|
||||||
|
KNOWN_CHANNELS
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| locate_channel_artifacts(name).is_ok())
|
||||||
|
.map(|(name, _)| *name)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_channels_includes_all_three() {
|
||||||
|
let names = bundled_channel_names();
|
||||||
|
assert!(names.contains(&"telegram"));
|
||||||
|
assert!(names.contains(&"slack"));
|
||||||
|
assert!(names.contains(&"whatsapp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_channels_src_dir_default() {
|
||||||
|
let dir = channels_src_dir();
|
||||||
|
assert!(dir.ends_with("channels-src"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_locate_unknown_channel_errors() {
|
||||||
|
assert!(locate_channel_artifacts("nonexistent").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_install_refuses_overwrite_without_force() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let wasm_path = dir.path().join("telegram.wasm");
|
||||||
|
fs::write(&wasm_path, b"custom").await.unwrap();
|
||||||
|
|
||||||
|
let result = install_bundled_channel("telegram", dir.path(), false).await;
|
||||||
|
// Either fails because artifacts missing OR because file exists
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
// Original file should be untouched
|
||||||
|
let existing = fs::read(&wasm_path).await.unwrap();
|
||||||
|
assert_eq!(existing, b"custom");
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-14
@@ -16,16 +16,21 @@ use crate::channels::wasm::error::WasmChannelError;
|
|||||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||||
use crate::channels::wasm::wrapper::WasmChannel;
|
use crate::channels::wasm::wrapper::WasmChannel;
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
|
|
||||||
/// Loads WASM channels from the filesystem.
|
/// Loads WASM channels from the filesystem.
|
||||||
pub struct WasmChannelLoader {
|
pub struct WasmChannelLoader {
|
||||||
runtime: Arc<WasmChannelRuntime>,
|
runtime: Arc<WasmChannelRuntime>,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmChannelLoader {
|
impl WasmChannelLoader {
|
||||||
/// Create a new loader with the given runtime.
|
/// Create a new loader with the given runtime and pairing store.
|
||||||
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
|
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
||||||
Self { runtime }
|
Self {
|
||||||
|
runtime,
|
||||||
|
pairing_store,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a single WASM channel from a file pair.
|
/// Load a single WASM channel from a file pair.
|
||||||
@@ -114,7 +119,13 @@ impl WasmChannelLoader {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Create the channel
|
// Create the channel
|
||||||
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
|
let channel = WasmChannel::new(
|
||||||
|
self.runtime.clone(),
|
||||||
|
prepared,
|
||||||
|
capabilities,
|
||||||
|
config_json,
|
||||||
|
self.pairing_store.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
name = name,
|
name = name,
|
||||||
@@ -151,17 +162,18 @@ impl WasmChannelLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut results = LoadResults::default();
|
let mut results = LoadResults::default();
|
||||||
|
|
||||||
|
// Collect all .wasm entries first, then load in parallel
|
||||||
|
let mut channel_entries = Vec::new();
|
||||||
let mut entries = fs::read_dir(dir).await?;
|
let mut entries = fs::read_dir(dir).await?;
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
// Only process .wasm files
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract channel name from filename
|
|
||||||
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
||||||
Some(n) => n.to_string(),
|
Some(n) => n.to_string(),
|
||||||
None => {
|
None => {
|
||||||
@@ -173,15 +185,20 @@ impl WasmChannelLoader {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Look for sidecar capabilities file
|
|
||||||
let cap_path = path.with_extension("capabilities.json");
|
let cap_path = path.with_extension("capabilities.json");
|
||||||
let cap_path_option = if cap_path.exists() {
|
let has_cap = cap_path.exists();
|
||||||
Some(cap_path.as_path())
|
channel_entries.push((name, path, if has_cap { Some(cap_path) } else { None }));
|
||||||
} else {
|
}
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.load_from_files(&name, &path, cap_path_option).await {
|
// Load all channels in parallel (file I/O + WASM compilation)
|
||||||
|
let load_futures = channel_entries
|
||||||
|
.iter()
|
||||||
|
.map(|(name, path, cap_path)| self.load_from_files(name, path, cap_path.as_deref()));
|
||||||
|
|
||||||
|
let load_results = futures::future::join_all(load_futures).await;
|
||||||
|
|
||||||
|
for ((name, path, _), result) in channel_entries.into_iter().zip(load_results) {
|
||||||
|
match result {
|
||||||
Ok(loaded) => {
|
Ok(loaded) => {
|
||||||
results.loaded.push(loaded);
|
results.loaded.push(loaded);
|
||||||
}
|
}
|
||||||
@@ -346,6 +363,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
||||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -402,7 +420,7 @@ mod tests {
|
|||||||
async fn test_loader_invalid_name() {
|
async fn test_loader_invalid_name() {
|
||||||
let config = WasmChannelRuntimeConfig::for_testing();
|
let config = WasmChannelRuntimeConfig::for_testing();
|
||||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||||
let loader = WasmChannelLoader::new(runtime);
|
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let wasm_path = dir.path().join("test.wasm");
|
let wasm_path = dir.path().join("test.wasm");
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
mod bundled;
|
||||||
mod capabilities;
|
mod capabilities;
|
||||||
mod error;
|
mod error;
|
||||||
mod host;
|
mod host;
|
||||||
@@ -88,6 +89,7 @@ mod schema;
|
|||||||
mod wrapper;
|
mod wrapper;
|
||||||
|
|
||||||
// Core types
|
// Core types
|
||||||
|
pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel};
|
||||||
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
||||||
pub use error::WasmChannelError;
|
pub use error::WasmChannelError;
|
||||||
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
||||||
@@ -95,9 +97,7 @@ pub use loader::{
|
|||||||
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
|
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
|
||||||
discover_channels,
|
discover_channels,
|
||||||
};
|
};
|
||||||
pub use router::{
|
pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router};
|
||||||
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
|
|
||||||
};
|
|
||||||
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||||
pub use schema::{
|
pub use schema::{
|
||||||
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
|
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! registered paths. Handles secret validation at the host level.
|
//! registered paths. Handles secret validation at the host level.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -469,56 +468,6 @@ pub fn create_wasm_channel_router(
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP server for WASM channel webhooks.
|
|
||||||
pub struct WasmChannelServer {
|
|
||||||
router: Arc<WasmChannelRouter>,
|
|
||||||
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasmChannelServer {
|
|
||||||
/// Create a new server.
|
|
||||||
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
|
||||||
Self {
|
|
||||||
router,
|
|
||||||
extension_manager: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the extension manager for OAuth callback handling.
|
|
||||||
pub fn with_extension_manager(
|
|
||||||
mut self,
|
|
||||||
manager: Arc<crate::extensions::ExtensionManager>,
|
|
||||||
) -> Self {
|
|
||||||
self.extension_manager = Some(manager);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the HTTP server.
|
|
||||||
///
|
|
||||||
/// Returns a handle that can be used to shut down the server.
|
|
||||||
pub async fn start(
|
|
||||||
&self,
|
|
||||||
addr: SocketAddr,
|
|
||||||
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
|
|
||||||
let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone());
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
addr = %addr,
|
|
||||||
"WASM channel HTTP server started"
|
|
||||||
);
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
if let Err(e) = axum::serve(listener, app).await {
|
|
||||||
tracing::error!("WASM channel HTTP server error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -529,6 +478,7 @@ mod tests {
|
|||||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||||
};
|
};
|
||||||
use crate::channels::wasm::wrapper::WasmChannel;
|
use crate::channels::wasm::wrapper::WasmChannel;
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
use crate::tools::wasm::ResourceLimits;
|
use crate::tools::wasm::ResourceLimits;
|
||||||
|
|
||||||
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
||||||
@@ -550,6 +500,7 @@ mod tests {
|
|||||||
prepared,
|
prepared,
|
||||||
capabilities,
|
capabilities,
|
||||||
"{}".to_string(),
|
"{}".to_string(),
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+285
-22
@@ -48,6 +48,7 @@ use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
|
|||||||
use crate::channels::wasm::schema::ChannelConfig;
|
use crate::channels::wasm::schema::ChannelConfig;
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
use crate::safety::LeakDetector;
|
use crate::safety::LeakDetector;
|
||||||
use crate::tools::wasm::LogLevel;
|
use crate::tools::wasm::LogLevel;
|
||||||
use crate::tools::wasm::WasmResourceLimiter;
|
use crate::tools::wasm::WasmResourceLimiter;
|
||||||
@@ -73,6 +74,8 @@ struct ChannelStoreData {
|
|||||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
|
/// Pairing store for DM pairing (guest access control).
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelStoreData {
|
impl ChannelStoreData {
|
||||||
@@ -81,6 +84,7 @@ impl ChannelStoreData {
|
|||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||||
let wasi = WasiCtxBuilder::new().build();
|
let wasi = WasiCtxBuilder::new().build();
|
||||||
@@ -91,6 +95,7 @@ impl ChannelStoreData {
|
|||||||
wasi,
|
wasi,
|
||||||
table: ResourceTable::new(),
|
table: ResourceTable::new(),
|
||||||
credentials,
|
credentials,
|
||||||
|
pairing_store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,22 @@ impl ChannelStoreData {
|
|||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace injected credential values with `[REDACTED]` in text.
|
||||||
|
///
|
||||||
|
/// Prevents credentials from leaking through error messages, logs, or
|
||||||
|
/// return values to WASM. reqwest::Error includes the full URL in its
|
||||||
|
/// Display output, so any error from an injected-URL request will
|
||||||
|
/// contain the raw credential unless we scrub it.
|
||||||
|
fn redact_credentials(&self, text: &str) -> String {
|
||||||
|
let mut result = text.to_string();
|
||||||
|
for (name, value) in &self.credentials {
|
||||||
|
if !value.is_empty() {
|
||||||
|
result = result.replace(value, &format!("[REDACTED:{}]", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement WasiView to provide WASI context and resource table
|
// Implement WasiView to provide WASI context and resource table
|
||||||
@@ -187,6 +208,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
url: String,
|
url: String,
|
||||||
headers_json: String,
|
headers_json: String,
|
||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
|
timeout_ms: Option<u32>,
|
||||||
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
method = %method,
|
method = %method,
|
||||||
@@ -276,12 +298,21 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
request = request.body(body_bytes);
|
request = request.body(body_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send request with timeout
|
// Send request with caller-specified timeout (default 30s).
|
||||||
let response = request
|
// Cap at callback_timeout to prevent outliving the host wrapper.
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
|
||||||
.send()
|
let response = request.timeout(timeout).send().await.map_err(|e| {
|
||||||
.await
|
// Walk the full error chain so we get the actual root cause
|
||||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
// (DNS, TLS, connection refused, etc.) instead of just
|
||||||
|
// "error sending request for url (...)".
|
||||||
|
let mut chain = format!("HTTP request failed: {}", e);
|
||||||
|
let mut source = std::error::Error::source(&e);
|
||||||
|
while let Some(cause) = source {
|
||||||
|
chain.push_str(&format!(" -> {}", cause));
|
||||||
|
source = cause.source();
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
})?;
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let response_headers: std::collections::HashMap<String, String> = response
|
let response_headers: std::collections::HashMap<String, String> = response
|
||||||
@@ -330,6 +361,11 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scrub credential values from error messages before logging or returning
|
||||||
|
// to WASM. reqwest::Error includes the full URL (with injected credentials)
|
||||||
|
// in its Display output.
|
||||||
|
let result = result.map_err(|e| self.redact_credentials(&e));
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
tracing::info!(status = resp.status, "http_request completed successfully");
|
tracing::info!(status = resp.status, "http_request completed successfully");
|
||||||
@@ -372,6 +408,43 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pairing_upsert_request(
|
||||||
|
&mut self,
|
||||||
|
channel: String,
|
||||||
|
id: String,
|
||||||
|
meta_json: String,
|
||||||
|
) -> Result<near::agent::channel_host::PairingUpsertResult, String> {
|
||||||
|
let meta = if meta_json.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(&meta_json).ok()
|
||||||
|
};
|
||||||
|
match self.pairing_store.upsert_request(&channel, &id, meta) {
|
||||||
|
Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult {
|
||||||
|
code: r.code,
|
||||||
|
created: r.created,
|
||||||
|
}),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pairing_is_allowed(
|
||||||
|
&mut self,
|
||||||
|
channel: String,
|
||||||
|
id: String,
|
||||||
|
username: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
self.pairing_store
|
||||||
|
.is_sender_allowed(&channel, &id, username.as_deref())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pairing_read_allow_from(&mut self, channel: String) -> Result<Vec<String>, String> {
|
||||||
|
self.pairing_store
|
||||||
|
.read_allow_from(&channel)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A WASM-based channel implementing the Channel trait.
|
/// A WASM-based channel implementing the Channel trait.
|
||||||
@@ -424,6 +497,9 @@ pub struct WasmChannel {
|
|||||||
/// Background task that repeats typing indicators every 4 seconds.
|
/// Background task that repeats typing indicators every 4 seconds.
|
||||||
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
||||||
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
|
||||||
|
/// Pairing store for DM pairing (guest access control).
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmChannel {
|
impl WasmChannel {
|
||||||
@@ -433,6 +509,7 @@ impl WasmChannel {
|
|||||||
prepared: Arc<PreparedChannelModule>,
|
prepared: Arc<PreparedChannelModule>,
|
||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
config_json: String,
|
config_json: String,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let name = prepared.name.clone();
|
let name = prepared.name.clone();
|
||||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||||
@@ -452,6 +529,7 @@ impl WasmChannel {
|
|||||||
endpoints: RwLock::new(Vec::new()),
|
endpoints: RwLock::new(Vec::new()),
|
||||||
credentials: Arc::new(RwLock::new(HashMap::new())),
|
credentials: Arc::new(RwLock::new(HashMap::new())),
|
||||||
typing_task: RwLock::new(None),
|
typing_task: RwLock::new(None),
|
||||||
|
pairing_store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +611,7 @@ impl WasmChannel {
|
|||||||
prepared: &PreparedChannelModule,
|
prepared: &PreparedChannelModule,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||||
let engine = runtime.engine();
|
let engine = runtime.engine();
|
||||||
let limits = &prepared.limits;
|
let limits = &prepared.limits;
|
||||||
@@ -543,6 +622,7 @@ impl WasmChannel {
|
|||||||
&prepared.name,
|
&prepared.name,
|
||||||
capabilities.clone(),
|
capabilities.clone(),
|
||||||
credentials,
|
credentials,
|
||||||
|
pairing_store,
|
||||||
);
|
);
|
||||||
let mut store = Store::new(engine, store_data);
|
let mut store = Store::new(engine, store_data);
|
||||||
|
|
||||||
@@ -643,12 +723,18 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_start using the generated typed interface
|
// Call on_start using the generated typed interface
|
||||||
@@ -753,6 +839,7 @@ impl WasmChannel {
|
|||||||
let capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
// Prepare request data
|
// Prepare request data
|
||||||
let method = method.to_string();
|
let method = method.to_string();
|
||||||
@@ -766,8 +853,13 @@ impl WasmChannel {
|
|||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Build the WIT request type
|
// Build the WIT request type
|
||||||
@@ -840,12 +932,18 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
@@ -929,6 +1027,7 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
// Prepare response data
|
// Prepare response data
|
||||||
let message_id_str = message_id.to_string();
|
let message_id_str = message_id.to_string();
|
||||||
@@ -942,8 +1041,13 @@ impl WasmChannel {
|
|||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
tracing::info!("Creating WASM store for on_respond");
|
tracing::info!("Creating WASM store for on_respond");
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
|
|
||||||
tracing::info!("Instantiating WASM component for on_respond");
|
tracing::info!("Instantiating WASM component for on_respond");
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1036,13 +1140,19 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
let wit_update = status_to_wit(status, metadata);
|
let wit_update = status_to_wit(status, metadata);
|
||||||
|
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
@@ -1080,12 +1190,14 @@ impl WasmChannel {
|
|||||||
///
|
///
|
||||||
/// Static method for use by the background typing repeat task (which
|
/// Static method for use by the background typing repeat task (which
|
||||||
/// doesn't have access to `&self`).
|
/// doesn't have access to `&self`).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn execute_status(
|
async fn execute_status(
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
runtime: &Arc<WasmChannelRuntime>,
|
runtime: &Arc<WasmChannelRuntime>,
|
||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
wit_update: wit_channel::StatusUpdate,
|
wit_update: wit_channel::StatusUpdate,
|
||||||
) -> Result<(), WasmChannelError> {
|
) -> Result<(), WasmChannelError> {
|
||||||
@@ -1101,8 +1213,13 @@ impl WasmChannel {
|
|||||||
|
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials_snapshot,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
@@ -1170,6 +1287,7 @@ impl WasmChannel {
|
|||||||
let prepared = Arc::clone(&self.prepared);
|
let prepared = Arc::clone(&self.prepared);
|
||||||
let capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let credentials = self.credentials.clone();
|
let credentials = self.credentials.clone();
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
let wit_update = status_to_wit(&status, metadata);
|
let wit_update = status_to_wit(&status, metadata);
|
||||||
|
|
||||||
@@ -1189,6 +1307,7 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
|
pairing_store.clone(),
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
wit_update_clone,
|
wit_update_clone,
|
||||||
)
|
)
|
||||||
@@ -1319,6 +1438,7 @@ impl WasmChannel {
|
|||||||
let message_tx = self.message_tx.clone();
|
let message_tx = self.message_tx.clone();
|
||||||
let rate_limiter = self.rate_limiter.clone();
|
let rate_limiter = self.rate_limiter.clone();
|
||||||
let credentials = self.credentials.clone();
|
let credentials = self.credentials.clone();
|
||||||
|
let pairing_store = self.pairing_store.clone();
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1340,6 +1460,7 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
|
pairing_store.clone(),
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
@@ -1391,6 +1512,7 @@ impl WasmChannel {
|
|||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
|
pairing_store: Arc<PairingStore>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
||||||
// Skip if no WASM bytes (testing mode)
|
// Skip if no WASM bytes (testing mode)
|
||||||
@@ -1411,8 +1533,13 @@ impl WasmChannel {
|
|||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store =
|
let mut store = Self::create_store(
|
||||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
&runtime,
|
||||||
|
&prepared,
|
||||||
|
&capabilities,
|
||||||
|
credentials_snapshot,
|
||||||
|
pairing_store,
|
||||||
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
@@ -1826,6 +1953,11 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
|||||||
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
|
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
|
||||||
metadata_json,
|
metadata_json,
|
||||||
},
|
},
|
||||||
|
StatusUpdate::ToolResult { name, preview } => wit_channel::StatusUpdate {
|
||||||
|
status: wit_channel::StatusType::ToolCompleted,
|
||||||
|
message: format!("{}: {}", name, preview),
|
||||||
|
metadata_json,
|
||||||
|
},
|
||||||
StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate {
|
StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate {
|
||||||
status: wit_channel::StatusType::Thinking,
|
status: wit_channel::StatusType::Thinking,
|
||||||
message: chunk.clone(),
|
message: chunk.clone(),
|
||||||
@@ -1844,6 +1976,38 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
|||||||
metadata_json,
|
metadata_json,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
..
|
||||||
|
} => wit_channel::StatusUpdate {
|
||||||
|
status: wit_channel::StatusType::Thinking,
|
||||||
|
message: format!("Approval needed: {} - {}", tool_name, description),
|
||||||
|
metadata_json,
|
||||||
|
},
|
||||||
|
StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate {
|
||||||
|
status: wit_channel::StatusType::Thinking,
|
||||||
|
message: format!("Job started: {} ({})", title, job_id),
|
||||||
|
metadata_json,
|
||||||
|
},
|
||||||
|
StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate {
|
||||||
|
status: wit_channel::StatusType::Thinking,
|
||||||
|
message: format!("Auth required: {}", extension_name),
|
||||||
|
metadata_json,
|
||||||
|
},
|
||||||
|
StatusUpdate::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
..
|
||||||
|
} => wit_channel::StatusUpdate {
|
||||||
|
status: wit_channel::StatusType::Thinking,
|
||||||
|
message: format!(
|
||||||
|
"Auth {}: {}",
|
||||||
|
if *success { "completed" } else { "failed" },
|
||||||
|
extension_name
|
||||||
|
),
|
||||||
|
metadata_json,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1915,6 +2079,7 @@ mod tests {
|
|||||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||||
};
|
};
|
||||||
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
use crate::tools::wasm::ResourceLimits;
|
use crate::tools::wasm::ResourceLimits;
|
||||||
|
|
||||||
fn create_test_channel() -> WasmChannel {
|
fn create_test_channel() -> WasmChannel {
|
||||||
@@ -1930,7 +2095,13 @@ mod tests {
|
|||||||
|
|
||||||
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
||||||
|
|
||||||
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
|
WasmChannel::new(
|
||||||
|
runtime,
|
||||||
|
prepared,
|
||||||
|
capabilities,
|
||||||
|
"{}".to_string(),
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2005,6 +2176,7 @@ mod tests {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
timeout,
|
timeout,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -2098,7 +2270,13 @@ mod tests {
|
|||||||
.with_path("/webhook/poll")
|
.with_path("/webhook/poll")
|
||||||
.with_polling(1000);
|
.with_polling(1000);
|
||||||
|
|
||||||
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
|
let channel = WasmChannel::new(
|
||||||
|
runtime,
|
||||||
|
prepared,
|
||||||
|
capabilities,
|
||||||
|
"{}".to_string(),
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
|
);
|
||||||
|
|
||||||
// Start the channel
|
// Start the channel
|
||||||
let _stream = channel.start().await.expect("Channel should start");
|
let _stream = channel.start().await.expect("Channel should start");
|
||||||
@@ -2336,4 +2514,89 @@ mod tests {
|
|||||||
assert_eq!(cloned.message, "hello");
|
assert_eq!(cloned.message, "hello");
|
||||||
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_credentials_replaces_values() {
|
||||||
|
use super::ChannelStoreData;
|
||||||
|
|
||||||
|
let mut creds = std::collections::HashMap::new();
|
||||||
|
creds.insert(
|
||||||
|
"TELEGRAM_BOT_TOKEN".to_string(),
|
||||||
|
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
|
||||||
|
);
|
||||||
|
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
||||||
|
|
||||||
|
let store = ChannelStoreData::new(
|
||||||
|
1024 * 1024,
|
||||||
|
"test",
|
||||||
|
ChannelCapabilities::default(),
|
||||||
|
creds,
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = "HTTP request failed: error sending request for url \
|
||||||
|
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
|
||||||
|
|
||||||
|
let redacted = store.redact_credentials(error);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
|
||||||
|
"credential value should be redacted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"),
|
||||||
|
"redacted text should contain placeholder name"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!redacted.contains("s3cret"),
|
||||||
|
"other credentials should also be redacted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_credentials_no_op_without_credentials() {
|
||||||
|
use super::ChannelStoreData;
|
||||||
|
|
||||||
|
let store = ChannelStoreData::new(
|
||||||
|
1024 * 1024,
|
||||||
|
"test",
|
||||||
|
ChannelCapabilities::default(),
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "some error message";
|
||||||
|
assert_eq!(store.redact_credentials(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_credentials_skips_empty_values() {
|
||||||
|
use super::ChannelStoreData;
|
||||||
|
|
||||||
|
let mut creds = std::collections::HashMap::new();
|
||||||
|
creds.insert("EMPTY_TOKEN".to_string(), String::new());
|
||||||
|
|
||||||
|
let store = ChannelStoreData::new(
|
||||||
|
1024 * 1024,
|
||||||
|
"test",
|
||||||
|
ChannelCapabilities::default(),
|
||||||
|
creds,
|
||||||
|
Arc::new(PairingStore::new()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = "should not match anything";
|
||||||
|
assert_eq!(store.redact_credentials(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the block_on-inside-spawn_blocking pattern used by the WASM
|
||||||
|
/// channel HTTP host function doesn't deadlock or panic.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
|
||||||
|
let result = tokio::task::spawn_blocking(|| {
|
||||||
|
tokio::runtime::Handle::current().block_on(async { 42 })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking panicked");
|
||||||
|
assert_eq!(result, 42);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Bearer token authentication middleware for the web gateway.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shared auth state injected via axum middleware state.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth middleware that validates bearer token from header or query param.
|
||||||
|
///
|
||||||
|
/// SSE connections can't set headers from `EventSource`, so we also accept
|
||||||
|
/// `?token=xxx` as a query parameter.
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
State(auth): State<AuthState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Try Authorization header first
|
||||||
|
if let Some(auth_header) = headers.get("authorization") {
|
||||||
|
if let Ok(value) = auth_header.to_str() {
|
||||||
|
if let Some(token) = value.strip_prefix("Bearer ") {
|
||||||
|
if token == auth.token {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to query parameter (for SSE EventSource)
|
||||||
|
if let Some(query) = request.uri().query() {
|
||||||
|
for pair in query.split('&') {
|
||||||
|
if let Some(token) = pair.strip_prefix("token=") {
|
||||||
|
if token == auth.token {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_state_clone() {
|
||||||
|
let state = AuthState {
|
||||||
|
token: "test-token".to_string(),
|
||||||
|
};
|
||||||
|
let cloned = state.clone();
|
||||||
|
assert_eq!(cloned.token, "test-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! Tracing layer that broadcasts log events to the web gateway via SSE.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! tracing::info!("...")
|
||||||
|
//! │
|
||||||
|
//! ▼
|
||||||
|
//! WebLogLayer::on_event()
|
||||||
|
//! │
|
||||||
|
//! ▼
|
||||||
|
//! LogBroadcaster::send()
|
||||||
|
//! │
|
||||||
|
//! ├──► broadcast::Sender<LogEntry> (live subscribers)
|
||||||
|
//! └──► ring buffer (recent history for late joiners)
|
||||||
|
//! │
|
||||||
|
//! ▼
|
||||||
|
//! SSE /api/logs/events
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::field::{Field, Visit};
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
|
|
||||||
|
/// Maximum number of recent log entries kept for late-joining SSE subscribers.
|
||||||
|
const HISTORY_CAP: usize = 500;
|
||||||
|
|
||||||
|
/// A single log entry broadcast to connected clients.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub level: String,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcasts log entries to SSE subscribers.
|
||||||
|
///
|
||||||
|
/// Created early in main.rs (before tracing init), shared with both
|
||||||
|
/// the tracing layer and the gateway's SSE endpoint.
|
||||||
|
///
|
||||||
|
/// Keeps a ring buffer of recent entries so browsers that connect
|
||||||
|
/// after startup still see the boot log.
|
||||||
|
pub struct LogBroadcaster {
|
||||||
|
tx: broadcast::Sender<LogEntry>,
|
||||||
|
recent: Mutex<VecDeque<LogEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogBroadcaster {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, _) = broadcast::channel(512);
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, entry: LogEntry) {
|
||||||
|
// Stash in ring buffer (for late joiners)
|
||||||
|
if let Ok(mut buf) = self.recent.lock() {
|
||||||
|
if buf.len() >= HISTORY_CAP {
|
||||||
|
buf.pop_front();
|
||||||
|
}
|
||||||
|
buf.push_back(entry.clone());
|
||||||
|
}
|
||||||
|
// Broadcast to live subscribers (ok to drop if nobody listening)
|
||||||
|
let _ = self.tx.send(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the live event stream.
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||||
|
self.tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of recent entries for replaying to a new subscriber.
|
||||||
|
pub fn recent_entries(&self) -> Vec<LogEntry> {
|
||||||
|
self.recent
|
||||||
|
.lock()
|
||||||
|
.map(|buf| buf.iter().cloned().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogBroadcaster {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visitor that extracts the `message` field and all extra key-value
|
||||||
|
/// fields from a tracing event.
|
||||||
|
///
|
||||||
|
/// The terminal formatter shows something like:
|
||||||
|
/// INFO ironclaw::agent: Request completed url="http://..." status=200
|
||||||
|
///
|
||||||
|
/// We replicate that by capturing both the message and the extra fields.
|
||||||
|
struct MessageVisitor {
|
||||||
|
message: String,
|
||||||
|
fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageVisitor {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
message: String::new(),
|
||||||
|
fields: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the final message string: "message key=val key=val ..."
|
||||||
|
fn finish(self) -> String {
|
||||||
|
if self.fields.is_empty() {
|
||||||
|
self.message
|
||||||
|
} else {
|
||||||
|
format!("{} {}", self.message, self.fields.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visit for MessageVisitor {
|
||||||
|
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = format!("{:?}", value);
|
||||||
|
// Strip surrounding quotes from Debug output
|
||||||
|
if self.message.starts_with('"') && self.message.ends_with('"') {
|
||||||
|
self.message = self.message[1..self.message.len() - 1].to_string();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.fields.push(format!("{}={:?}", field.name(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_str(&mut self, field: &Field, value: &str) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = value.to_string();
|
||||||
|
} else {
|
||||||
|
self.fields.push(format!("{}={}", field.name(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracing layer that forwards events to a [`LogBroadcaster`].
|
||||||
|
///
|
||||||
|
/// Only forwards DEBUG and above. Attach to the tracing subscriber
|
||||||
|
/// alongside the existing fmt layer.
|
||||||
|
pub struct WebLogLayer {
|
||||||
|
broadcaster: Arc<LogBroadcaster>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebLogLayer {
|
||||||
|
pub fn new(broadcaster: Arc<LogBroadcaster>) -> Self {
|
||||||
|
Self { broadcaster }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: tracing::Subscriber> Layer<S> for WebLogLayer {
|
||||||
|
fn on_event(
|
||||||
|
&self,
|
||||||
|
event: &tracing::Event<'_>,
|
||||||
|
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||||
|
) {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
|
||||||
|
// Only forward DEBUG+
|
||||||
|
if *metadata.level() > tracing::Level::DEBUG {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visitor = MessageVisitor::new();
|
||||||
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
let entry = LogEntry {
|
||||||
|
level: metadata.level().to_string().to_uppercase(),
|
||||||
|
target: metadata.target().to_string(),
|
||||||
|
message: visitor.finish(),
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.broadcaster.send(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_broadcaster_creation() {
|
||||||
|
let broadcaster = LogBroadcaster::new();
|
||||||
|
// Should not panic with no receivers
|
||||||
|
broadcaster.send(LogEntry {
|
||||||
|
level: "INFO".to_string(),
|
||||||
|
target: "test".to_string(),
|
||||||
|
message: "hello".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_broadcaster_subscribe() {
|
||||||
|
let broadcaster = LogBroadcaster::new();
|
||||||
|
let mut rx = broadcaster.subscribe();
|
||||||
|
|
||||||
|
broadcaster.send(LogEntry {
|
||||||
|
level: "WARN".to_string(),
|
||||||
|
target: "ironclaw::test".to_string(),
|
||||||
|
message: "test warning".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let entry = rx.try_recv().expect("should receive entry");
|
||||||
|
assert_eq!(entry.level, "WARN");
|
||||||
|
assert_eq!(entry.message, "test warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_entry_serialization() {
|
||||||
|
let entry = LogEntry {
|
||||||
|
level: "ERROR".to_string(),
|
||||||
|
target: "ironclaw::agent".to_string(),
|
||||||
|
message: "something broke".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&entry).expect("should serialize");
|
||||||
|
assert!(json.contains("\"level\":\"ERROR\""));
|
||||||
|
assert!(json.contains("something broke"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recent_entries_buffer() {
|
||||||
|
let broadcaster = LogBroadcaster::new();
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
broadcaster.send(LogEntry {
|
||||||
|
level: "INFO".to_string(),
|
||||||
|
target: "test".to_string(),
|
||||||
|
message: format!("msg {}", i),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let recent = broadcaster.recent_entries();
|
||||||
|
assert_eq!(recent.len(), 5);
|
||||||
|
assert_eq!(recent[0].message, "msg 0");
|
||||||
|
assert_eq!(recent[4].message, "msg 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recent_entries_cap() {
|
||||||
|
let broadcaster = LogBroadcaster::new();
|
||||||
|
|
||||||
|
// Overflow the buffer
|
||||||
|
for i in 0..(HISTORY_CAP + 50) {
|
||||||
|
broadcaster.send(LogEntry {
|
||||||
|
level: "INFO".to_string(),
|
||||||
|
target: "test".to_string(),
|
||||||
|
message: format!("msg {}", i),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let recent = broadcaster.recent_entries();
|
||||||
|
assert_eq!(recent.len(), HISTORY_CAP);
|
||||||
|
// Oldest should be msg 50 (first 50 evicted)
|
||||||
|
assert_eq!(recent[0].message, "msg 50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recent_entries_available_without_subscribers() {
|
||||||
|
let broadcaster = LogBroadcaster::new();
|
||||||
|
// No subscribe() call, just send
|
||||||
|
broadcaster.send(LogEntry {
|
||||||
|
level: "INFO".to_string(),
|
||||||
|
target: "test".to_string(),
|
||||||
|
message: "before anyone listened".to_string(),
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let recent = broadcaster.recent_entries();
|
||||||
|
assert_eq!(recent.len(), 1);
|
||||||
|
assert_eq!(recent[0].message, "before anyone listened");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_visitor_finish_message_only() {
|
||||||
|
let v = MessageVisitor {
|
||||||
|
message: "hello world".to_string(),
|
||||||
|
fields: vec![],
|
||||||
|
};
|
||||||
|
assert_eq!(v.finish(), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_visitor_finish_with_fields() {
|
||||||
|
let v = MessageVisitor {
|
||||||
|
message: "Request completed".to_string(),
|
||||||
|
fields: vec![
|
||||||
|
"url=http://localhost:8080".to_string(),
|
||||||
|
"status=200".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let result = v.finish();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
"Request completed url=http://localhost:8080 status=200"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_visitor_finish_empty() {
|
||||||
|
let v = MessageVisitor::new();
|
||||||
|
assert_eq!(v.finish(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
//! Web gateway channel for browser-based access to IronClaw.
|
||||||
|
//!
|
||||||
|
//! Provides a single-page web UI with:
|
||||||
|
//! - Chat with the agent (via REST + SSE)
|
||||||
|
//! - Workspace/memory browsing
|
||||||
|
//! - Job management
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Browser ─── POST /api/chat/send ──► Agent Loop
|
||||||
|
//! ◄── GET /api/chat/events ── SSE stream
|
||||||
|
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
|
||||||
|
//! ─── GET /api/memory/* ────► Workspace
|
||||||
|
//! ─── GET /api/jobs/* ──────► Database
|
||||||
|
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod log_layer;
|
||||||
|
pub mod server;
|
||||||
|
pub mod sse;
|
||||||
|
pub mod types;
|
||||||
|
pub mod ws;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use crate::agent::SessionManager;
|
||||||
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
|
use crate::config::GatewayConfig;
|
||||||
|
use crate::error::ChannelError;
|
||||||
|
use crate::extensions::ExtensionManager;
|
||||||
|
use crate::history::Store;
|
||||||
|
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||||
|
use crate::tools::ToolRegistry;
|
||||||
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
|
use self::log_layer::LogBroadcaster;
|
||||||
|
|
||||||
|
use self::server::GatewayState;
|
||||||
|
use self::sse::SseManager;
|
||||||
|
use self::types::SseEvent;
|
||||||
|
|
||||||
|
/// Web gateway channel implementing the Channel trait.
|
||||||
|
pub struct GatewayChannel {
|
||||||
|
config: GatewayConfig,
|
||||||
|
state: Arc<GatewayState>,
|
||||||
|
/// The actual auth token in use (generated or from config).
|
||||||
|
auth_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayChannel {
|
||||||
|
/// Create a new gateway channel.
|
||||||
|
///
|
||||||
|
/// If no auth token is configured, generates a random one and prints it.
|
||||||
|
pub fn new(config: GatewayConfig) -> Self {
|
||||||
|
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
||||||
|
use rand::Rng;
|
||||||
|
let token: String = rand::thread_rng()
|
||||||
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
token
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = Arc::new(GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: SseManager::new(),
|
||||||
|
workspace: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
extension_manager: None,
|
||||||
|
tool_registry: None,
|
||||||
|
store: None,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue: None,
|
||||||
|
user_id: config.user_id.clone(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
auth_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
||||||
|
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
||||||
|
let mut new_state = GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: SseManager::new(),
|
||||||
|
workspace: self.state.workspace.clone(),
|
||||||
|
session_manager: self.state.session_manager.clone(),
|
||||||
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||||
|
extension_manager: self.state.extension_manager.clone(),
|
||||||
|
tool_registry: self.state.tool_registry.clone(),
|
||||||
|
store: self.state.store.clone(),
|
||||||
|
job_manager: self.state.job_manager.clone(),
|
||||||
|
prompt_queue: self.state.prompt_queue.clone(),
|
||||||
|
user_id: self.state.user_id.clone(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: self.state.ws_tracker.clone(),
|
||||||
|
};
|
||||||
|
mutate(&mut new_state);
|
||||||
|
self.state = Arc::new(new_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the workspace reference for the memory API.
|
||||||
|
pub fn with_workspace(mut self, workspace: Arc<Workspace>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.workspace = Some(workspace));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the session manager for thread/session info.
|
||||||
|
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.session_manager = Some(sm));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the log broadcaster for the logs SSE endpoint.
|
||||||
|
pub fn with_log_broadcaster(mut self, lb: Arc<LogBroadcaster>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.log_broadcaster = Some(lb));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the extension manager for the extensions API.
|
||||||
|
pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.extension_manager = Some(em));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the tool registry for the extensions API.
|
||||||
|
pub fn with_tool_registry(mut self, tr: Arc<ToolRegistry>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.tool_registry = Some(tr));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the database store for sandbox job persistence.
|
||||||
|
pub fn with_store(mut self, store: Arc<Store>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.store = Some(store));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the container job manager for sandbox operations.
|
||||||
|
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.job_manager = Some(jm));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject the prompt queue for Claude Code follow-up prompts.
|
||||||
|
pub fn with_prompt_queue(
|
||||||
|
mut self,
|
||||||
|
pq: Arc<
|
||||||
|
tokio::sync::Mutex<
|
||||||
|
std::collections::HashMap<
|
||||||
|
uuid::Uuid,
|
||||||
|
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
>,
|
||||||
|
) -> Self {
|
||||||
|
self.rebuild_state(|s| s.prompt_queue = Some(pq));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the auth token (for printing to console on startup).
|
||||||
|
pub fn auth_token(&self) -> &str {
|
||||||
|
&self.auth_token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
||||||
|
pub fn state(&self) -> &Arc<GatewayState> {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Channel for GatewayChannel {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"gateway"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
|
let (tx, rx) = mpsc::channel(256);
|
||||||
|
*self.state.msg_tx.write().await = Some(tx);
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| ChannelError::StartupFailed {
|
||||||
|
name: "gateway".to_string(),
|
||||||
|
reason: format!(
|
||||||
|
"Invalid address '{}:{}': {}",
|
||||||
|
self.config.host, self.config.port, e
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
|
||||||
|
|
||||||
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(
|
||||||
|
&self,
|
||||||
|
msg: &IncomingMessage,
|
||||||
|
response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
let thread_id = msg.thread_id.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
self.state.sse.broadcast(SseEvent::Response {
|
||||||
|
content: response.content,
|
||||||
|
thread_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_status(
|
||||||
|
&self,
|
||||||
|
status: StatusUpdate,
|
||||||
|
metadata: &serde_json::Value,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
let thread_id = metadata
|
||||||
|
.get("thread_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
let event = match status {
|
||||||
|
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
|
||||||
|
message: msg,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
|
||||||
|
name,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted {
|
||||||
|
name,
|
||||||
|
success,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
||||||
|
name,
|
||||||
|
preview,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk {
|
||||||
|
content,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::Status(msg) => SseEvent::Status {
|
||||||
|
message: msg,
|
||||||
|
thread_id: thread_id.clone(),
|
||||||
|
},
|
||||||
|
StatusUpdate::JobStarted {
|
||||||
|
job_id,
|
||||||
|
title,
|
||||||
|
browse_url,
|
||||||
|
} => SseEvent::JobStarted {
|
||||||
|
job_id,
|
||||||
|
title,
|
||||||
|
browse_url,
|
||||||
|
},
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id,
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
} => SseEvent::ApprovalNeeded {
|
||||||
|
request_id,
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
parameters: serde_json::to_string_pretty(¶meters)
|
||||||
|
.unwrap_or_else(|_| parameters.to_string()),
|
||||||
|
},
|
||||||
|
StatusUpdate::AuthRequired {
|
||||||
|
extension_name,
|
||||||
|
instructions,
|
||||||
|
auth_url,
|
||||||
|
setup_url,
|
||||||
|
} => SseEvent::AuthRequired {
|
||||||
|
extension_name,
|
||||||
|
instructions,
|
||||||
|
auth_url,
|
||||||
|
setup_url,
|
||||||
|
},
|
||||||
|
StatusUpdate::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
} => SseEvent::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
self.state.sse.broadcast(event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn broadcast(
|
||||||
|
&self,
|
||||||
|
_user_id: &str,
|
||||||
|
response: OutgoingResponse,
|
||||||
|
) -> Result<(), ChannelError> {
|
||||||
|
self.state.sse.broadcast(SseEvent::Response {
|
||||||
|
content: response.content,
|
||||||
|
thread_id: String::new(),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||||
|
if self.state.msg_tx.read().await.is_some() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ChannelError::HealthCheckFailed {
|
||||||
|
name: "gateway".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||||
|
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
*self.state.msg_tx.write().await = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
|||||||
|
//! SSE connection manager for broadcasting events to browser tabs.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
|
use futures::Stream;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
|
||||||
|
use crate::channels::web::types::SseEvent;
|
||||||
|
|
||||||
|
/// Manages SSE broadcast to all connected browser tabs.
|
||||||
|
pub struct SseManager {
|
||||||
|
tx: broadcast::Sender<SseEvent>,
|
||||||
|
connection_count: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseManager {
|
||||||
|
/// Create a new SSE manager.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Buffer 256 events; slow clients will miss events (acceptable for SSE with reconnect)
|
||||||
|
let (tx, _) = broadcast::channel(256);
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
connection_count: Arc::new(AtomicU64::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast an event to all connected clients.
|
||||||
|
pub fn broadcast(&self, event: SseEvent) {
|
||||||
|
// Ignore send errors (no receivers is fine)
|
||||||
|
let _ = self.tx.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current number of active connections.
|
||||||
|
pub fn connection_count(&self) -> u64 {
|
||||||
|
self.connection_count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a raw broadcast subscription for non-SSE consumers (e.g. WebSocket).
|
||||||
|
///
|
||||||
|
/// Returns a stream of `SseEvent` values and increments/decrements the
|
||||||
|
/// connection counter on creation/drop, just like `subscribe()` does for SSE.
|
||||||
|
pub fn subscribe_raw(&self) -> impl Stream<Item = SseEvent> + Send + 'static + use<> {
|
||||||
|
let counter = Arc::clone(&self.connection_count);
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
|
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
|
||||||
|
|
||||||
|
CountedStream {
|
||||||
|
inner: stream,
|
||||||
|
counter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new SSE stream for a client connection.
|
||||||
|
pub fn subscribe(
|
||||||
|
&self,
|
||||||
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>> {
|
||||||
|
let counter = Arc::clone(&self.connection_count);
|
||||||
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
|
let stream = BroadcastStream::new(rx)
|
||||||
|
.filter_map(|result| result.ok())
|
||||||
|
.map(|event| {
|
||||||
|
let data = serde_json::to_string(&event).unwrap_or_default();
|
||||||
|
let event_type = match &event {
|
||||||
|
SseEvent::Response { .. } => "response",
|
||||||
|
SseEvent::Thinking { .. } => "thinking",
|
||||||
|
SseEvent::ToolStarted { .. } => "tool_started",
|
||||||
|
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||||
|
SseEvent::ToolResult { .. } => "tool_result",
|
||||||
|
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||||
|
SseEvent::Status { .. } => "status",
|
||||||
|
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||||
|
SseEvent::AuthRequired { .. } => "auth_required",
|
||||||
|
SseEvent::AuthCompleted { .. } => "auth_completed",
|
||||||
|
SseEvent::Error { .. } => "error",
|
||||||
|
SseEvent::JobStarted { .. } => "job_started",
|
||||||
|
SseEvent::JobMessage { .. } => "job_message",
|
||||||
|
SseEvent::JobToolUse { .. } => "job_tool_use",
|
||||||
|
SseEvent::JobToolResult { .. } => "job_tool_result",
|
||||||
|
SseEvent::JobStatus { .. } => "job_status",
|
||||||
|
SseEvent::JobResult { .. } => "job_result",
|
||||||
|
SseEvent::Heartbeat => "heartbeat",
|
||||||
|
};
|
||||||
|
Ok(Event::default().event(event_type).data(data))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap in a stream that decrements on drop
|
||||||
|
let counted_stream = CountedStream {
|
||||||
|
inner: stream,
|
||||||
|
counter,
|
||||||
|
};
|
||||||
|
|
||||||
|
Sse::new(counted_stream)
|
||||||
|
.keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SseManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream wrapper that decrements connection count on drop.
|
||||||
|
///
|
||||||
|
/// When the SSE client disconnects, this stream is dropped
|
||||||
|
/// and the counter is decremented.
|
||||||
|
struct CountedStream<S> {
|
||||||
|
inner: S,
|
||||||
|
counter: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Stream + Unpin> Stream for CountedStream<S> {
|
||||||
|
type Item = S::Item;
|
||||||
|
|
||||||
|
fn poll_next(
|
||||||
|
mut self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<Option<Self::Item>> {
|
||||||
|
std::pin::Pin::new(&mut self.inner).poll_next(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Drop for CountedStream<S> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.counter.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_manager_creation() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
assert_eq!(manager.connection_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_broadcast_without_receivers() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
// Should not panic even with no receivers
|
||||||
|
manager.broadcast(SseEvent::Heartbeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_broadcast_to_receiver() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
let mut rx = BroadcastStream::new(manager.tx.subscribe());
|
||||||
|
|
||||||
|
manager.broadcast(SseEvent::Status {
|
||||||
|
message: "test".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = rx.next().await;
|
||||||
|
assert!(event.is_some());
|
||||||
|
let event = event.unwrap().unwrap();
|
||||||
|
match event {
|
||||||
|
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
|
||||||
|
_ => panic!("unexpected event type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_raw_receives_events() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
let mut stream = Box::pin(manager.subscribe_raw());
|
||||||
|
|
||||||
|
assert_eq!(manager.connection_count(), 1);
|
||||||
|
|
||||||
|
manager.broadcast(SseEvent::Thinking {
|
||||||
|
message: "working".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = stream.next().await.unwrap();
|
||||||
|
match event {
|
||||||
|
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"),
|
||||||
|
_ => panic!("Expected Thinking event"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_raw_decrements_on_drop() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
{
|
||||||
|
let _stream = Box::pin(manager.subscribe_raw());
|
||||||
|
assert_eq!(manager.connection_count(), 1);
|
||||||
|
}
|
||||||
|
// Stream dropped, counter should decrement
|
||||||
|
assert_eq!(manager.connection_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_subscribe_raw_multiple_subscribers() {
|
||||||
|
let manager = SseManager::new();
|
||||||
|
let mut s1 = Box::pin(manager.subscribe_raw());
|
||||||
|
let mut s2 = Box::pin(manager.subscribe_raw());
|
||||||
|
assert_eq!(manager.connection_count(), 2);
|
||||||
|
|
||||||
|
manager.broadcast(SseEvent::Heartbeat);
|
||||||
|
|
||||||
|
let e1 = s1.next().await.unwrap();
|
||||||
|
let e2 = s2.next().await.unwrap();
|
||||||
|
assert!(matches!(e1, SseEvent::Heartbeat));
|
||||||
|
assert!(matches!(e2, SseEvent::Heartbeat));
|
||||||
|
|
||||||
|
drop(s1);
|
||||||
|
assert_eq!(manager.connection_count(), 1);
|
||||||
|
drop(s2);
|
||||||
|
assert_eq!(manager.connection_count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>IronClaw</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Auth Screen -->
|
||||||
|
<div id="auth-screen">
|
||||||
|
<div class="auth-card-login">
|
||||||
|
<div class="auth-brand">
|
||||||
|
<h1>IronClaw</h1>
|
||||||
|
<p class="auth-tagline">Secure AI Assistant</p>
|
||||||
|
</div>
|
||||||
|
<div class="auth-form">
|
||||||
|
<label for="token-input">Gateway Token</label>
|
||||||
|
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
|
||||||
|
<button onclick="authenticate()">Connect</button>
|
||||||
|
</div>
|
||||||
|
<div id="auth-error"></div>
|
||||||
|
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App (hidden until authenticated) -->
|
||||||
|
<div id="app">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="active" data-tab="chat">Chat</button>
|
||||||
|
<button data-tab="memory">Memory</button>
|
||||||
|
<button data-tab="jobs">Jobs</button>
|
||||||
|
<button data-tab="logs">Logs</button>
|
||||||
|
<button data-tab="routines">Routines</button>
|
||||||
|
<button data-tab="extensions">Extensions</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="status" id="gateway-status-trigger">
|
||||||
|
<div class="dot" id="sse-dot"></div>
|
||||||
|
<span id="sse-status">Connected</span>
|
||||||
|
<div class="gateway-popover" id="gateway-popover"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Tab -->
|
||||||
|
<div class="tab-panel active" id="tab-chat">
|
||||||
|
<div class="thread-sidebar" id="thread-sidebar">
|
||||||
|
<div class="thread-sidebar-header">
|
||||||
|
<span>Threads</span>
|
||||||
|
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
|
||||||
|
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">«</button>
|
||||||
|
</div>
|
||||||
|
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
|
||||||
|
<span class="assistant-label">Assistant</span>
|
||||||
|
<span class="assistant-meta" id="assistant-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="threads-section-header">
|
||||||
|
<span>Conversations</span>
|
||||||
|
</div>
|
||||||
|
<div class="thread-list" id="thread-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
|
<div class="chat-status" id="chat-status"></div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||||
|
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Tab -->
|
||||||
|
<div class="tab-panel" id="tab-memory">
|
||||||
|
<div class="memory-container">
|
||||||
|
<div class="memory-sidebar">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="memory-search" placeholder="Search memory...">
|
||||||
|
</div>
|
||||||
|
<div class="memory-tree" id="memory-tree"></div>
|
||||||
|
</div>
|
||||||
|
<div class="memory-content">
|
||||||
|
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
||||||
|
<span id="memory-breadcrumb-path">workspace /</span>
|
||||||
|
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="memory-viewer" id="memory-viewer">
|
||||||
|
<div class="empty">Select a file to view its contents</div>
|
||||||
|
</div>
|
||||||
|
<div class="memory-editor" id="memory-editor" style="display:none">
|
||||||
|
<textarea id="memory-edit-textarea"></textarea>
|
||||||
|
<div class="memory-editor-actions">
|
||||||
|
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
|
||||||
|
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs Tab -->
|
||||||
|
<div class="tab-panel" id="tab-jobs">
|
||||||
|
<div class="jobs-container">
|
||||||
|
<div class="jobs-summary" id="jobs-summary"></div>
|
||||||
|
<table class="jobs-table" id="jobs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="jobs-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Tab -->
|
||||||
|
<div class="tab-panel" id="tab-logs">
|
||||||
|
<div class="logs-container">
|
||||||
|
<div class="logs-toolbar">
|
||||||
|
<select id="logs-level-filter">
|
||||||
|
<option value="all">All Levels</option>
|
||||||
|
<option value="ERROR">Error</option>
|
||||||
|
<option value="WARN">Warn</option>
|
||||||
|
<option value="INFO">Info</option>
|
||||||
|
<option value="DEBUG">Debug</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
|
||||||
|
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
|
||||||
|
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
|
||||||
|
<button onclick="clearLogs()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="logs-output" id="logs-output"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Routines Tab -->
|
||||||
|
<div class="tab-panel" id="tab-routines">
|
||||||
|
<div class="routines-container">
|
||||||
|
<div class="routines-summary" id="routines-summary"></div>
|
||||||
|
<table class="routines-table" id="routines-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Last Run</th>
|
||||||
|
<th>Next Run</th>
|
||||||
|
<th>Runs</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="routines-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty-state" id="routines-empty" style="display:none">
|
||||||
|
No routines configured. Ask the assistant to create one.
|
||||||
|
</div>
|
||||||
|
<div class="routine-detail" id="routine-detail" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Extensions Tab -->
|
||||||
|
<div class="tab-panel" id="tab-extensions">
|
||||||
|
<div class="extensions-container">
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3>Install Extension</h3>
|
||||||
|
<div class="ext-install-form" id="ext-install-form">
|
||||||
|
<input type="text" id="ext-install-name" placeholder="Extension name (required)">
|
||||||
|
<input type="text" id="ext-install-url" placeholder="URL (optional)">
|
||||||
|
<select id="ext-install-kind">
|
||||||
|
<option value="mcp_server">MCP Server</option>
|
||||||
|
<option value="wasm_tool">WASM Tool</option>
|
||||||
|
<option value="wasm_channel">WASM Channel</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="installExtension()">Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3>Installed Extensions</h3>
|
||||||
|
<div class="extensions-list" id="extensions-list">
|
||||||
|
<div class="empty-state">Loading extensions...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3>Registered Tools</h3>
|
||||||
|
<table class="tools-table" id="tools-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tools-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toasts"></div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,884 @@
|
|||||||
|
//! Request and response DTOs for the web gateway API.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// --- Chat ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SendMessageRequest {
|
||||||
|
pub content: String,
|
||||||
|
pub thread_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SendMessageResponse {
|
||||||
|
pub message_id: Uuid,
|
||||||
|
pub status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ThreadInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub state: String,
|
||||||
|
pub turn_count: usize,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub thread_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ThreadListResponse {
|
||||||
|
/// The pinned assistant thread (always present after first load).
|
||||||
|
pub assistant_thread: Option<ThreadInfo>,
|
||||||
|
/// Regular conversation threads.
|
||||||
|
pub threads: Vec<ThreadInfo>,
|
||||||
|
pub active_thread: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TurnInfo {
|
||||||
|
pub turn_number: usize,
|
||||||
|
pub user_input: String,
|
||||||
|
pub response: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub tool_calls: Vec<ToolCallInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ToolCallInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub has_result: bool,
|
||||||
|
pub has_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HistoryResponse {
|
||||||
|
pub thread_id: Uuid,
|
||||||
|
pub turns: Vec<TurnInfo>,
|
||||||
|
/// Whether there are older messages available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_more: bool,
|
||||||
|
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub oldest_timestamp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Approval ---
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApprovalRequest {
|
||||||
|
pub request_id: String,
|
||||||
|
/// "approve", "always", or "deny"
|
||||||
|
pub action: String,
|
||||||
|
/// Thread that owns the pending approval (so the agent loop finds the right session).
|
||||||
|
pub thread_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SSE Event Types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum SseEvent {
|
||||||
|
#[serde(rename = "response")]
|
||||||
|
Response { content: String, thread_id: String },
|
||||||
|
#[serde(rename = "thinking")]
|
||||||
|
Thinking {
|
||||||
|
message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_started")]
|
||||||
|
ToolStarted {
|
||||||
|
name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_completed")]
|
||||||
|
ToolCompleted {
|
||||||
|
name: String,
|
||||||
|
success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_result")]
|
||||||
|
ToolResult {
|
||||||
|
name: String,
|
||||||
|
preview: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "stream_chunk")]
|
||||||
|
StreamChunk {
|
||||||
|
content: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "status")]
|
||||||
|
Status {
|
||||||
|
message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "job_started")]
|
||||||
|
JobStarted {
|
||||||
|
job_id: String,
|
||||||
|
title: String,
|
||||||
|
browse_url: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "approval_needed")]
|
||||||
|
ApprovalNeeded {
|
||||||
|
request_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
description: String,
|
||||||
|
parameters: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "auth_required")]
|
||||||
|
AuthRequired {
|
||||||
|
extension_name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
instructions: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
auth_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
setup_url: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "auth_completed")]
|
||||||
|
AuthCompleted {
|
||||||
|
extension_name: String,
|
||||||
|
success: bool,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "heartbeat")]
|
||||||
|
Heartbeat,
|
||||||
|
|
||||||
|
// Sandbox job streaming events (worker + Claude Code bridge)
|
||||||
|
#[serde(rename = "job_message")]
|
||||||
|
JobMessage {
|
||||||
|
job_id: String,
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "job_tool_use")]
|
||||||
|
JobToolUse {
|
||||||
|
job_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
input: serde_json::Value,
|
||||||
|
},
|
||||||
|
#[serde(rename = "job_tool_result")]
|
||||||
|
JobToolResult {
|
||||||
|
job_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "job_status")]
|
||||||
|
JobStatus { job_id: String, message: String },
|
||||||
|
#[serde(rename = "job_result")]
|
||||||
|
JobResult {
|
||||||
|
job_id: String,
|
||||||
|
status: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemoryTreeResponse {
|
||||||
|
pub entries: Vec<TreeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TreeEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemoryListResponse {
|
||||||
|
pub path: String,
|
||||||
|
pub entries: Vec<ListEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemoryReadResponse {
|
||||||
|
pub path: String,
|
||||||
|
pub content: String,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MemoryWriteRequest {
|
||||||
|
pub path: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemoryWriteResponse {
|
||||||
|
pub path: String,
|
||||||
|
pub status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MemorySearchRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemorySearchResponse {
|
||||||
|
pub results: Vec<SearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SearchHit {
|
||||||
|
pub path: String,
|
||||||
|
pub content: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Jobs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct JobInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub state: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct JobListResponse {
|
||||||
|
pub jobs: Vec<JobInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct JobSummaryResponse {
|
||||||
|
pub total: usize,
|
||||||
|
pub pending: usize,
|
||||||
|
pub in_progress: usize,
|
||||||
|
pub completed: usize,
|
||||||
|
pub failed: usize,
|
||||||
|
pub stuck: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct JobDetailResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub state: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub elapsed_secs: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub project_dir: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub browse_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub job_mode: Option<String>,
|
||||||
|
pub transitions: Vec<TransitionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project Files ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectFileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectFilesResponse {
|
||||||
|
pub entries: Vec<ProjectFileEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectFileReadResponse {
|
||||||
|
pub path: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TransitionInfo {
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extensions ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExtensionInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub authenticated: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExtensionListResponse {
|
||||||
|
pub extensions: Vec<ExtensionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ToolInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ToolListResponse {
|
||||||
|
pub tools: Vec<ToolInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InstallExtensionRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ActionResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
/// Auth URL to open (when activation requires OAuth).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_url: Option<String>,
|
||||||
|
/// Whether the extension is waiting for a manual token.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub awaiting_token: Option<bool>,
|
||||||
|
/// Instructions for manual token entry.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub instructions: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionResponse {
|
||||||
|
pub fn ok(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
message: message.into(),
|
||||||
|
auth_url: None,
|
||||||
|
awaiting_token: None,
|
||||||
|
instructions: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
message: message.into(),
|
||||||
|
auth_url: None,
|
||||||
|
awaiting_token: None,
|
||||||
|
instructions: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth Token ---
|
||||||
|
|
||||||
|
/// Request to submit an auth token for an extension (dedicated endpoint).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthTokenRequest {
|
||||||
|
pub extension_name: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to cancel an in-progress auth flow.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthCancelRequest {
|
||||||
|
pub extension_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
|
||||||
|
/// Message sent by a WebSocket client to the server.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum WsClientMessage {
|
||||||
|
/// Send a chat message to the agent.
|
||||||
|
#[serde(rename = "message")]
|
||||||
|
Message {
|
||||||
|
content: String,
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
/// Approve or deny a pending tool execution.
|
||||||
|
#[serde(rename = "approval")]
|
||||||
|
Approval {
|
||||||
|
request_id: String,
|
||||||
|
/// "approve", "always", or "deny"
|
||||||
|
action: String,
|
||||||
|
/// Thread that owns the pending approval.
|
||||||
|
thread_id: Option<String>,
|
||||||
|
},
|
||||||
|
/// Submit an auth token for an extension (bypasses message pipeline).
|
||||||
|
#[serde(rename = "auth_token")]
|
||||||
|
AuthToken {
|
||||||
|
extension_name: String,
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
/// Cancel an in-progress auth flow.
|
||||||
|
#[serde(rename = "auth_cancel")]
|
||||||
|
AuthCancel { extension_name: String },
|
||||||
|
/// Client heartbeat ping.
|
||||||
|
#[serde(rename = "ping")]
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message sent by the server to a WebSocket client.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum WsServerMessage {
|
||||||
|
/// An SSE-style event forwarded over WebSocket.
|
||||||
|
#[serde(rename = "event")]
|
||||||
|
Event {
|
||||||
|
/// The event sub-type (response, thinking, tool_started, etc.)
|
||||||
|
event_type: String,
|
||||||
|
/// The event payload as a JSON value.
|
||||||
|
data: serde_json::Value,
|
||||||
|
},
|
||||||
|
/// Server heartbeat pong.
|
||||||
|
#[serde(rename = "pong")]
|
||||||
|
Pong,
|
||||||
|
/// Error message.
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsServerMessage {
|
||||||
|
/// Create a WsServerMessage from an SseEvent.
|
||||||
|
pub fn from_sse_event(event: &SseEvent) -> Self {
|
||||||
|
let event_type = match event {
|
||||||
|
SseEvent::Response { .. } => "response",
|
||||||
|
SseEvent::Thinking { .. } => "thinking",
|
||||||
|
SseEvent::ToolStarted { .. } => "tool_started",
|
||||||
|
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||||
|
SseEvent::ToolResult { .. } => "tool_result",
|
||||||
|
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||||
|
SseEvent::Status { .. } => "status",
|
||||||
|
SseEvent::JobStarted { .. } => "job_started",
|
||||||
|
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||||
|
SseEvent::AuthRequired { .. } => "auth_required",
|
||||||
|
SseEvent::AuthCompleted { .. } => "auth_completed",
|
||||||
|
SseEvent::Error { .. } => "error",
|
||||||
|
SseEvent::Heartbeat => "heartbeat",
|
||||||
|
SseEvent::JobMessage { .. } => "job_message",
|
||||||
|
SseEvent::JobToolUse { .. } => "job_tool_use",
|
||||||
|
SseEvent::JobToolResult { .. } => "job_tool_result",
|
||||||
|
SseEvent::JobStatus { .. } => "job_status",
|
||||||
|
SseEvent::JobResult { .. } => "job_result",
|
||||||
|
};
|
||||||
|
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||||
|
WsServerMessage::Event {
|
||||||
|
event_type: event_type.to_string(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routines ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoutineInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub trigger_type: String,
|
||||||
|
pub trigger_summary: String,
|
||||||
|
pub action_type: String,
|
||||||
|
pub last_run_at: Option<String>,
|
||||||
|
pub next_fire_at: Option<String>,
|
||||||
|
pub run_count: u64,
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoutineListResponse {
|
||||||
|
pub routines: Vec<RoutineInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoutineSummaryResponse {
|
||||||
|
pub total: u64,
|
||||||
|
pub enabled: u64,
|
||||||
|
pub disabled: u64,
|
||||||
|
pub failing: u64,
|
||||||
|
pub runs_today: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoutineDetailResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub trigger: serde_json::Value,
|
||||||
|
pub action: serde_json::Value,
|
||||||
|
pub guardrails: serde_json::Value,
|
||||||
|
pub notify: serde_json::Value,
|
||||||
|
pub last_run_at: Option<String>,
|
||||||
|
pub next_fire_at: Option<String>,
|
||||||
|
pub run_count: u64,
|
||||||
|
pub consecutive_failures: u32,
|
||||||
|
pub created_at: String,
|
||||||
|
pub recent_runs: Vec<RoutineRunInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoutineRunInfo {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub trigger_type: String,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub result_summary: Option<String>,
|
||||||
|
pub tokens_used: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SettingResponse {
|
||||||
|
pub key: String,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SettingsListResponse {
|
||||||
|
pub settings: Vec<SettingResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SettingWriteRequest {
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SettingsImportRequest {
|
||||||
|
pub settings: std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SettingsExportResponse {
|
||||||
|
pub settings: std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Health ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub channel: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---- WsClientMessage deserialization tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_message_parse() {
|
||||||
|
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::Message { content, thread_id } => {
|
||||||
|
assert_eq!(content, "hello");
|
||||||
|
assert_eq!(thread_id.as_deref(), Some("t1"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Message variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_message_no_thread() {
|
||||||
|
let json = r#"{"type":"message","content":"hi"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::Message { content, thread_id } => {
|
||||||
|
assert_eq!(content, "hi");
|
||||||
|
assert!(thread_id.is_none());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Message variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_approval_parse() {
|
||||||
|
let json =
|
||||||
|
r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id,
|
||||||
|
action,
|
||||||
|
thread_id,
|
||||||
|
} => {
|
||||||
|
assert_eq!(request_id, "abc-123");
|
||||||
|
assert_eq!(action, "approve");
|
||||||
|
assert_eq!(thread_id.as_deref(), Some("t1"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Approval variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_approval_parse_no_thread() {
|
||||||
|
let json = r#"{"type":"approval","request_id":"abc-123","action":"deny"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id,
|
||||||
|
action,
|
||||||
|
thread_id,
|
||||||
|
} => {
|
||||||
|
assert_eq!(request_id, "abc-123");
|
||||||
|
assert_eq!(action, "deny");
|
||||||
|
assert!(thread_id.is_none());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Approval variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_ping_parse() {
|
||||||
|
let json = r#"{"type":"ping"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, WsClientMessage::Ping));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_unknown_type_fails() {
|
||||||
|
let json = r#"{"type":"unknown"}"#;
|
||||||
|
let result: Result<WsClientMessage, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WsServerMessage serialization tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_pong_serialize() {
|
||||||
|
let msg = WsServerMessage::Pong;
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"{"type":"pong"}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_error_serialize() {
|
||||||
|
let msg = WsServerMessage::Error {
|
||||||
|
message: "bad request".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["type"], "error");
|
||||||
|
assert_eq!(parsed["message"], "bad request");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_response() {
|
||||||
|
let sse = SseEvent::Response {
|
||||||
|
content: "hello".to_string(),
|
||||||
|
thread_id: "t1".to_string(),
|
||||||
|
};
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, data } => {
|
||||||
|
assert_eq!(event_type, "response");
|
||||||
|
assert_eq!(data["content"], "hello");
|
||||||
|
assert_eq!(data["thread_id"], "t1");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_thinking() {
|
||||||
|
let sse = SseEvent::Thinking {
|
||||||
|
message: "reasoning...".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
};
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, data } => {
|
||||||
|
assert_eq!(event_type, "thinking");
|
||||||
|
assert_eq!(data["message"], "reasoning...");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_approval_needed() {
|
||||||
|
let sse = SseEvent::ApprovalNeeded {
|
||||||
|
request_id: "r1".to_string(),
|
||||||
|
tool_name: "shell".to_string(),
|
||||||
|
description: "Run ls".to_string(),
|
||||||
|
parameters: "{}".to_string(),
|
||||||
|
};
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, data } => {
|
||||||
|
assert_eq!(event_type, "approval_needed");
|
||||||
|
assert_eq!(data["tool_name"], "shell");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_heartbeat() {
|
||||||
|
let sse = SseEvent::Heartbeat;
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, .. } => {
|
||||||
|
assert_eq!(event_type, "heartbeat");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth type tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_auth_token_parse() {
|
||||||
|
let json = r#"{"type":"auth_token","extension_name":"notion","token":"sk-123"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::AuthToken {
|
||||||
|
extension_name,
|
||||||
|
token,
|
||||||
|
} => {
|
||||||
|
assert_eq!(extension_name, "notion");
|
||||||
|
assert_eq!(token, "sk-123");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected AuthToken variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_client_auth_cancel_parse() {
|
||||||
|
let json = r#"{"type":"auth_cancel","extension_name":"notion"}"#;
|
||||||
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::AuthCancel { extension_name } => {
|
||||||
|
assert_eq!(extension_name, "notion");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected AuthCancel variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_auth_required_serialize() {
|
||||||
|
let event = SseEvent::AuthRequired {
|
||||||
|
extension_name: "notion".to_string(),
|
||||||
|
instructions: Some("Get your token from...".to_string()),
|
||||||
|
auth_url: None,
|
||||||
|
setup_url: Some("https://notion.so/integrations".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["type"], "auth_required");
|
||||||
|
assert_eq!(parsed["extension_name"], "notion");
|
||||||
|
assert_eq!(parsed["instructions"], "Get your token from...");
|
||||||
|
assert!(parsed.get("auth_url").is_none());
|
||||||
|
assert_eq!(parsed["setup_url"], "https://notion.so/integrations");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_auth_completed_serialize() {
|
||||||
|
let event = SseEvent::AuthCompleted {
|
||||||
|
extension_name: "notion".to_string(),
|
||||||
|
success: true,
|
||||||
|
message: "notion authenticated (3 tools loaded)".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed["type"], "auth_completed");
|
||||||
|
assert_eq!(parsed["extension_name"], "notion");
|
||||||
|
assert_eq!(parsed["success"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_auth_required() {
|
||||||
|
let sse = SseEvent::AuthRequired {
|
||||||
|
extension_name: "openai".to_string(),
|
||||||
|
instructions: Some("Enter API key".to_string()),
|
||||||
|
auth_url: None,
|
||||||
|
setup_url: None,
|
||||||
|
};
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, data } => {
|
||||||
|
assert_eq!(event_type, "auth_required");
|
||||||
|
assert_eq!(data["extension_name"], "openai");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_server_from_sse_auth_completed() {
|
||||||
|
let sse = SseEvent::AuthCompleted {
|
||||||
|
extension_name: "slack".to_string(),
|
||||||
|
success: false,
|
||||||
|
message: "Invalid token".to_string(),
|
||||||
|
};
|
||||||
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
|
match ws {
|
||||||
|
WsServerMessage::Event { event_type, data } => {
|
||||||
|
assert_eq!(event_type, "auth_completed");
|
||||||
|
assert_eq!(data["success"], false);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Event variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_token_request_deserialize() {
|
||||||
|
let json = r#"{"extension_name":"telegram","token":"bot12345"}"#;
|
||||||
|
let req: AuthTokenRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(req.extension_name, "telegram");
|
||||||
|
assert_eq!(req.token, "bot12345");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_cancel_request_deserialize() {
|
||||||
|
let json = r#"{"extension_name":"telegram"}"#;
|
||||||
|
let req: AuthCancelRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(req.extension_name, "telegram");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
//! WebSocket handler for bidirectional client communication.
|
||||||
|
//!
|
||||||
|
//! Provides the same event stream as SSE but also accepts incoming messages
|
||||||
|
//! (chat, approvals) over a single persistent connection.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Client ──── WS frame: {"type":"message","content":"hello"} ──► Agent Loop
|
||||||
|
//! ◄─── WS frame: {"type":"event","event_type":"response","data":{...}} ── Broadcast
|
||||||
|
//! ──── WS frame: {"type":"ping"} ──────────────────────────────────────►
|
||||||
|
//! ◄─── WS frame: {"type":"pong"} ──────────────────────────────────────
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::agent::submission::Submission;
|
||||||
|
use crate::channels::IncomingMessage;
|
||||||
|
use crate::channels::web::server::GatewayState;
|
||||||
|
use crate::channels::web::types::{WsClientMessage, WsServerMessage};
|
||||||
|
|
||||||
|
/// Tracks active WebSocket connections.
|
||||||
|
pub struct WsConnectionTracker {
|
||||||
|
count: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsConnectionTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
count: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connection_count(&self) -> u64 {
|
||||||
|
self.count.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment(&self) {
|
||||||
|
self.count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement(&self) {
|
||||||
|
self.count.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WsConnectionTracker {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an upgraded WebSocket connection.
|
||||||
|
///
|
||||||
|
/// Spawns two tasks:
|
||||||
|
/// - **sender**: forwards broadcast events to the WebSocket client
|
||||||
|
/// - **receiver**: reads client frames and routes them to the agent
|
||||||
|
///
|
||||||
|
/// When either task ends (client disconnect or broadcast closed), both are
|
||||||
|
/// cleaned up.
|
||||||
|
pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
||||||
|
let (mut ws_sink, mut ws_stream) = socket.split();
|
||||||
|
|
||||||
|
// Track connection
|
||||||
|
if let Some(ref tracker) = state.ws_tracker {
|
||||||
|
tracker.increment();
|
||||||
|
}
|
||||||
|
let tracker_for_drop = state.ws_tracker.clone();
|
||||||
|
|
||||||
|
// Subscribe to broadcast events (same source as SSE)
|
||||||
|
let mut event_stream = Box::pin(state.sse.subscribe_raw());
|
||||||
|
|
||||||
|
// Channel for the sender task to receive messages from both
|
||||||
|
// the broadcast stream and any direct sends (like Pong)
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel::<WsServerMessage>(64);
|
||||||
|
|
||||||
|
// Sender task: forward broadcast events + direct messages to WS client
|
||||||
|
let sender_handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let msg = tokio::select! {
|
||||||
|
event = event_stream.next() => {
|
||||||
|
match event {
|
||||||
|
Some(sse_event) => WsServerMessage::from_sse_event(&sse_event),
|
||||||
|
None => break, // Broadcast channel closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
direct = direct_rx.recv() => {
|
||||||
|
match direct {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => break, // Direct channel closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = match serde_json::to_string(&msg) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ws_sink.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break; // Client disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Receiver task: read client frames and route to agent
|
||||||
|
let user_id = state.user_id.clone();
|
||||||
|
while let Some(Ok(frame)) = ws_stream.next().await {
|
||||||
|
match frame {
|
||||||
|
Message::Text(text) => {
|
||||||
|
let parsed: Result<WsClientMessage, _> = serde_json::from_str(&text);
|
||||||
|
match parsed {
|
||||||
|
Ok(client_msg) => {
|
||||||
|
handle_client_message(client_msg, &state, &user_id, &direct_tx).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: format!("Invalid message: {}", e),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
// Ignore binary, ping/pong (axum handles protocol-level pings)
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: abort sender, decrement counter
|
||||||
|
sender_handle.abort();
|
||||||
|
if let Some(ref tracker) = tracker_for_drop {
|
||||||
|
tracker.decrement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a parsed client message to the appropriate handler.
|
||||||
|
async fn handle_client_message(
|
||||||
|
msg: WsClientMessage,
|
||||||
|
state: &GatewayState,
|
||||||
|
user_id: &str,
|
||||||
|
direct_tx: &mpsc::Sender<WsServerMessage>,
|
||||||
|
) {
|
||||||
|
match msg {
|
||||||
|
WsClientMessage::Message { content, thread_id } => {
|
||||||
|
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
|
||||||
|
if let Some(ref tid) = thread_id {
|
||||||
|
incoming = incoming.with_thread(tid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx_guard = state.msg_tx.read().await;
|
||||||
|
if let Some(ref tx) = *tx_guard {
|
||||||
|
if tx.send(incoming).await.is_err() {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: "Channel closed".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: "Channel not started".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id,
|
||||||
|
action,
|
||||||
|
thread_id,
|
||||||
|
} => {
|
||||||
|
let (approved, always) = match action.as_str() {
|
||||||
|
"approve" => (true, false),
|
||||||
|
"always" => (true, true),
|
||||||
|
"deny" => (false, false),
|
||||||
|
other => {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: format!("Unknown approval action: {}", other),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_uuid = match Uuid::parse_str(&request_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: "Invalid request_id (expected UUID)".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let approval = Submission::ExecApproval {
|
||||||
|
request_id: request_uuid,
|
||||||
|
approved,
|
||||||
|
always,
|
||||||
|
};
|
||||||
|
let content = match serde_json::to_string(&approval) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: format!("Failed to serialize approval: {}", e),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut msg = IncomingMessage::new("gateway", user_id, content);
|
||||||
|
if let Some(ref tid) = thread_id {
|
||||||
|
msg = msg.with_thread(tid);
|
||||||
|
}
|
||||||
|
let tx_guard = state.msg_tx.read().await;
|
||||||
|
if let Some(ref tx) = *tx_guard {
|
||||||
|
let _ = tx.send(msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WsClientMessage::AuthToken {
|
||||||
|
extension_name,
|
||||||
|
token,
|
||||||
|
} => {
|
||||||
|
if let Some(ref ext_mgr) = state.extension_manager {
|
||||||
|
match ext_mgr.auth(&extension_name, Some(&token)).await {
|
||||||
|
Ok(result) if result.status == "authenticated" => {
|
||||||
|
let msg = match ext_mgr.activate(&extension_name).await {
|
||||||
|
Ok(r) => format!(
|
||||||
|
"{} authenticated ({} tools loaded)",
|
||||||
|
extension_name,
|
||||||
|
r.tools_loaded.len()
|
||||||
|
),
|
||||||
|
Err(e) => format!(
|
||||||
|
"{} authenticated but activation failed: {}",
|
||||||
|
extension_name, e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
crate::channels::web::server::clear_auth_mode(state).await;
|
||||||
|
state
|
||||||
|
.sse
|
||||||
|
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
|
||||||
|
extension_name,
|
||||||
|
success: true,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(result) => {
|
||||||
|
state
|
||||||
|
.sse
|
||||||
|
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
|
||||||
|
extension_name,
|
||||||
|
instructions: result.instructions,
|
||||||
|
auth_url: result.auth_url,
|
||||||
|
setup_url: result.setup_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: format!("Auth failed: {}", e),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = direct_tx
|
||||||
|
.send(WsServerMessage::Error {
|
||||||
|
message: "Extension manager not available".to_string(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WsClientMessage::AuthCancel { .. } => {
|
||||||
|
crate::channels::web::server::clear_auth_mode(state).await;
|
||||||
|
}
|
||||||
|
WsClientMessage::Ping => {
|
||||||
|
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_connection_tracker() {
|
||||||
|
let tracker = WsConnectionTracker::new();
|
||||||
|
assert_eq!(tracker.connection_count(), 0);
|
||||||
|
|
||||||
|
tracker.increment();
|
||||||
|
assert_eq!(tracker.connection_count(), 1);
|
||||||
|
|
||||||
|
tracker.increment();
|
||||||
|
assert_eq!(tracker.connection_count(), 2);
|
||||||
|
|
||||||
|
tracker.decrement();
|
||||||
|
assert_eq!(tracker.connection_count(), 1);
|
||||||
|
|
||||||
|
tracker.decrement();
|
||||||
|
assert_eq!(tracker.connection_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ws_connection_tracker_default() {
|
||||||
|
let tracker = WsConnectionTracker::default();
|
||||||
|
assert_eq!(tracker.connection_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_message_ping() {
|
||||||
|
// Ping should produce a Pong on the direct channel
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
||||||
|
let state = make_test_state(None).await;
|
||||||
|
|
||||||
|
handle_client_message(WsClientMessage::Ping, &state, "user1", &direct_tx).await;
|
||||||
|
|
||||||
|
let response = direct_rx.recv().await.unwrap();
|
||||||
|
assert!(matches!(response, WsServerMessage::Pong));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_message_sends_to_agent() {
|
||||||
|
// A Message should be forwarded to the agent's msg_tx
|
||||||
|
let (agent_tx, mut agent_rx) = mpsc::channel(16);
|
||||||
|
let state = make_test_state(Some(agent_tx)).await;
|
||||||
|
let (direct_tx, _direct_rx) = mpsc::channel(16);
|
||||||
|
|
||||||
|
handle_client_message(
|
||||||
|
WsClientMessage::Message {
|
||||||
|
content: "hello agent".to_string(),
|
||||||
|
thread_id: Some("t1".to_string()),
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
"user1",
|
||||||
|
&direct_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let incoming = agent_rx.recv().await.unwrap();
|
||||||
|
assert_eq!(incoming.content, "hello agent");
|
||||||
|
assert_eq!(incoming.thread_id.as_deref(), Some("t1"));
|
||||||
|
assert_eq!(incoming.channel, "gateway");
|
||||||
|
assert_eq!(incoming.user_id, "user1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_message_no_channel() {
|
||||||
|
// When msg_tx is None, should send an error back
|
||||||
|
let state = make_test_state(None).await;
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
||||||
|
|
||||||
|
handle_client_message(
|
||||||
|
WsClientMessage::Message {
|
||||||
|
content: "hello".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
"user1",
|
||||||
|
&direct_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = direct_rx.recv().await.unwrap();
|
||||||
|
match response {
|
||||||
|
WsServerMessage::Error { message } => {
|
||||||
|
assert!(message.contains("not started"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Error variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_approval_approve() {
|
||||||
|
let (agent_tx, mut agent_rx) = mpsc::channel(16);
|
||||||
|
let state = make_test_state(Some(agent_tx)).await;
|
||||||
|
let (direct_tx, _direct_rx) = mpsc::channel(16);
|
||||||
|
|
||||||
|
let request_id = Uuid::new_v4();
|
||||||
|
handle_client_message(
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
action: "approve".to_string(),
|
||||||
|
thread_id: Some("thread-42".to_string()),
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
"user1",
|
||||||
|
&direct_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let incoming = agent_rx.recv().await.unwrap();
|
||||||
|
// The content should be a serialized ExecApproval
|
||||||
|
assert!(incoming.content.contains("ExecApproval"));
|
||||||
|
// Thread should be forwarded onto the IncomingMessage.
|
||||||
|
assert_eq!(incoming.thread_id.as_deref(), Some("thread-42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_approval_invalid_action() {
|
||||||
|
let state = make_test_state(None).await;
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
||||||
|
|
||||||
|
handle_client_message(
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id: Uuid::new_v4().to_string(),
|
||||||
|
action: "maybe".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
"user1",
|
||||||
|
&direct_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = direct_rx.recv().await.unwrap();
|
||||||
|
match response {
|
||||||
|
WsServerMessage::Error { message } => {
|
||||||
|
assert!(message.contains("Unknown approval action"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Error variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_handle_client_approval_invalid_uuid() {
|
||||||
|
let state = make_test_state(None).await;
|
||||||
|
let (direct_tx, mut direct_rx) = mpsc::channel(16);
|
||||||
|
|
||||||
|
handle_client_message(
|
||||||
|
WsClientMessage::Approval {
|
||||||
|
request_id: "not-a-uuid".to_string(),
|
||||||
|
action: "approve".to_string(),
|
||||||
|
thread_id: None,
|
||||||
|
},
|
||||||
|
&state,
|
||||||
|
"user1",
|
||||||
|
&direct_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = direct_rx.recv().await.unwrap();
|
||||||
|
match response {
|
||||||
|
WsServerMessage::Error { message } => {
|
||||||
|
assert!(message.contains("Invalid request_id"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Error variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to create a GatewayState for testing.
|
||||||
|
async fn make_test_state(msg_tx: Option<mpsc::Sender<IncomingMessage>>) -> GatewayState {
|
||||||
|
use crate::channels::web::sse::SseManager;
|
||||||
|
|
||||||
|
GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
||||||
|
sse: SseManager::new(),
|
||||||
|
workspace: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
extension_manager: None,
|
||||||
|
tool_registry: None,
|
||||||
|
store: None,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue: None,
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Unified HTTP server for all webhook routes.
|
||||||
|
//!
|
||||||
|
//! Composes route fragments from HttpChannel, WASM channel router, etc.
|
||||||
|
//! into a single axum server. Channels define routes but never spawn servers.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
|
/// Configuration for the unified webhook server.
|
||||||
|
pub struct WebhookServerConfig {
|
||||||
|
/// Address to bind the server to.
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single HTTP server that hosts all webhook routes.
|
||||||
|
///
|
||||||
|
/// Channels contribute route fragments via `add_routes()`, then a single
|
||||||
|
/// `start()` call binds the listener and spawns the server task.
|
||||||
|
pub struct WebhookServer {
|
||||||
|
config: WebhookServerConfig,
|
||||||
|
routes: Vec<Router>,
|
||||||
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebhookServer {
|
||||||
|
/// Create a new webhook server with the given bind address.
|
||||||
|
pub fn new(config: WebhookServerConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
routes: Vec::new(),
|
||||||
|
shutdown_tx: None,
|
||||||
|
handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate a route fragment. Each fragment should already have its
|
||||||
|
/// state applied via `.with_state()`.
|
||||||
|
pub fn add_routes(&mut self, router: Router) {
|
||||||
|
self.routes.push(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the listener, merge all route fragments, and spawn the server.
|
||||||
|
pub async fn start(&mut self) -> Result<(), ChannelError> {
|
||||||
|
let mut app = Router::new();
|
||||||
|
for fragment in self.routes.drain(..) {
|
||||||
|
app = app.merge(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(self.config.addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChannelError::StartupFailed {
|
||||||
|
name: "webhook_server".to_string(),
|
||||||
|
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Webhook server listening on {}", self.config.addr);
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||||
|
self.shutdown_tx = Some(shutdown_tx);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
tracing::info!("Webhook server shutting down");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Webhook server error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.handle = Some(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal graceful shutdown and wait for the server task to finish.
|
||||||
|
pub async fn shutdown(&mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-54
@@ -1,6 +1,7 @@
|
|||||||
//! Configuration management CLI commands.
|
//! Configuration management CLI commands.
|
||||||
//!
|
//!
|
||||||
//! Commands for viewing and modifying settings.
|
//! Commands for viewing and modifying settings.
|
||||||
|
//! Settings are stored in PostgreSQL (env > DB > default).
|
||||||
|
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
|
|
||||||
@@ -36,41 +37,82 @@ pub enum ConfigCommand {
|
|||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show the settings file path
|
/// Show the settings storage info
|
||||||
Path,
|
Path,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a config command.
|
/// Run a config command.
|
||||||
pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
|
///
|
||||||
|
/// Connects to the database to read/write settings. Falls back to disk
|
||||||
|
/// if the database is not available.
|
||||||
|
pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
// Try to connect to the DB for settings access
|
||||||
|
let store = match connect_store().await {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Could not connect to database ({}), using disk fallback",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
ConfigCommand::List { filter } => list_settings(filter),
|
ConfigCommand::List { filter } => list_settings(store.as_ref(), filter).await,
|
||||||
ConfigCommand::Get { path } => get_setting(&path),
|
ConfigCommand::Get { path } => get_setting(store.as_ref(), &path).await,
|
||||||
ConfigCommand::Set { path, value } => set_setting(&path, &value),
|
ConfigCommand::Set { path, value } => set_setting(store.as_ref(), &path, &value).await,
|
||||||
ConfigCommand::Reset { path } => reset_setting(&path),
|
ConfigCommand::Reset { path } => reset_setting(store.as_ref(), &path).await,
|
||||||
ConfigCommand::Path => show_path(),
|
ConfigCommand::Path => show_path(store.is_some()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bootstrap a DB connection for config commands.
|
||||||
|
async fn connect_store() -> anyhow::Result<crate::history::Store> {
|
||||||
|
let config = crate::config::Config::from_env()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
let store = crate::history::Store::new(&config.database).await?;
|
||||||
|
store.run_migrations().await?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_USER_ID: &str = "default";
|
||||||
|
|
||||||
|
/// Load settings: DB if available, else disk.
|
||||||
|
async fn load_settings(store: Option<&crate::history::Store>) -> Settings {
|
||||||
|
if let Some(store) = store {
|
||||||
|
match store.get_all_settings(DEFAULT_USER_ID).await {
|
||||||
|
Ok(map) if !map.is_empty() => return Settings::from_db_map(&map),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Settings::load()
|
||||||
|
}
|
||||||
|
|
||||||
/// List all settings.
|
/// List all settings.
|
||||||
fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
|
async fn list_settings(
|
||||||
let settings = Settings::load();
|
store: Option<&crate::history::Store>,
|
||||||
|
filter: Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let settings = load_settings(store).await;
|
||||||
let all = settings.list();
|
let all = settings.list();
|
||||||
|
|
||||||
// Find the longest key for alignment
|
|
||||||
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||||
|
|
||||||
println!("Settings:");
|
let source = if store.is_some() { "database" } else { "disk" };
|
||||||
|
println!("Settings (source: {}):", source);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for (key, value) in all {
|
for (key, value) in all {
|
||||||
// Skip if filter is set and doesn't match
|
|
||||||
if let Some(ref f) = filter {
|
if let Some(ref f) = filter {
|
||||||
if !key.starts_with(f) {
|
if !key.starts_with(f) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate long values for display
|
|
||||||
let display_value = if value.len() > 60 {
|
let display_value = if value.len() > 60 {
|
||||||
format!("{}...", &value[..57])
|
format!("{}...", &value[..57])
|
||||||
} else {
|
} else {
|
||||||
@@ -84,8 +126,8 @@ fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific setting.
|
/// Get a specific setting.
|
||||||
fn get_setting(path: &str) -> anyhow::Result<()> {
|
async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> {
|
||||||
let settings = Settings::load();
|
let settings = load_settings(store).await;
|
||||||
|
|
||||||
match settings.get(path) {
|
match settings.get(path) {
|
||||||
Some(value) => {
|
Some(value) => {
|
||||||
@@ -99,67 +141,92 @@ fn get_setting(path: &str) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set a setting value.
|
/// Set a setting value.
|
||||||
fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
|
async fn set_setting(
|
||||||
let mut settings = Settings::load();
|
store: Option<&crate::history::Store>,
|
||||||
|
path: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut settings = load_settings(store).await;
|
||||||
|
|
||||||
// Try to set the value
|
|
||||||
settings
|
settings
|
||||||
.set(path, value)
|
.set(path, value)
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
// Save to disk
|
// Save to DB if available, otherwise disk
|
||||||
settings.save()?;
|
if let Some(store) = store {
|
||||||
|
let json_value = match serde_json::from_str::<serde_json::Value>(value) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => serde_json::Value::String(value.to_string()),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.set_setting(DEFAULT_USER_ID, path, &json_value)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
|
||||||
|
} else {
|
||||||
|
settings.save()?;
|
||||||
|
}
|
||||||
|
|
||||||
println!("Set {} = {}", path, value);
|
println!("Set {} = {}", path, value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset a setting to default.
|
/// Reset a setting to default.
|
||||||
fn reset_setting(path: &str) -> anyhow::Result<()> {
|
async fn reset_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> {
|
||||||
let mut settings = Settings::load();
|
|
||||||
|
|
||||||
// Get the default value for display
|
|
||||||
let default = Settings::default();
|
let default = Settings::default();
|
||||||
let default_value = default
|
let default_value = default
|
||||||
.get(path)
|
.get(path)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
||||||
|
|
||||||
// Reset it
|
// Delete from DB (falling back to default) or reset on disk
|
||||||
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
|
if let Some(store) = store {
|
||||||
|
store
|
||||||
// Save to disk
|
.delete_setting(DEFAULT_USER_ID, path)
|
||||||
settings.save()?;
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
|
||||||
|
} else {
|
||||||
|
let mut settings = Settings::load();
|
||||||
|
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
settings.save()?;
|
||||||
|
}
|
||||||
|
|
||||||
println!("Reset {} to default: {}", path, default_value);
|
println!("Reset {} to default: {}", path, default_value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the settings file path.
|
/// Show the settings storage info.
|
||||||
fn show_path() -> anyhow::Result<()> {
|
fn show_path(has_db: bool) -> anyhow::Result<()> {
|
||||||
let path = Settings::default_path();
|
if has_db {
|
||||||
println!("{}", path.display());
|
println!("Settings stored in: PostgreSQL (settings table)");
|
||||||
|
println!(
|
||||||
if path.exists() {
|
"Bootstrap config: {}",
|
||||||
let metadata = std::fs::metadata(&path)?;
|
crate::bootstrap::BootstrapConfig::default_path().display()
|
||||||
println!(" Size: {} bytes", metadata.len());
|
);
|
||||||
if let Ok(modified) = metadata.modified() {
|
|
||||||
use std::time::SystemTime;
|
|
||||||
let duration = SystemTime::now()
|
|
||||||
.duration_since(modified)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let secs = duration.as_secs();
|
|
||||||
if secs < 60 {
|
|
||||||
println!(" Modified: {} seconds ago", secs);
|
|
||||||
} else if secs < 3600 {
|
|
||||||
println!(" Modified: {} minutes ago", secs / 60);
|
|
||||||
} else if secs < 86400 {
|
|
||||||
println!(" Modified: {} hours ago", secs / 3600);
|
|
||||||
} else {
|
|
||||||
println!(" Modified: {} days ago", secs / 86400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
println!(" (does not exist, using defaults)");
|
let path = Settings::default_path();
|
||||||
|
println!("Settings stored in: {} (disk fallback)", path.display());
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let metadata = std::fs::metadata(&path)?;
|
||||||
|
println!(" Size: {} bytes", metadata.len());
|
||||||
|
if let Ok(modified) = metadata.modified() {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(modified)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = duration.as_secs();
|
||||||
|
if secs < 60 {
|
||||||
|
println!(" Modified: {} seconds ago", secs);
|
||||||
|
} else if secs < 3600 {
|
||||||
|
println!(" Modified: {} minutes ago", secs / 60);
|
||||||
|
} else if secs < 86400 {
|
||||||
|
println!(" Modified: {} hours ago", secs / 3600);
|
||||||
|
} else {
|
||||||
|
println!(" Modified: {} days ago", secs / 86400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(" (does not exist, using defaults)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+64
-13
@@ -13,9 +13,7 @@ use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
|||||||
use crate::tools::mcp::{
|
use crate::tools::mcp::{
|
||||||
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||||
auth::{authorize_mcp_server, is_authenticated},
|
auth::{authorize_mcp_server, is_authenticated},
|
||||||
config::{
|
config::{self, McpServersFile},
|
||||||
add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
@@ -173,8 +171,11 @@ async fn add_server(
|
|||||||
// Validate
|
// Validate
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
|
|
||||||
// Save
|
// Save (DB if available, else disk)
|
||||||
add_mcp_server(config).await?;
|
let store = connect_store().await;
|
||||||
|
let mut servers = load_servers(store.as_ref()).await?;
|
||||||
|
servers.upsert(config);
|
||||||
|
save_servers(store.as_ref(), &servers).await?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ✓ Added MCP server '{}'", name);
|
println!(" ✓ Added MCP server '{}'", name);
|
||||||
@@ -192,7 +193,12 @@ async fn add_server(
|
|||||||
|
|
||||||
/// Remove an MCP server.
|
/// Remove an MCP server.
|
||||||
async fn remove_server(name: String) -> anyhow::Result<()> {
|
async fn remove_server(name: String) -> anyhow::Result<()> {
|
||||||
remove_mcp_server(&name).await?;
|
let store = connect_store().await;
|
||||||
|
let mut servers = load_servers(store.as_ref()).await?;
|
||||||
|
if !servers.remove(&name) {
|
||||||
|
anyhow::bail!("Server '{}' not found", name);
|
||||||
|
}
|
||||||
|
save_servers(store.as_ref(), &servers).await?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ✓ Removed MCP server '{}'", name);
|
println!(" ✓ Removed MCP server '{}'", name);
|
||||||
@@ -203,7 +209,8 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// List configured MCP servers.
|
/// List configured MCP servers.
|
||||||
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
||||||
let servers = load_mcp_servers().await?;
|
let store = connect_store().await;
|
||||||
|
let servers = load_servers(store.as_ref()).await?;
|
||||||
|
|
||||||
if servers.servers.is_empty() {
|
if servers.servers.is_empty() {
|
||||||
println!();
|
println!();
|
||||||
@@ -261,7 +268,12 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
|||||||
/// Authenticate with an MCP server.
|
/// Authenticate with an MCP server.
|
||||||
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||||
// Get server config
|
// Get server config
|
||||||
let server = get_mcp_server(&name).await?;
|
let store = connect_store().await;
|
||||||
|
let servers = load_servers(store.as_ref()).await?;
|
||||||
|
let server = servers
|
||||||
|
.get(&name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
|
||||||
|
|
||||||
// Initialize secrets store
|
// Initialize secrets store
|
||||||
let secrets = get_secrets_store().await?;
|
let secrets = get_secrets_store().await?;
|
||||||
@@ -329,7 +341,12 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
|||||||
/// Test connection to an MCP server.
|
/// Test connection to an MCP server.
|
||||||
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||||
// Get server config
|
// Get server config
|
||||||
let server = get_mcp_server(&name).await?;
|
let store = connect_store().await;
|
||||||
|
let servers = load_servers(store.as_ref()).await?;
|
||||||
|
let server = servers
|
||||||
|
.get(&name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" Testing connection to '{}'...", name);
|
println!(" Testing connection to '{}'...", name);
|
||||||
@@ -420,7 +437,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Toggle server enabled/disabled state.
|
/// Toggle server enabled/disabled state.
|
||||||
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
|
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
|
||||||
let mut servers = load_mcp_servers().await?;
|
let store = connect_store().await;
|
||||||
|
let mut servers = load_servers(store.as_ref()).await?;
|
||||||
|
|
||||||
let server = servers
|
let server = servers
|
||||||
.get_mut(&name)
|
.get_mut(&name)
|
||||||
@@ -435,7 +453,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
|||||||
};
|
};
|
||||||
|
|
||||||
server.enabled = new_state;
|
server.enabled = new_state;
|
||||||
save_mcp_servers(&servers).await?;
|
save_servers(store.as_ref(), &servers).await?;
|
||||||
|
|
||||||
let status = if new_state { "enabled" } else { "disabled" };
|
let status = if new_state { "enabled" } else { "disabled" };
|
||||||
println!();
|
println!();
|
||||||
@@ -445,12 +463,45 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_USER_ID: &str = "default";
|
||||||
|
|
||||||
|
/// Try to connect to the database store for DB-backed config.
|
||||||
|
async fn connect_store() -> Option<Store> {
|
||||||
|
let config = Config::from_env().await.ok()?;
|
||||||
|
let store = Store::new(&config.database).await.ok()?;
|
||||||
|
store.run_migrations().await.ok()?;
|
||||||
|
Some(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load MCP servers (DB if available, else disk).
|
||||||
|
async fn load_servers(store: Option<&Store>) -> Result<McpServersFile, config::ConfigError> {
|
||||||
|
if let Some(store) = store {
|
||||||
|
config::load_mcp_servers_from_db(store, DEFAULT_USER_ID).await
|
||||||
|
} else {
|
||||||
|
config::load_mcp_servers().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save MCP servers (DB if available, else disk).
|
||||||
|
async fn save_servers(
|
||||||
|
store: Option<&Store>,
|
||||||
|
servers: &McpServersFile,
|
||||||
|
) -> Result<(), config::ConfigError> {
|
||||||
|
if let Some(store) = store {
|
||||||
|
config::save_mcp_servers_to_db(store, DEFAULT_USER_ID, servers).await
|
||||||
|
} else {
|
||||||
|
config::save_mcp_servers(servers).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize and return the secrets store.
|
/// Initialize and return the secrets store.
|
||||||
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env().await?;
|
||||||
|
|
||||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
anyhow::anyhow!("SECRETS_MASTER_KEY not set. Run 'ironclaw setup' first or set it in .env")
|
anyhow::anyhow!(
|
||||||
|
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let store = Store::new(&config.database).await?;
|
let store = Store::new(&config.database).await?;
|
||||||
|
|||||||
+47
-9
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Provides subcommands for:
|
//! Provides subcommands for:
|
||||||
//! - Running the agent (`run`)
|
//! - Running the agent (`run`)
|
||||||
//! - Interactive setup wizard (`setup`)
|
//! - Interactive onboarding wizard (`onboard`)
|
||||||
//! - Managing configuration (`config list`, `config get`, `config set`)
|
//! - Managing configuration (`config list`, `config get`, `config set`)
|
||||||
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
||||||
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
||||||
@@ -12,12 +12,14 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
mod pairing;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
mod tool;
|
mod tool;
|
||||||
|
|
||||||
pub use config::{ConfigCommand, run_config_command};
|
pub use config::{ConfigCommand, run_config_command};
|
||||||
pub use mcp::{McpCommand, run_mcp_command};
|
pub use mcp::{McpCommand, run_mcp_command};
|
||||||
pub use memory::{MemoryCommand, run_memory_command};
|
pub use memory::{MemoryCommand, run_memory_command};
|
||||||
|
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use status::run_status_command;
|
pub use status::run_status_command;
|
||||||
pub use tool::{ToolCommand, run_tool_command};
|
pub use tool::{ToolCommand, run_tool_command};
|
||||||
|
|
||||||
@@ -41,10 +43,6 @@ pub struct Cli {
|
|||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub no_db: bool,
|
pub no_db: bool,
|
||||||
|
|
||||||
/// Simple REPL mode without TUI (for testing)
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
pub repl: bool,
|
|
||||||
|
|
||||||
/// Single message mode - send one message and exit
|
/// Single message mode - send one message and exit
|
||||||
#[arg(short, long, global = true)]
|
#[arg(short, long, global = true)]
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
@@ -53,9 +51,9 @@ pub struct Cli {
|
|||||||
#[arg(short, long, global = true)]
|
#[arg(short, long, global = true)]
|
||||||
pub config: Option<std::path::PathBuf>,
|
pub config: Option<std::path::PathBuf>,
|
||||||
|
|
||||||
/// Skip first-run setup check
|
/// Skip first-run onboarding check
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub no_setup: bool,
|
pub no_onboard: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -63,8 +61,8 @@ pub enum Command {
|
|||||||
/// Run the agent (default if no subcommand given)
|
/// Run the agent (default if no subcommand given)
|
||||||
Run,
|
Run,
|
||||||
|
|
||||||
/// Interactive setup wizard
|
/// Interactive onboarding wizard
|
||||||
Setup {
|
Onboard {
|
||||||
/// Skip authentication (use existing session)
|
/// Skip authentication (use existing session)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
skip_auth: bool,
|
skip_auth: bool,
|
||||||
@@ -90,8 +88,48 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Memory(MemoryCommand),
|
Memory(MemoryCommand),
|
||||||
|
|
||||||
|
/// DM pairing (approve inbound requests from unknown senders)
|
||||||
|
#[command(subcommand)]
|
||||||
|
Pairing(PairingCommand),
|
||||||
|
|
||||||
/// Show system health and diagnostics
|
/// Show system health and diagnostics
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
|
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||||
|
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||||
|
Worker {
|
||||||
|
/// Job ID to execute.
|
||||||
|
#[arg(long)]
|
||||||
|
job_id: uuid::Uuid,
|
||||||
|
|
||||||
|
/// URL of the orchestrator's internal API.
|
||||||
|
#[arg(long, default_value = "http://host.docker.internal:50051")]
|
||||||
|
orchestrator_url: String,
|
||||||
|
|
||||||
|
/// Maximum iterations before stopping.
|
||||||
|
#[arg(long, default_value = "50")]
|
||||||
|
max_iterations: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Run as a Claude Code bridge inside a Docker container (internal use).
|
||||||
|
/// Spawns the `claude` CLI and streams output back to the orchestrator.
|
||||||
|
ClaudeBridge {
|
||||||
|
/// Job ID to execute.
|
||||||
|
#[arg(long)]
|
||||||
|
job_id: uuid::Uuid,
|
||||||
|
|
||||||
|
/// URL of the orchestrator's internal API.
|
||||||
|
#[arg(long, default_value = "http://host.docker.internal:50051")]
|
||||||
|
orchestrator_url: String,
|
||||||
|
|
||||||
|
/// Maximum agentic turns for Claude Code.
|
||||||
|
#[arg(long, default_value = "50")]
|
||||||
|
max_turns: u32,
|
||||||
|
|
||||||
|
/// Claude model to use (e.g. "sonnet", "opus").
|
||||||
|
#[arg(long, default_value = "sonnet")]
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! DM pairing CLI commands.
|
||||||
|
//!
|
||||||
|
//! Manage pairing requests for channels (Telegram, Slack, etc.).
|
||||||
|
|
||||||
|
use clap::Subcommand;
|
||||||
|
|
||||||
|
use crate::pairing::PairingStore;
|
||||||
|
|
||||||
|
/// Pairing subcommands.
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum PairingCommand {
|
||||||
|
/// List pending pairing requests
|
||||||
|
List {
|
||||||
|
/// Channel name (e.g., telegram, slack)
|
||||||
|
#[arg(required = true)]
|
||||||
|
channel: String,
|
||||||
|
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Approve a pairing request by code
|
||||||
|
Approve {
|
||||||
|
/// Channel name (e.g., telegram, slack)
|
||||||
|
#[arg(required = true)]
|
||||||
|
channel: String,
|
||||||
|
|
||||||
|
/// Pairing code (e.g., ABC12345)
|
||||||
|
#[arg(required = true)]
|
||||||
|
code: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run pairing CLI command.
|
||||||
|
pub fn run_pairing_command(cmd: PairingCommand) -> Result<(), String> {
|
||||||
|
run_pairing_command_with_store(&PairingStore::new(), cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run pairing CLI command with a given store (for testing).
|
||||||
|
pub fn run_pairing_command_with_store(
|
||||||
|
store: &PairingStore,
|
||||||
|
cmd: PairingCommand,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match cmd {
|
||||||
|
PairingCommand::List { channel, json } => run_list(store, &channel, json),
|
||||||
|
PairingCommand::Approve { channel, code } => run_approve(store, &channel, &code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_list(store: &PairingStore, channel: &str, json: bool) -> Result<(), String> {
|
||||||
|
let requests = store.list_pending(channel).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&requests).map_err(|e| e.to_string())?
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if requests.is_empty() {
|
||||||
|
println!("No pending {} pairing requests.", channel);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Pairing requests ({}):", requests.len());
|
||||||
|
for r in &requests {
|
||||||
|
let meta = r
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.as_object())
|
||||||
|
.map(|o| {
|
||||||
|
o.iter()
|
||||||
|
.filter_map(|(k, v)| v.as_str().map(|s| format!("{}={}", k, s)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(" {} {} {} {}", r.code, r.id, meta, r.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_approve(store: &PairingStore, channel: &str, code: &str) -> Result<(), String> {
|
||||||
|
match store.approve(channel, code) {
|
||||||
|
Ok(Some(entry)) => {
|
||||||
|
println!("Approved {} sender {}.", channel, entry.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(None) => Err(format!(
|
||||||
|
"No pending pairing request found for code: {}",
|
||||||
|
code
|
||||||
|
)),
|
||||||
|
Err(crate::pairing::PairingStoreError::ApproveRateLimited) => Err(
|
||||||
|
"Too many failed approve attempts. Wait a few minutes before trying again.".to_string(),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn test_store() -> (PairingStore, TempDir) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store = PairingStore::with_base_dir(dir.path().to_path_buf());
|
||||||
|
(store, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_empty_returns_ok() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
let result = run_pairing_command_with_store(
|
||||||
|
&store,
|
||||||
|
PairingCommand::List {
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
json: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_json_empty_returns_ok() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
let result = run_pairing_command_with_store(
|
||||||
|
&store,
|
||||||
|
PairingCommand::List {
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_approve_invalid_code_returns_err() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
// Create a pending request so the pairing file exists, then approve with wrong code
|
||||||
|
store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
|
||||||
|
let result = run_pairing_command_with_store(
|
||||||
|
&store,
|
||||||
|
PairingCommand::Approve {
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
code: "BADCODE1".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("No pending pairing request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_approve_valid_code_returns_ok() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
let r = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
assert!(r.created);
|
||||||
|
|
||||||
|
let result = run_pairing_command_with_store(
|
||||||
|
&store,
|
||||||
|
PairingCommand::Approve {
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
code: r.code,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_with_pending_returns_ok() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
|
||||||
|
let result = run_pairing_command_with_store(
|
||||||
|
&store,
|
||||||
|
PairingCommand::List {
|
||||||
|
channel: "telegram".to_string(),
|
||||||
|
json: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -40,14 +40,14 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
if session_path.exists() {
|
if session_path.exists() {
|
||||||
println!("found ({})", session_path.display());
|
println!("found ({})", session_path.display());
|
||||||
} else {
|
} else {
|
||||||
println!("not found (run `ironclaw setup`)");
|
println!("not found (run `ironclaw onboard`)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets
|
// Secrets
|
||||||
print!(" Secrets: ");
|
print!(" Secrets: ");
|
||||||
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|
||||||
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|
||||||
|| crate::secrets::keychain::has_master_key();
|
|| crate::secrets::keychain::has_master_key().await;
|
||||||
if secrets_configured {
|
if secrets_configured {
|
||||||
println!("configured ({:?})", settings.secrets_master_key_source);
|
println!("configured ({:?})", settings.secrets_master_key_source);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+4
-2
@@ -715,9 +715,11 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Initialize secrets store
|
// Initialize secrets store
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env().await?;
|
||||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
anyhow::anyhow!("SECRETS_MASTER_KEY not set. Run 'ironclaw setup' first or set it in .env")
|
anyhow::anyhow!(
|
||||||
|
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let store = Store::new(&config.database).await?;
|
let store = Store::new(&config.database).await?;
|
||||||
|
|||||||
+424
-124
@@ -1,4 +1,9 @@
|
|||||||
//! Configuration for IronClaw.
|
//! Configuration for IronClaw.
|
||||||
|
//!
|
||||||
|
//! Settings are loaded with priority: env var > database > default.
|
||||||
|
//! The database replaces the old `settings.json` file for all settings
|
||||||
|
//! except the 4 bootstrap fields (database_url, pool_size, secrets key
|
||||||
|
//! source, onboard_completed) which live in `~/.ironclaw/bootstrap.json`.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -6,6 +11,7 @@ use std::time::Duration;
|
|||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Main configuration for the agent.
|
/// Main configuration for the agent.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -21,28 +27,67 @@ pub struct Config {
|
|||||||
pub secrets: SecretsConfig,
|
pub secrets: SecretsConfig,
|
||||||
pub builder: BuilderModeConfig,
|
pub builder: BuilderModeConfig,
|
||||||
pub heartbeat: HeartbeatConfig,
|
pub heartbeat: HeartbeatConfig,
|
||||||
|
pub routines: RoutineConfig,
|
||||||
pub sandbox: SandboxModeConfig,
|
pub sandbox: SandboxModeConfig,
|
||||||
|
pub claude_code: ClaudeCodeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
/// Load configuration from environment variables.
|
/// Load configuration from environment variables and the database.
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
///
|
||||||
// Load .env file if present (ignore errors if not found)
|
/// Priority: env var > DB settings > default.
|
||||||
|
/// This is the primary way to load config after DB is connected.
|
||||||
|
pub async fn from_db(
|
||||||
|
store: &crate::history::Store,
|
||||||
|
user_id: &str,
|
||||||
|
bootstrap: &crate::bootstrap::BootstrapConfig,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
// Load all settings from DB into a Settings struct
|
||||||
|
let db_settings = match store.get_all_settings(user_id).await {
|
||||||
|
Ok(map) => Settings::from_db_map(&map),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
|
||||||
|
Settings::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::build(bootstrap, &db_settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration from environment variables only (no database).
|
||||||
|
///
|
||||||
|
/// Used during early startup before the database is connected,
|
||||||
|
/// and by CLI commands that don't have DB access.
|
||||||
|
/// Falls back to legacy `settings.json` on disk if present.
|
||||||
|
pub async fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
let bootstrap = crate::bootstrap::BootstrapConfig::load();
|
||||||
|
let settings = Settings::load();
|
||||||
|
Self::build(&bootstrap, &settings).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build config from bootstrap + settings (shared by from_env and from_db).
|
||||||
|
async fn build(
|
||||||
|
bootstrap: &crate::bootstrap::BootstrapConfig,
|
||||||
|
settings: &Settings,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database: DatabaseConfig::from_env()?,
|
database: DatabaseConfig::resolve(bootstrap)?,
|
||||||
llm: LlmConfig::from_env()?,
|
llm: LlmConfig::resolve(settings)?,
|
||||||
embeddings: EmbeddingsConfig::from_env()?,
|
embeddings: EmbeddingsConfig::resolve(settings)?,
|
||||||
tunnel: TunnelConfig::from_env()?,
|
tunnel: TunnelConfig::resolve(settings)?,
|
||||||
channels: ChannelsConfig::from_env()?,
|
channels: ChannelsConfig::resolve(settings)?,
|
||||||
agent: AgentConfig::from_env()?,
|
agent: AgentConfig::resolve(settings)?,
|
||||||
safety: SafetyConfig::from_env()?,
|
safety: SafetyConfig::resolve()?,
|
||||||
wasm: WasmConfig::from_env()?,
|
wasm: WasmConfig::resolve()?,
|
||||||
secrets: SecretsConfig::from_env()?,
|
secrets: SecretsConfig::resolve(bootstrap).await?,
|
||||||
builder: BuilderModeConfig::from_env()?,
|
builder: BuilderModeConfig::resolve()?,
|
||||||
heartbeat: HeartbeatConfig::from_env()?,
|
heartbeat: HeartbeatConfig::resolve(settings)?,
|
||||||
sandbox: SandboxModeConfig::from_env()?,
|
routines: RoutineConfig::resolve()?,
|
||||||
|
sandbox: SandboxModeConfig::resolve()?,
|
||||||
|
claude_code: ClaudeCodeConfig::resolve()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,48 +96,17 @@ impl Config {
|
|||||||
///
|
///
|
||||||
/// Used by channels and tools that need public webhook endpoints.
|
/// Used by channels and tools that need public webhook endpoints.
|
||||||
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
|
/// The tunnel URL is shared across all channels (Telegram, Slack, etc.).
|
||||||
///
|
|
||||||
/// # Security Notes
|
|
||||||
///
|
|
||||||
/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level
|
|
||||||
/// authentication because webhook providers (Telegram, Slack, GitHub) need
|
|
||||||
/// unauthenticated access to POST updates. Security for webhooks comes from:
|
|
||||||
/// - Webhook signature verification (provider-specific secrets)
|
|
||||||
/// - IP allowlisting (if supported by provider)
|
|
||||||
///
|
|
||||||
/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using
|
|
||||||
/// tunnel provider features:
|
|
||||||
/// - ngrok: Basic Auth, OAuth, IP restrictions
|
|
||||||
/// - Cloudflare: Access policies, mTLS
|
|
||||||
///
|
|
||||||
/// These protections are configured in the tunnel provider, not here.
|
|
||||||
///
|
|
||||||
/// # Supported Providers
|
|
||||||
///
|
|
||||||
/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io`
|
|
||||||
/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080`
|
|
||||||
/// - **localtunnel**: `lt --port 8080`
|
|
||||||
/// - Any service that provides a public HTTPS URL to localhost
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct TunnelConfig {
|
pub struct TunnelConfig {
|
||||||
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
||||||
///
|
|
||||||
/// When set, channels that support webhooks will register their endpoints
|
|
||||||
/// with this base URL instead of using polling.
|
|
||||||
pub public_url: Option<String>,
|
pub public_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TunnelConfig {
|
impl TunnelConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
// Priority: env var > settings file
|
let public_url = optional_env("TUNNEL_URL")?
|
||||||
let public_url = optional_env("TUNNEL_URL")?.or_else(|| {
|
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
||||||
crate::settings::Settings::load()
|
|
||||||
.tunnel
|
|
||||||
.public_url
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate URL format if provided
|
|
||||||
if let Some(ref url) = public_url {
|
if let Some(ref url) = public_url {
|
||||||
if !url.starts_with("https://") {
|
if !url.starts_with("https://") {
|
||||||
return Err(ConfigError::InvalidValue {
|
return Err(ConfigError::InvalidValue {
|
||||||
@@ -111,8 +125,6 @@ impl TunnelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the webhook URL for a given path.
|
/// Get the webhook URL for a given path.
|
||||||
///
|
|
||||||
/// Returns `None` if no tunnel is configured.
|
|
||||||
pub fn webhook_url(&self, path: &str) -> Option<String> {
|
pub fn webhook_url(&self, path: &str) -> Option<String> {
|
||||||
self.public_url.as_ref().map(|base| {
|
self.public_url.as_ref().map(|base| {
|
||||||
let base = base.trim_end_matches('/');
|
let base = base.trim_end_matches('/');
|
||||||
@@ -130,18 +142,14 @@ pub struct DatabaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DatabaseConfig {
|
impl DatabaseConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
|
|
||||||
// Priority: env var > settings > error (required)
|
|
||||||
let url = optional_env("DATABASE_URL")?
|
let url = optional_env("DATABASE_URL")?
|
||||||
.or(settings.database_url.clone())
|
.or_else(|| bootstrap.database_url.clone())
|
||||||
.ok_or_else(|| ConfigError::MissingRequired {
|
.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
key: "database_url".to_string(),
|
key: "database_url".to_string(),
|
||||||
hint: "Run 'ironclaw setup' or set DATABASE_URL environment variable".to_string(),
|
hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Priority: env var > settings > default
|
|
||||||
let pool_size = optional_env("DATABASE_POOL_SIZE")?
|
let pool_size = optional_env("DATABASE_POOL_SIZE")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -149,7 +157,7 @@ impl DatabaseConfig {
|
|||||||
key: "DATABASE_POOL_SIZE".to_string(),
|
key: "DATABASE_POOL_SIZE".to_string(),
|
||||||
message: format!("must be a positive integer: {e}"),
|
message: format!("must be a positive integer: {e}"),
|
||||||
})?
|
})?
|
||||||
.or(settings.database_pool_size)
|
.or(bootstrap.database_pool_size)
|
||||||
.unwrap_or(10);
|
.unwrap_or(10);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -164,10 +172,102 @@ impl DatabaseConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM provider configuration (NEAR AI only).
|
/// Which LLM backend to use.
|
||||||
|
///
|
||||||
|
/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem.
|
||||||
|
/// Users can override with `LLM_BACKEND` env var to use their own API keys.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum LlmBackend {
|
||||||
|
/// NEAR AI proxy (default) -- session or API key auth
|
||||||
|
#[default]
|
||||||
|
NearAi,
|
||||||
|
/// Direct OpenAI API
|
||||||
|
OpenAi,
|
||||||
|
/// Direct Anthropic API
|
||||||
|
Anthropic,
|
||||||
|
/// Local Ollama instance
|
||||||
|
Ollama,
|
||||||
|
/// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together)
|
||||||
|
OpenAiCompatible,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for LlmBackend {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"nearai" | "near_ai" | "near" => Ok(Self::NearAi),
|
||||||
|
"openai" | "open_ai" => Ok(Self::OpenAi),
|
||||||
|
"anthropic" | "claude" => Ok(Self::Anthropic),
|
||||||
|
"ollama" => Ok(Self::Ollama),
|
||||||
|
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
||||||
|
_ => Err(format!(
|
||||||
|
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible",
|
||||||
|
s
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LlmBackend {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NearAi => write!(f, "nearai"),
|
||||||
|
Self::OpenAi => write!(f, "openai"),
|
||||||
|
Self::Anthropic => write!(f, "anthropic"),
|
||||||
|
Self::Ollama => write!(f, "ollama"),
|
||||||
|
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for direct OpenAI API access.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenAiDirectConfig {
|
||||||
|
pub api_key: SecretString,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for direct Anthropic API access.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AnthropicDirectConfig {
|
||||||
|
pub api_key: SecretString,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for local Ollama.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OllamaConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for any OpenAI-compatible endpoint.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenAiCompatibleConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: Option<SecretString>,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LLM provider configuration.
|
||||||
|
///
|
||||||
|
/// NEAR AI remains the default backend. Users can switch to other providers
|
||||||
|
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LlmConfig {
|
pub struct LlmConfig {
|
||||||
|
/// Which backend to use (default: NearAi)
|
||||||
|
pub backend: LlmBackend,
|
||||||
|
/// NEAR AI config (always populated for NEAR AI embeddings, etc.)
|
||||||
pub nearai: NearAiConfig,
|
pub nearai: NearAiConfig,
|
||||||
|
/// Direct OpenAI config (populated when backend=openai)
|
||||||
|
pub openai: Option<OpenAiDirectConfig>,
|
||||||
|
/// Direct Anthropic config (populated when backend=anthropic)
|
||||||
|
pub anthropic: Option<AnthropicDirectConfig>,
|
||||||
|
/// Ollama config (populated when backend=ollama)
|
||||||
|
pub ollama: Option<OllamaConfig>,
|
||||||
|
/// OpenAI-compatible config (populated when backend=openai_compatible)
|
||||||
|
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API mode for NEAR AI.
|
/// API mode for NEAR AI.
|
||||||
@@ -215,42 +315,110 @@ pub struct NearAiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
// Determine backend (default: NearAi)
|
||||||
|
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||||
|
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "LLM_BACKEND".to_string(),
|
||||||
|
message: e,
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
LlmBackend::NearAi
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always resolve NEAR AI config (used as fallback and for embeddings)
|
||||||
|
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||||
|
|
||||||
// Determine API mode: explicit setting, or infer from API key presence
|
|
||||||
let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? {
|
let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? {
|
||||||
mode_str.parse().map_err(|e| ConfigError::InvalidValue {
|
mode_str.parse().map_err(|e| ConfigError::InvalidValue {
|
||||||
key: "NEARAI_API_MODE".to_string(),
|
key: "NEARAI_API_MODE".to_string(),
|
||||||
message: e,
|
message: e,
|
||||||
})?
|
})?
|
||||||
} else if api_key.is_some() {
|
} else if nearai_api_key.is_some() {
|
||||||
// If API key is provided, default to chat_completions mode
|
|
||||||
NearAiApiMode::ChatCompletions
|
NearAiApiMode::ChatCompletions
|
||||||
} else {
|
} else {
|
||||||
NearAiApiMode::Responses
|
NearAiApiMode::Responses
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
let nearai = NearAiConfig {
|
||||||
nearai: NearAiConfig {
|
model: optional_env("NEARAI_MODEL")?
|
||||||
// Load model from saved settings first, then env, then default
|
.or_else(|| settings.selected_model.clone())
|
||||||
model: crate::settings::Settings::load()
|
.unwrap_or_else(|| {
|
||||||
.selected_model
|
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||||
.or_else(|| optional_env("NEARAI_MODEL").ok().flatten())
|
.to_string()
|
||||||
.unwrap_or_else(|| {
|
}),
|
||||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
base_url: optional_env("NEARAI_BASE_URL")?
|
||||||
.to_string()
|
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
|
||||||
}),
|
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||||
base_url: optional_env("NEARAI_BASE_URL")?
|
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||||
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
|
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
.unwrap_or_else(default_session_path),
|
||||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
api_mode,
|
||||||
.map(PathBuf::from)
|
api_key: nearai_api_key,
|
||||||
.unwrap_or_else(default_session_path),
|
};
|
||||||
api_mode,
|
|
||||||
|
// Resolve provider-specific configs based on backend
|
||||||
|
let openai = if backend == LlmBackend::OpenAi {
|
||||||
|
let api_key = optional_env("OPENAI_API_KEY")?
|
||||||
|
.map(SecretString::from)
|
||||||
|
.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
|
key: "OPENAI_API_KEY".to_string(),
|
||||||
|
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
||||||
|
})?;
|
||||||
|
let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string());
|
||||||
|
Some(OpenAiDirectConfig { api_key, model })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let anthropic = if backend == LlmBackend::Anthropic {
|
||||||
|
let api_key = optional_env("ANTHROPIC_API_KEY")?
|
||||||
|
.map(SecretString::from)
|
||||||
|
.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
|
key: "ANTHROPIC_API_KEY".to_string(),
|
||||||
|
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
||||||
|
})?;
|
||||||
|
let model = optional_env("ANTHROPIC_MODEL")?
|
||||||
|
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||||
|
Some(AnthropicDirectConfig { api_key, model })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let ollama = if backend == LlmBackend::Ollama {
|
||||||
|
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||||
|
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||||
|
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||||
|
Some(OllamaConfig { base_url, model })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||||
|
let base_url =
|
||||||
|
optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
|
key: "LLM_BASE_URL".to_string(),
|
||||||
|
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||||
|
})?;
|
||||||
|
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
||||||
|
let model = optional_env("LLM_MODEL")?.unwrap_or_else(|| "default".to_string());
|
||||||
|
Some(OpenAiCompatibleConfig {
|
||||||
|
base_url,
|
||||||
api_key,
|
api_key,
|
||||||
},
|
model,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
backend,
|
||||||
|
nearai,
|
||||||
|
openai,
|
||||||
|
anthropic,
|
||||||
|
ollama,
|
||||||
|
openai_compatible,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,8 +433,6 @@ pub struct EmbeddingsConfig {
|
|||||||
/// OpenAI API key (for OpenAI provider).
|
/// OpenAI API key (for OpenAI provider).
|
||||||
pub openai_api_key: Option<SecretString>,
|
pub openai_api_key: Option<SecretString>,
|
||||||
/// Model to use for embeddings.
|
/// Model to use for embeddings.
|
||||||
/// For OpenAI: "text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"
|
|
||||||
/// For NEAR AI: Uses the configured session for auth.
|
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,18 +448,15 @@ impl Default for EmbeddingsConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EmbeddingsConfig {
|
impl EmbeddingsConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
||||||
|
|
||||||
// Priority: env var > settings > default
|
|
||||||
let provider = optional_env("EMBEDDING_PROVIDER")?
|
let provider = optional_env("EMBEDDING_PROVIDER")?
|
||||||
.unwrap_or_else(|| settings.embeddings.provider.clone());
|
.unwrap_or_else(|| settings.embeddings.provider.clone());
|
||||||
|
|
||||||
let model =
|
let model =
|
||||||
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
|
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
|
||||||
|
|
||||||
// Priority: env var > settings > auto-detect from API key
|
|
||||||
let enabled = optional_env("EMBEDDING_ENABLED")?
|
let enabled = optional_env("EMBEDDING_ENABLED")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -301,10 +464,7 @@ impl EmbeddingsConfig {
|
|||||||
key: "EMBEDDING_ENABLED".to_string(),
|
key: "EMBEDDING_ENABLED".to_string(),
|
||||||
message: format!("must be 'true' or 'false': {e}"),
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
})?
|
})?
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| settings.embeddings.enabled || openai_api_key.is_some());
|
||||||
// Check settings, or auto-enable if API key present
|
|
||||||
settings.embeddings.enabled || openai_api_key.is_some()
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enabled,
|
enabled,
|
||||||
@@ -333,10 +493,13 @@ fn default_session_path() -> PathBuf {
|
|||||||
pub struct ChannelsConfig {
|
pub struct ChannelsConfig {
|
||||||
pub cli: CliConfig,
|
pub cli: CliConfig,
|
||||||
pub http: Option<HttpConfig>,
|
pub http: Option<HttpConfig>,
|
||||||
|
pub gateway: Option<GatewayConfig>,
|
||||||
/// Directory containing WASM channel modules (default: ~/.ironclaw/channels/).
|
/// Directory containing WASM channel modules (default: ~/.ironclaw/channels/).
|
||||||
pub wasm_channels_dir: std::path::PathBuf,
|
pub wasm_channels_dir: std::path::PathBuf,
|
||||||
/// Whether WASM channels are enabled.
|
/// Whether WASM channels are enabled.
|
||||||
pub wasm_channels_enabled: bool,
|
pub wasm_channels_enabled: bool,
|
||||||
|
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||||
|
pub telegram_owner_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -352,8 +515,18 @@ pub struct HttpConfig {
|
|||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Web gateway configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GatewayConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// Bearer token for authentication. Random hex generated at startup if unset.
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl ChannelsConfig {
|
impl ChannelsConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
|
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
|
||||||
Some(HttpConfig {
|
Some(HttpConfig {
|
||||||
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
|
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
|
||||||
@@ -372,6 +545,27 @@ impl ChannelsConfig {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let gateway = if optional_env("GATEWAY_ENABLED")?
|
||||||
|
.map(|s| s.to_lowercase() == "true" || s == "1")
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
Some(GatewayConfig {
|
||||||
|
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||||
|
port: optional_env("GATEWAY_PORT")?
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_PORT".to_string(),
|
||||||
|
message: format!("must be a valid port number: {e}"),
|
||||||
|
})?
|
||||||
|
.unwrap_or(3000),
|
||||||
|
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?,
|
||||||
|
user_id: optional_env("GATEWAY_USER_ID")?.unwrap_or_else(|| "default".to_string()),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let cli_enabled = optional_env("CLI_ENABLED")?
|
let cli_enabled = optional_env("CLI_ENABLED")?
|
||||||
.map(|s| s.to_lowercase() != "false" && s != "0")
|
.map(|s| s.to_lowercase() != "false" && s != "0")
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
@@ -381,6 +575,7 @@ impl ChannelsConfig {
|
|||||||
enabled: cli_enabled,
|
enabled: cli_enabled,
|
||||||
},
|
},
|
||||||
http,
|
http,
|
||||||
|
gateway,
|
||||||
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(default_channels_dir),
|
.unwrap_or_else(default_channels_dir),
|
||||||
@@ -392,6 +587,14 @@ impl ChannelsConfig {
|
|||||||
message: format!("must be 'true' or 'false': {e}"),
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
})?
|
})?
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||||
|
message: format!("must be an integer: {e}"),
|
||||||
|
})?
|
||||||
|
.or(settings.channels.telegram_owner_id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,14 +620,13 @@ pub struct AgentConfig {
|
|||||||
pub use_planning: bool,
|
pub use_planning: bool,
|
||||||
/// Session idle timeout. Sessions inactive longer than this are pruned.
|
/// Session idle timeout. Sessions inactive longer than this are pruned.
|
||||||
pub session_idle_timeout: Duration,
|
pub session_idle_timeout: Duration,
|
||||||
|
/// Allow chat to use filesystem/shell tools directly (bypass sandbox).
|
||||||
|
pub allow_local_tools: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
// Priority: env var > settings > default
|
|
||||||
name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()),
|
name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()),
|
||||||
max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")?
|
max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
@@ -490,6 +692,14 @@ impl AgentConfig {
|
|||||||
})?
|
})?
|
||||||
.unwrap_or(settings.agent.session_idle_timeout_secs),
|
.unwrap_or(settings.agent.session_idle_timeout_secs),
|
||||||
),
|
),
|
||||||
|
allow_local_tools: optional_env("ALLOW_LOCAL_TOOLS")?
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "ALLOW_LOCAL_TOOLS".to_string(),
|
||||||
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
|
})?
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +712,7 @@ pub struct SafetyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SafetyConfig {
|
impl SafetyConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?,
|
max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?,
|
||||||
injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")?
|
injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")?
|
||||||
@@ -540,7 +750,6 @@ pub struct WasmConfig {
|
|||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct SecretsConfig {
|
pub struct SecretsConfig {
|
||||||
/// Master key for encrypting secrets.
|
/// Master key for encrypting secrets.
|
||||||
/// Source determined by KeySource in settings.
|
|
||||||
pub master_key: Option<SecretString>,
|
pub master_key: Option<SecretString>,
|
||||||
/// Whether secrets management is enabled.
|
/// Whether secrets management is enabled.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -559,20 +768,16 @@ impl std::fmt::Debug for SecretsConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SecretsConfig {
|
impl SecretsConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
|
||||||
use crate::settings::KeySource;
|
use crate::settings::KeySource;
|
||||||
|
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
|
|
||||||
// Priority: env var > keychain (based on settings) > disabled
|
|
||||||
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
|
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
|
||||||
// Env var takes priority (for CI/Docker)
|
|
||||||
(Some(SecretString::from(env_key)), KeySource::Env)
|
(Some(SecretString::from(env_key)), KeySource::Env)
|
||||||
} else {
|
} else {
|
||||||
match settings.secrets_master_key_source {
|
match bootstrap.secrets_master_key_source {
|
||||||
KeySource::Keychain => {
|
KeySource::Keychain => {
|
||||||
// Try to load from OS keychain
|
// Try to load from OS keychain (async on Linux)
|
||||||
match crate::secrets::keychain::get_master_key() {
|
match crate::secrets::keychain::get_master_key().await {
|
||||||
Ok(key_bytes) => {
|
Ok(key_bytes) => {
|
||||||
let key_hex: String =
|
let key_hex: String =
|
||||||
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||||
@@ -583,14 +788,13 @@ impl SecretsConfig {
|
|||||||
// This might happen if keychain was cleared
|
// This might happen if keychain was cleared
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Secrets configured for keychain but key not found. \
|
"Secrets configured for keychain but key not found. \
|
||||||
Run 'ironclaw setup' to reconfigure."
|
Run 'ironclaw onboard' to reconfigure."
|
||||||
);
|
);
|
||||||
(None, KeySource::None)
|
(None, KeySource::None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeySource::Env => {
|
KeySource::Env => {
|
||||||
// Settings say env, but no env var found
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
|
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
|
||||||
);
|
);
|
||||||
@@ -602,7 +806,6 @@ impl SecretsConfig {
|
|||||||
|
|
||||||
let enabled = master_key.is_some();
|
let enabled = master_key.is_some();
|
||||||
|
|
||||||
// Validate master key length if provided
|
|
||||||
if let Some(ref key) = master_key {
|
if let Some(ref key) = master_key {
|
||||||
if key.expose_secret().len() < 32 {
|
if key.expose_secret().len() < 32 {
|
||||||
return Err(ConfigError::InvalidValue {
|
return Err(ConfigError::InvalidValue {
|
||||||
@@ -648,7 +851,7 @@ fn default_tools_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WasmConfig {
|
impl WasmConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enabled: optional_env("WASM_ENABLED")?
|
enabled: optional_env("WASM_ENABLED")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
@@ -719,7 +922,7 @@ pub struct BuilderModeConfig {
|
|||||||
impl Default for BuilderModeConfig {
|
impl Default for BuilderModeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true, // Builder enabled by default
|
enabled: true,
|
||||||
build_dir: None,
|
build_dir: None,
|
||||||
max_iterations: 20,
|
max_iterations: 20,
|
||||||
timeout_secs: 600,
|
timeout_secs: 600,
|
||||||
@@ -729,7 +932,7 @@ impl Default for BuilderModeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BuilderModeConfig {
|
impl BuilderModeConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enabled: optional_env("BUILDER_ENABLED")?
|
enabled: optional_env("BUILDER_ENABLED")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
@@ -738,7 +941,7 @@ impl BuilderModeConfig {
|
|||||||
key: "BUILDER_ENABLED".to_string(),
|
key: "BUILDER_ENABLED".to_string(),
|
||||||
message: format!("must be 'true' or 'false': {e}"),
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
})?
|
})?
|
||||||
.unwrap_or(true), // Builder enabled by default
|
.unwrap_or(true),
|
||||||
build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from),
|
build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from),
|
||||||
max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?,
|
max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?,
|
||||||
timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?,
|
timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?,
|
||||||
@@ -793,11 +996,8 @@ impl Default for HeartbeatConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HeartbeatConfig {
|
impl HeartbeatConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
let settings = crate::settings::Settings::load();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
// Priority: env var > settings > default
|
|
||||||
enabled: optional_env("HEARTBEAT_ENABLED")?
|
enabled: optional_env("HEARTBEAT_ENABLED")?
|
||||||
.map(|s| s.parse())
|
.map(|s| s.parse())
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -815,9 +1015,55 @@ impl HeartbeatConfig {
|
|||||||
})?
|
})?
|
||||||
.unwrap_or(settings.heartbeat.interval_secs),
|
.unwrap_or(settings.heartbeat.interval_secs),
|
||||||
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?
|
||||||
.or(settings.heartbeat.notify_channel.clone()),
|
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
||||||
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
||||||
.or(settings.heartbeat.notify_user.clone()),
|
.or_else(|| settings.heartbeat.notify_user.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routines configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RoutineConfig {
|
||||||
|
/// Whether the routines system is enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// How often (seconds) to poll for cron routines that need firing.
|
||||||
|
pub cron_check_interval_secs: u64,
|
||||||
|
/// Max routines executing concurrently across all users.
|
||||||
|
pub max_concurrent_routines: usize,
|
||||||
|
/// Default cooldown between fires (seconds).
|
||||||
|
pub default_cooldown_secs: u64,
|
||||||
|
/// Max output tokens for lightweight routine LLM calls.
|
||||||
|
pub max_lightweight_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RoutineConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
cron_check_interval_secs: 15,
|
||||||
|
max_concurrent_routines: 10,
|
||||||
|
default_cooldown_secs: 300,
|
||||||
|
max_lightweight_tokens: 4096,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutineConfig {
|
||||||
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
|
Ok(Self {
|
||||||
|
enabled: optional_env("ROUTINES_ENABLED")?
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "ROUTINES_ENABLED".to_string(),
|
||||||
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
|
})?
|
||||||
|
.unwrap_or(true),
|
||||||
|
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
||||||
|
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
||||||
|
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
||||||
|
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,7 +1092,7 @@ pub struct SandboxModeConfig {
|
|||||||
impl Default for SandboxModeConfig {
|
impl Default for SandboxModeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true, // Enabled by default
|
enabled: true,
|
||||||
policy: "readonly".to_string(),
|
policy: "readonly".to_string(),
|
||||||
timeout_secs: 120,
|
timeout_secs: 120,
|
||||||
memory_limit_mb: 2048,
|
memory_limit_mb: 2048,
|
||||||
@@ -859,7 +1105,7 @@ impl Default for SandboxModeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SandboxModeConfig {
|
impl SandboxModeConfig {
|
||||||
fn from_env() -> Result<Self, ConfigError> {
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")?
|
let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")?
|
||||||
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -915,6 +1161,60 @@ impl SandboxModeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude Code sandbox configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClaudeCodeConfig {
|
||||||
|
/// Whether Claude Code sandbox mode is available.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Host directory containing Claude auth session (mounted read-only).
|
||||||
|
pub config_dir: std::path::PathBuf,
|
||||||
|
/// Claude model to use (e.g. "sonnet", "opus").
|
||||||
|
pub model: String,
|
||||||
|
/// Maximum agentic turns before stopping.
|
||||||
|
pub max_turns: u32,
|
||||||
|
/// Memory limit in MB for Claude Code containers (heavier than workers).
|
||||||
|
pub memory_limit_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClaudeCodeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
config_dir: dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".claude"),
|
||||||
|
model: "sonnet".to_string(),
|
||||||
|
max_turns: 50,
|
||||||
|
memory_limit_mb: 4096,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClaudeCodeConfig {
|
||||||
|
fn resolve() -> Result<Self, ConfigError> {
|
||||||
|
let defaults = Self::default();
|
||||||
|
Ok(Self {
|
||||||
|
enabled: optional_env("CLAUDE_CODE_ENABLED")?
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "CLAUDE_CODE_ENABLED".to_string(),
|
||||||
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
|
})?
|
||||||
|
.unwrap_or(defaults.enabled),
|
||||||
|
config_dir: optional_env("CLAUDE_CONFIG_DIR")?
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or(defaults.config_dir),
|
||||||
|
model: optional_env("CLAUDE_CODE_MODEL")?.unwrap_or(defaults.model),
|
||||||
|
max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?,
|
||||||
|
memory_limit_mb: parse_optional_env(
|
||||||
|
"CLAUDE_CODE_MEMORY_LIMIT_MB",
|
||||||
|
defaults.memory_limit_mb,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Workspace error: {0}")]
|
#[error("Workspace error: {0}")]
|
||||||
Workspace(#[from] WorkspaceError),
|
Workspace(#[from] WorkspaceError),
|
||||||
|
|
||||||
|
#[error("Orchestrator error: {0}")]
|
||||||
|
Orchestrator(#[from] OrchestratorError),
|
||||||
|
|
||||||
|
#[error("Worker error: {0}")]
|
||||||
|
Worker(#[from] WorkerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration-related errors.
|
/// Configuration-related errors.
|
||||||
@@ -308,5 +314,52 @@ pub enum WorkspaceError {
|
|||||||
HeartbeatError { reason: String },
|
HeartbeatError { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orchestrator errors (internal API, container management).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum OrchestratorError {
|
||||||
|
#[error("Container creation failed for job {job_id}: {reason}")]
|
||||||
|
ContainerCreationFailed { job_id: Uuid, reason: String },
|
||||||
|
|
||||||
|
#[error("Container not found for job {job_id}")]
|
||||||
|
ContainerNotFound { job_id: Uuid },
|
||||||
|
|
||||||
|
#[error("Container for job {job_id} is in unexpected state: {state}")]
|
||||||
|
InvalidContainerState { job_id: Uuid, state: String },
|
||||||
|
|
||||||
|
#[error("Worker authentication failed: {reason}")]
|
||||||
|
AuthFailed { reason: String },
|
||||||
|
|
||||||
|
#[error("Internal API error: {reason}")]
|
||||||
|
ApiError { reason: String },
|
||||||
|
|
||||||
|
#[error("Docker error: {reason}")]
|
||||||
|
Docker { reason: String },
|
||||||
|
|
||||||
|
#[error("Job {job_id} timed out in container")]
|
||||||
|
ContainerTimeout { job_id: Uuid },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Worker errors (container-side execution).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WorkerError {
|
||||||
|
#[error("Failed to connect to orchestrator at {url}: {reason}")]
|
||||||
|
ConnectionFailed { url: String, reason: String },
|
||||||
|
|
||||||
|
#[error("LLM proxy request failed: {reason}")]
|
||||||
|
LlmProxyFailed { reason: String },
|
||||||
|
|
||||||
|
#[error("Secret resolution failed for {secret_name}: {reason}")]
|
||||||
|
SecretResolveFailed { secret_name: String, reason: String },
|
||||||
|
|
||||||
|
#[error("Orchestrator returned error for job {job_id}: {reason}")]
|
||||||
|
OrchestratorRejected { job_id: Uuid, reason: String },
|
||||||
|
|
||||||
|
#[error("Worker execution failed: {reason}")]
|
||||||
|
ExecutionFailed { reason: String },
|
||||||
|
|
||||||
|
#[error("Missing worker token (IRONCLAW_WORKER_TOKEN not set)")]
|
||||||
|
MissingToken,
|
||||||
|
}
|
||||||
|
|
||||||
/// Result type alias for the agent.
|
/// Result type alias for the agent.
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|||||||
+128
-15
@@ -23,9 +23,7 @@ use crate::tools::mcp::auth::{
|
|||||||
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
||||||
find_available_port, is_authenticated, register_client,
|
find_available_port, is_authenticated, register_client,
|
||||||
};
|
};
|
||||||
use crate::tools::mcp::config::{
|
use crate::tools::mcp::config::McpServerConfig;
|
||||||
McpServerConfig, add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server,
|
|
||||||
};
|
|
||||||
use crate::tools::mcp::session::McpSessionManager;
|
use crate::tools::mcp::session::McpSessionManager;
|
||||||
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
||||||
|
|
||||||
@@ -58,6 +56,8 @@ pub struct ExtensionManager {
|
|||||||
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
|
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
|
||||||
_tunnel_url: Option<String>,
|
_tunnel_url: Option<String>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
/// Optional database store for DB-backed MCP config.
|
||||||
|
store: Option<Arc<crate::history::Store>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionManager {
|
impl ExtensionManager {
|
||||||
@@ -71,6 +71,7 @@ impl ExtensionManager {
|
|||||||
wasm_channels_dir: PathBuf,
|
wasm_channels_dir: PathBuf,
|
||||||
tunnel_url: Option<String>,
|
tunnel_url: Option<String>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
store: Option<Arc<crate::history::Store>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry: ExtensionRegistry::new(),
|
registry: ExtensionRegistry::new(),
|
||||||
@@ -85,6 +86,7 @@ impl ExtensionManager {
|
|||||||
pending_auth: RwLock::new(HashMap::new()),
|
pending_auth: RwLock::new(HashMap::new()),
|
||||||
_tunnel_url: tunnel_url,
|
_tunnel_url: tunnel_url,
|
||||||
user_id,
|
user_id,
|
||||||
|
store,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
// List MCP servers
|
// List MCP servers
|
||||||
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
||||||
match load_mcp_servers().await {
|
match self.load_mcp_servers().await {
|
||||||
Ok(servers) => {
|
Ok(servers) => {
|
||||||
for server in &servers.servers {
|
for server in &servers.servers {
|
||||||
let authenticated =
|
let authenticated =
|
||||||
@@ -215,6 +217,7 @@ impl ExtensionManager {
|
|||||||
name: server.name.clone(),
|
name: server.name.clone(),
|
||||||
kind: ExtensionKind::McpServer,
|
kind: ExtensionKind::McpServer,
|
||||||
description: server.description.clone(),
|
description: server.description.clone(),
|
||||||
|
url: Some(server.url.clone()),
|
||||||
authenticated,
|
authenticated,
|
||||||
active,
|
active,
|
||||||
tools,
|
tools,
|
||||||
@@ -240,6 +243,7 @@ impl ExtensionManager {
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
kind: ExtensionKind::WasmTool,
|
kind: ExtensionKind::WasmTool,
|
||||||
description: None,
|
description: None,
|
||||||
|
url: None,
|
||||||
authenticated: true, // WASM tools don't always need auth
|
authenticated: true, // WASM tools don't always need auth
|
||||||
active,
|
active,
|
||||||
tools: if active { vec![name] } else { Vec::new() },
|
tools: if active { vec![name] } else { Vec::new() },
|
||||||
@@ -263,6 +267,7 @@ impl ExtensionManager {
|
|||||||
name,
|
name,
|
||||||
kind: ExtensionKind::WasmChannel,
|
kind: ExtensionKind::WasmChannel,
|
||||||
description: None,
|
description: None,
|
||||||
|
url: None,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
active: true, // If loaded at startup, they're active
|
active: true, // If loaded at startup, they're active
|
||||||
tools: Vec::new(),
|
tools: Vec::new(),
|
||||||
@@ -301,7 +306,7 @@ impl ExtensionManager {
|
|||||||
self.mcp_clients.write().await.remove(name);
|
self.mcp_clients.write().await.remove(name);
|
||||||
|
|
||||||
// Remove from config
|
// Remove from config
|
||||||
remove_mcp_server(name)
|
self.remove_mcp_server(name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||||
|
|
||||||
@@ -339,6 +344,54 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MCP config helpers (DB with disk fallback) ─────────────────────
|
||||||
|
|
||||||
|
async fn load_mcp_servers(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
|
||||||
|
{
|
||||||
|
if let Some(ref store) = self.store {
|
||||||
|
crate::tools::mcp::config::load_mcp_servers_from_db(store, &self.user_id).await
|
||||||
|
} else {
|
||||||
|
crate::tools::mcp::config::load_mcp_servers().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_mcp_server(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
|
||||||
|
let servers = self.load_mcp_servers().await?;
|
||||||
|
servers.get(name).cloned().ok_or_else(|| {
|
||||||
|
crate::tools::mcp::config::ConfigError::ServerNotFound {
|
||||||
|
name: name.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_mcp_server(
|
||||||
|
&self,
|
||||||
|
config: McpServerConfig,
|
||||||
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
||||||
|
config.validate()?;
|
||||||
|
if let Some(ref store) = self.store {
|
||||||
|
crate::tools::mcp::config::add_mcp_server_db(store, &self.user_id, config).await
|
||||||
|
} else {
|
||||||
|
crate::tools::mcp::config::add_mcp_server(config).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_mcp_server(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
||||||
|
if let Some(ref store) = self.store {
|
||||||
|
crate::tools::mcp::config::remove_mcp_server_db(store, &self.user_id, name).await
|
||||||
|
} else {
|
||||||
|
crate::tools::mcp::config::remove_mcp_server(name).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────────
|
// ── Private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn install_from_entry(
|
async fn install_from_entry(
|
||||||
@@ -378,7 +431,7 @@ impl ExtensionManager {
|
|||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<InstallResult, ExtensionError> {
|
) -> Result<InstallResult, ExtensionError> {
|
||||||
// Check if already installed
|
// Check if already installed
|
||||||
if get_mcp_server(name).await.is_ok() {
|
if self.get_mcp_server(name).await.is_ok() {
|
||||||
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +440,7 @@ impl ExtensionManager {
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
||||||
|
|
||||||
add_mcp_server(config)
|
self.add_mcp_server(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||||
|
|
||||||
@@ -460,12 +513,36 @@ impl ExtensionManager {
|
|||||||
async fn auth_mcp(
|
async fn auth_mcp(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
_token: Option<&str>,
|
token: Option<&str>,
|
||||||
) -> Result<AuthResult, ExtensionError> {
|
) -> Result<AuthResult, ExtensionError> {
|
||||||
let server = get_mcp_server(name)
|
let server = self
|
||||||
|
.get_mcp_server(name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||||
|
|
||||||
|
// If a token was provided directly, store it and we're done.
|
||||||
|
if let Some(token_value) = token {
|
||||||
|
let secret_name = server.token_secret_name();
|
||||||
|
let params =
|
||||||
|
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
|
||||||
|
self.secrets
|
||||||
|
.create(&self.user_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("MCP server '{}' authenticated via manual token", name);
|
||||||
|
return Ok(AuthResult {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
auth_url: None,
|
||||||
|
callback_type: None,
|
||||||
|
instructions: None,
|
||||||
|
setup_url: None,
|
||||||
|
awaiting_token: false,
|
||||||
|
status: "authenticated".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
if is_authenticated(&server, &self.secrets, &self.user_id).await {
|
if is_authenticated(&server, &self.secrets, &self.user_id).await {
|
||||||
return Ok(AuthResult {
|
return Ok(AuthResult {
|
||||||
@@ -483,7 +560,7 @@ impl ExtensionManager {
|
|||||||
// Run the full OAuth flow (opens browser, waits for callback)
|
// Run the full OAuth flow (opens browser, waits for callback)
|
||||||
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
||||||
Ok(_token) => {
|
Ok(_token) => {
|
||||||
tracing::info!("MCP server '{}' authenticated successfully", name);
|
tracing::info!("MCP server '{}' authenticated via OAuth", name);
|
||||||
Ok(AuthResult {
|
Ok(AuthResult {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
kind: ExtensionKind::McpServer,
|
kind: ExtensionKind::McpServer,
|
||||||
@@ -496,10 +573,45 @@ impl ExtensionManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
||||||
// Server doesn't support OAuth at all, try to build a non-interactive auth URL
|
// Server doesn't support OAuth, try building a URL first
|
||||||
self.auth_mcp_build_url(name, &server).await
|
match self.auth_mcp_build_url(name, &server).await {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(_) => {
|
||||||
|
// No OAuth, no DCR: fall back to manual token entry
|
||||||
|
Ok(AuthResult {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
auth_url: None,
|
||||||
|
callback_type: None,
|
||||||
|
instructions: Some(format!(
|
||||||
|
"Server '{}' does not support OAuth. \
|
||||||
|
Please provide an API token/key for this server.",
|
||||||
|
name
|
||||||
|
)),
|
||||||
|
setup_url: None,
|
||||||
|
awaiting_token: true,
|
||||||
|
status: "awaiting_token".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// OAuth failed for some other reason, fall back to manual token
|
||||||
|
Ok(AuthResult {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
auth_url: None,
|
||||||
|
callback_type: None,
|
||||||
|
instructions: Some(format!(
|
||||||
|
"OAuth failed for '{}': {}. \
|
||||||
|
Please provide an API token/key manually.",
|
||||||
|
name, e
|
||||||
|
)),
|
||||||
|
setup_url: None,
|
||||||
|
awaiting_token: true,
|
||||||
|
status: "awaiting_token".to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) => Err(ExtensionError::AuthFailed(e.to_string())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +835,8 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = get_mcp_server(name)
|
let server = self
|
||||||
|
.get_mcp_server(name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||||
|
|
||||||
@@ -832,7 +945,7 @@ impl ExtensionManager {
|
|||||||
/// Determine what kind of installed extension this is.
|
/// Determine what kind of installed extension this is.
|
||||||
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
||||||
// Check MCP servers first
|
// Check MCP servers first
|
||||||
if get_mcp_server(name).await.is_ok() {
|
if self.get_mcp_server(name).await.is_ok() {
|
||||||
return Ok(ExtensionKind::McpServer);
|
return Ok(ExtensionKind::McpServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ pub struct InstalledExtension {
|
|||||||
pub kind: ExtensionKind,
|
pub kind: ExtensionKind,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Server or source URL (e.g. MCP server endpoint).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
pub authenticated: bool,
|
pub authenticated: bool,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
/// Tool names if active.
|
/// Tool names if active.
|
||||||
|
|||||||
+4
-1
@@ -9,4 +9,7 @@ mod analytics;
|
|||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
pub use analytics::{JobStats, ToolStats};
|
pub use analytics::{JobStats, ToolStats};
|
||||||
pub use store::{LlmCallRecord, Store};
|
pub use store::{
|
||||||
|
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
||||||
|
SandboxJobSummary, Store,
|
||||||
|
};
|
||||||
|
|||||||
+1117
-6
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -39,6 +39,7 @@
|
|||||||
//! - **Continuous learning** - Improve estimates from historical data
|
//! - **Continuous learning** - Improve estimates from historical data
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod bootstrap;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -49,13 +50,15 @@ pub mod evaluation;
|
|||||||
pub mod extensions;
|
pub mod extensions;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
pub mod orchestrator;
|
||||||
|
pub mod pairing;
|
||||||
pub mod safety;
|
pub mod safety;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod skills;
|
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod worker;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! Per-model cost lookup table for multi-provider LLM support.
|
||||||
|
//!
|
||||||
|
//! Returns (input_cost_per_token, output_cost_per_token) as Decimal pairs.
|
||||||
|
//! Ollama and other local models return zero cost.
|
||||||
|
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
/// Look up known per-token costs for a model by its identifier.
|
||||||
|
///
|
||||||
|
/// Returns `Some((input_cost, output_cost))` for known models, `None` otherwise.
|
||||||
|
pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> {
|
||||||
|
// Normalize: strip provider prefixes (e.g., "openai/gpt-4o" -> "gpt-4o")
|
||||||
|
let id = model_id
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(_, name)| name)
|
||||||
|
.unwrap_or(model_id);
|
||||||
|
|
||||||
|
match id {
|
||||||
|
// OpenAI models -- prices per token (USD)
|
||||||
|
"gpt-4o" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" => {
|
||||||
|
Some((dec!(0.0000025), dec!(0.00001)))
|
||||||
|
}
|
||||||
|
"gpt-4o-mini" | "gpt-4o-mini-2024-07-18" => Some((dec!(0.00000015), dec!(0.0000006))),
|
||||||
|
"gpt-4-turbo" | "gpt-4-turbo-2024-04-09" => Some((dec!(0.00001), dec!(0.00003))),
|
||||||
|
"gpt-4" | "gpt-4-0613" => Some((dec!(0.00003), dec!(0.00006))),
|
||||||
|
"gpt-3.5-turbo" | "gpt-3.5-turbo-0125" => Some((dec!(0.0000005), dec!(0.0000015))),
|
||||||
|
"o1" | "o1-2024-12-17" => Some((dec!(0.000015), dec!(0.00006))),
|
||||||
|
"o1-mini" | "o1-mini-2024-09-12" => Some((dec!(0.000003), dec!(0.000012))),
|
||||||
|
"o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))),
|
||||||
|
|
||||||
|
// Anthropic models
|
||||||
|
"claude-3-5-sonnet-20241022" | "claude-3-5-sonnet-latest" | "claude-sonnet-4-20250514" => {
|
||||||
|
Some((dec!(0.000003), dec!(0.000015)))
|
||||||
|
}
|
||||||
|
"claude-3-5-haiku-20241022" | "claude-3-5-haiku-latest" => {
|
||||||
|
Some((dec!(0.0000008), dec!(0.000004)))
|
||||||
|
}
|
||||||
|
"claude-3-opus-20240229" | "claude-3-opus-latest" | "claude-opus-4-20250514" => {
|
||||||
|
Some((dec!(0.000015), dec!(0.000075)))
|
||||||
|
}
|
||||||
|
"claude-3-haiku-20240307" => Some((dec!(0.00000025), dec!(0.00000125))),
|
||||||
|
|
||||||
|
// Ollama / local models -- free
|
||||||
|
_ if is_local_model(id) => Some((Decimal::ZERO, Decimal::ZERO)),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default cost for unknown models.
|
||||||
|
pub fn default_cost() -> (Decimal, Decimal) {
|
||||||
|
// Conservative estimate: roughly GPT-4o pricing
|
||||||
|
(dec!(0.0000025), dec!(0.00001))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic to detect local/self-hosted models (Ollama, llama.cpp, etc.).
|
||||||
|
fn is_local_model(model_id: &str) -> bool {
|
||||||
|
let lower = model_id.to_lowercase();
|
||||||
|
lower.starts_with("llama")
|
||||||
|
|| lower.starts_with("mistral")
|
||||||
|
|| lower.starts_with("mixtral")
|
||||||
|
|| lower.starts_with("phi")
|
||||||
|
|| lower.starts_with("gemma")
|
||||||
|
|| lower.starts_with("qwen")
|
||||||
|
|| lower.starts_with("codellama")
|
||||||
|
|| lower.starts_with("deepseek")
|
||||||
|
|| lower.starts_with("starcoder")
|
||||||
|
|| lower.starts_with("vicuna")
|
||||||
|
|| lower.starts_with("yi")
|
||||||
|
|| lower.contains(":latest")
|
||||||
|
|| lower.contains(":instruct")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_model_costs() {
|
||||||
|
let (input, output) = model_cost("gpt-4o").unwrap();
|
||||||
|
assert!(input > Decimal::ZERO);
|
||||||
|
assert!(output > input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_costs() {
|
||||||
|
let (input, output) = model_cost("claude-3-5-sonnet-20241022").unwrap();
|
||||||
|
assert!(input > Decimal::ZERO);
|
||||||
|
assert!(output > input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_local_model_free() {
|
||||||
|
let (input, output) = model_cost("llama3").unwrap();
|
||||||
|
assert_eq!(input, Decimal::ZERO);
|
||||||
|
assert_eq!(output, Decimal::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ollama_tagged_model_free() {
|
||||||
|
let (input, output) = model_cost("mistral:latest").unwrap();
|
||||||
|
assert_eq!(input, Decimal::ZERO);
|
||||||
|
assert_eq!(output, Decimal::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_model_returns_none() {
|
||||||
|
assert!(model_cost("some-totally-unknown-model-xyz").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_cost_nonzero() {
|
||||||
|
let (input, output) = default_cost();
|
||||||
|
assert!(input > Decimal::ZERO);
|
||||||
|
assert!(output > Decimal::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_prefix_stripped() {
|
||||||
|
// "openai/gpt-4o" should resolve to same as "gpt-4o"
|
||||||
|
assert_eq!(model_cost("openai/gpt-4o"), model_cost("gpt-4o"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-10
@@ -1,48 +1,172 @@
|
|||||||
//! LLM integration for the agent.
|
//! LLM integration for the agent.
|
||||||
//!
|
//!
|
||||||
//! Supports two API modes:
|
//! Supports multiple backends:
|
||||||
//! - **Responses API** (chat-api): Session-based auth, uses `/v1/responses` endpoint
|
//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy
|
||||||
//! - **Chat Completions API** (cloud-api): API key auth, uses `/v1/chat/completions` endpoint
|
//! - **OpenAI**: Direct API access with your own key
|
||||||
|
//! - **Anthropic**: Direct API access with your own key
|
||||||
|
//! - **Ollama**: Local model inference
|
||||||
|
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
|
||||||
|
|
||||||
|
mod costs;
|
||||||
mod nearai;
|
mod nearai;
|
||||||
mod nearai_chat;
|
mod nearai_chat;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod reasoning;
|
mod reasoning;
|
||||||
|
mod rig_adapter;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
pub use nearai::{ModelInfo, NearAiProvider};
|
pub use nearai::{ModelInfo, NearAiProvider};
|
||||||
pub use nearai_chat::NearAiChatProvider;
|
pub use nearai_chat::NearAiChatProvider;
|
||||||
pub use provider::{
|
pub use provider::{
|
||||||
ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall,
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
||||||
ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
|
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
|
||||||
};
|
};
|
||||||
pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection};
|
pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection};
|
||||||
|
pub use rig_adapter::RigAdapter;
|
||||||
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{LlmConfig, NearAiApiMode};
|
use rig::client::CompletionClient;
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
|
use crate::config::{LlmBackend, LlmConfig, NearAiApiMode};
|
||||||
use crate::error::LlmError;
|
use crate::error::LlmError;
|
||||||
|
|
||||||
/// Create an LLM provider based on configuration.
|
/// Create an LLM provider based on configuration.
|
||||||
///
|
///
|
||||||
/// - For `Responses` mode: Requires a session manager for authentication
|
/// - `NearAi` backend: Uses session manager for authentication (Responses API)
|
||||||
/// - For `ChatCompletions` mode: Uses API key from config (session not needed)
|
/// or API key (Chat Completions API)
|
||||||
|
/// - Other backends: Use rig-core adapter with provider-specific clients
|
||||||
pub fn create_llm_provider(
|
pub fn create_llm_provider(
|
||||||
config: &LlmConfig,
|
config: &LlmConfig,
|
||||||
session: Arc<SessionManager>,
|
session: Arc<SessionManager>,
|
||||||
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
match config.backend {
|
||||||
|
LlmBackend::NearAi => create_nearai_provider(config, session),
|
||||||
|
LlmBackend::OpenAi => create_openai_provider(config),
|
||||||
|
LlmBackend::Anthropic => create_anthropic_provider(config),
|
||||||
|
LlmBackend::Ollama => create_ollama_provider(config),
|
||||||
|
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_nearai_provider(
|
||||||
|
config: &LlmConfig,
|
||||||
|
session: Arc<SessionManager>,
|
||||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
match config.nearai.api_mode {
|
match config.nearai.api_mode {
|
||||||
NearAiApiMode::Responses => {
|
NearAiApiMode::Responses => {
|
||||||
tracing::info!("Using Responses API (chat-api) with session auth");
|
tracing::info!("Using NEAR AI Responses API (chat-api) with session auth");
|
||||||
Ok(Arc::new(NearAiProvider::new(
|
Ok(Arc::new(NearAiProvider::new(
|
||||||
config.nearai.clone(),
|
config.nearai.clone(),
|
||||||
session,
|
session,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
NearAiApiMode::ChatCompletions => {
|
NearAiApiMode::ChatCompletions => {
|
||||||
tracing::info!("Using Chat Completions API (cloud-api) with API key auth");
|
tracing::info!("Using NEAR AI Chat Completions API (cloud-api) with API key auth");
|
||||||
Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?))
|
Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
use rig::providers::openai;
|
||||||
|
|
||||||
|
let client: openai::Client =
|
||||||
|
openai::Client::new(oai.api_key.expose_secret()).map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
reason: format!("Failed to create OpenAI client: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let model = client.completion_model(&oai.model);
|
||||||
|
tracing::info!("Using OpenAI direct API (model: {})", oai.model);
|
||||||
|
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let anth = config
|
||||||
|
.anthropic
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
use rig::providers::anthropic;
|
||||||
|
|
||||||
|
let client: anthropic::Client =
|
||||||
|
anthropic::Client::new(anth.api_key.expose_secret()).map_err(|e| {
|
||||||
|
LlmError::RequestFailed {
|
||||||
|
provider: "anthropic".to_string(),
|
||||||
|
reason: format!("Failed to create Anthropic client: {}", e),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let model = client.completion_model(&anth.model);
|
||||||
|
tracing::info!("Using Anthropic direct API (model: {})", anth.model);
|
||||||
|
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_ollama_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "ollama".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
use rig::client::Nothing;
|
||||||
|
use rig::providers::ollama;
|
||||||
|
|
||||||
|
let client: ollama::Client = ollama::Client::builder()
|
||||||
|
.base_url(&oll.base_url)
|
||||||
|
.api_key(Nothing)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "ollama".to_string(),
|
||||||
|
reason: format!("Failed to create Ollama client: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let model = client.completion_model(&oll.model);
|
||||||
|
tracing::info!(
|
||||||
|
"Using Ollama (base_url: {}, model: {})",
|
||||||
|
oll.base_url,
|
||||||
|
oll.model
|
||||||
|
);
|
||||||
|
Ok(Arc::new(RigAdapter::new(model, &oll.model)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||||
|
let compat = config
|
||||||
|
.openai_compatible
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| LlmError::AuthFailed {
|
||||||
|
provider: "openai_compatible".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
use rig::providers::openai;
|
||||||
|
|
||||||
|
let api_key = compat
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| k.expose_secret().to_string())
|
||||||
|
.unwrap_or_else(|| "no-key".to_string());
|
||||||
|
|
||||||
|
let client: openai::Client = openai::Client::builder()
|
||||||
|
.base_url(&compat.base_url)
|
||||||
|
.api_key(api_key)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "openai_compatible".to_string(),
|
||||||
|
reason: format!("Failed to create OpenAI-compatible client: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let model = client.completion_model(&compat.model);
|
||||||
|
tracing::info!(
|
||||||
|
"Using OpenAI-compatible endpoint (base_url: {}, model: {})",
|
||||||
|
compat.base_url,
|
||||||
|
compat.model
|
||||||
|
);
|
||||||
|
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||||
|
}
|
||||||
|
|||||||
+308
-20
@@ -3,6 +3,7 @@
|
|||||||
//! This provider uses the NEAR AI chat-api which provides a unified interface
|
//! This provider uses the NEAR AI chat-api which provides a unified interface
|
||||||
//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication.
|
//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -31,11 +32,23 @@ pub struct ModelInfo {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-thread chaining state: the last response ID and how many input
|
||||||
|
/// messages were included in that request. This lets subsequent calls send
|
||||||
|
/// only the delta (new messages since last call).
|
||||||
|
struct ChainState {
|
||||||
|
response_id: String,
|
||||||
|
input_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// NEAR AI Chat API provider.
|
/// NEAR AI Chat API provider.
|
||||||
pub struct NearAiProvider {
|
pub struct NearAiProvider {
|
||||||
client: Client,
|
client: Client,
|
||||||
config: NearAiConfig,
|
config: NearAiConfig,
|
||||||
session: Arc<SessionManager>,
|
session: Arc<SessionManager>,
|
||||||
|
active_model: std::sync::RwLock<String>,
|
||||||
|
/// Per-thread response ID chaining state.
|
||||||
|
/// Key is thread_id from request metadata.
|
||||||
|
response_chains: std::sync::RwLock<HashMap<String, ChainState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NearAiProvider {
|
impl NearAiProvider {
|
||||||
@@ -46,13 +59,64 @@ impl NearAiProvider {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new());
|
.unwrap_or_else(|_| Client::new());
|
||||||
|
|
||||||
|
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
config,
|
config,
|
||||||
session,
|
session,
|
||||||
|
active_model,
|
||||||
|
response_chains: std::sync::RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed a response chain for a thread (e.g. when restoring from DB).
|
||||||
|
pub fn seed_response_id(&self, thread_id: &str, response_id: String) {
|
||||||
|
let mut chains = self
|
||||||
|
.response_chains
|
||||||
|
.write()
|
||||||
|
.expect("response_chains lock poisoned");
|
||||||
|
chains.insert(
|
||||||
|
thread_id.to_string(),
|
||||||
|
ChainState {
|
||||||
|
response_id,
|
||||||
|
input_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the last response ID for a thread (for persistence).
|
||||||
|
pub fn get_response_id(&self, thread_id: &str) -> Option<String> {
|
||||||
|
let chains = self
|
||||||
|
.response_chains
|
||||||
|
.read()
|
||||||
|
.expect("response_chains lock poisoned");
|
||||||
|
chains.get(thread_id).map(|c| c.response_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a response chain state after a successful call.
|
||||||
|
fn store_chain(&self, thread_id: &str, response_id: String, input_count: usize) {
|
||||||
|
let mut chains = self
|
||||||
|
.response_chains
|
||||||
|
.write()
|
||||||
|
.expect("response_chains lock poisoned");
|
||||||
|
chains.insert(
|
||||||
|
thread_id.to_string(),
|
||||||
|
ChainState {
|
||||||
|
response_id,
|
||||||
|
input_count,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the chain for a thread (on error / fallback).
|
||||||
|
fn clear_chain(&self, thread_id: &str) {
|
||||||
|
let mut chains = self
|
||||||
|
.response_chains
|
||||||
|
.write()
|
||||||
|
.expect("response_chains lock poisoned");
|
||||||
|
chains.remove(thread_id);
|
||||||
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/v1/{}",
|
"{}/v1/{}",
|
||||||
@@ -291,18 +355,34 @@ impl NearAiProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split messages into system instructions and non-system input messages.
|
/// Split messages into system instructions and non-system input items.
|
||||||
/// The OpenAI Responses API expects system prompts in an `instructions` field,
|
/// The OpenAI Responses API expects system prompts in an `instructions` field,
|
||||||
/// not as a message with role "system" in the input array.
|
/// not as a message with role "system" in the input array.
|
||||||
fn split_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<NearAiMessage>) {
|
///
|
||||||
|
/// When `chaining` is true, tool result messages (role=tool) are converted to
|
||||||
|
/// `NearAiInputItem::FunctionCallOutput` for the Responses API protocol.
|
||||||
|
fn split_messages(
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
chaining: bool,
|
||||||
|
) -> (Option<String>, Vec<NearAiInputItem>) {
|
||||||
let mut instructions: Vec<String> = Vec::new();
|
let mut instructions: Vec<String> = Vec::new();
|
||||||
let mut input: Vec<NearAiMessage> = Vec::new();
|
let mut input: Vec<NearAiInputItem> = Vec::new();
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
if msg.role == Role::System {
|
if msg.role == Role::System {
|
||||||
instructions.push(msg.content);
|
instructions.push(msg.content);
|
||||||
|
} else if chaining && msg.role == Role::Tool {
|
||||||
|
if let Some(ref call_id) = msg.tool_call_id {
|
||||||
|
input.push(NearAiInputItem::FunctionCallOutput {
|
||||||
|
item_type: "function_call_output".to_string(),
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
output: msg.content,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
input.push(NearAiInputItem::Message(msg.into()));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
input.push(msg.into());
|
input.push(NearAiInputItem::Message(msg.into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,12 +398,14 @@ fn split_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<NearAiMess
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for NearAiProvider {
|
impl LlmProvider for NearAiProvider {
|
||||||
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
let (instructions, input) = split_messages(req.messages);
|
let thread_id = req.metadata.get("thread_id").cloned();
|
||||||
|
let (instructions, input) = split_messages(req.messages, false);
|
||||||
|
|
||||||
let request = NearAiRequest {
|
let request = NearAiRequest {
|
||||||
model: self.config.model.clone(),
|
model: self.active_model_name(),
|
||||||
instructions,
|
instructions,
|
||||||
input,
|
input,
|
||||||
|
previous_response_id: None,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_output_tokens: req.max_tokens,
|
max_output_tokens: req.max_tokens,
|
||||||
stream: Some(false),
|
stream: Some(false),
|
||||||
@@ -350,6 +432,7 @@ impl LlmProvider for NearAiProvider {
|
|||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens,
|
||||||
output_tokens: usage.output_tokens,
|
output_tokens: usage.output_tokens,
|
||||||
|
response_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +450,7 @@ impl LlmProvider for NearAiProvider {
|
|||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
|
response_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
@@ -423,11 +507,17 @@ impl LlmProvider for NearAiProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store response ID for chaining
|
||||||
|
if let Some(ref tid) = thread_id {
|
||||||
|
self.store_chain(tid, response.id.clone(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CompletionResponse {
|
Ok(CompletionResponse {
|
||||||
content: text,
|
content: text,
|
||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
input_tokens: response.usage.input_tokens,
|
input_tokens: response.usage.input_tokens,
|
||||||
output_tokens: response.usage.output_tokens,
|
output_tokens: response.usage.output_tokens,
|
||||||
|
response_id: Some(response.id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +525,33 @@ impl LlmProvider for NearAiProvider {
|
|||||||
&self,
|
&self,
|
||||||
req: ToolCompletionRequest,
|
req: ToolCompletionRequest,
|
||||||
) -> Result<ToolCompletionResponse, LlmError> {
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
let (instructions, input) = split_messages(req.messages);
|
let thread_id = req.metadata.get("thread_id").cloned();
|
||||||
|
|
||||||
|
// Look up chaining state for this thread
|
||||||
|
let chain_state = thread_id.as_ref().and_then(|tid| {
|
||||||
|
let chains = self
|
||||||
|
.response_chains
|
||||||
|
.read()
|
||||||
|
.expect("response_chains lock poisoned");
|
||||||
|
chains
|
||||||
|
.get(tid)
|
||||||
|
.map(|c| (c.response_id.clone(), c.input_count))
|
||||||
|
});
|
||||||
|
|
||||||
|
let chaining = chain_state.is_some();
|
||||||
|
let (previous_response_id, prev_input_count) = chain_state
|
||||||
|
.map(|(rid, count)| (Some(rid), count))
|
||||||
|
.unwrap_or((None, 0));
|
||||||
|
|
||||||
|
// When chaining, only send new messages (the delta since last call).
|
||||||
|
// Tool results are converted to function_call_output items.
|
||||||
|
let (instructions, all_input) = split_messages(req.messages, chaining);
|
||||||
|
let input = if chaining && all_input.len() > prev_input_count {
|
||||||
|
all_input[prev_input_count..].to_vec()
|
||||||
|
} else {
|
||||||
|
all_input.clone()
|
||||||
|
};
|
||||||
|
let total_input_count = all_input.len();
|
||||||
|
|
||||||
let tools: Vec<NearAiTool> = req
|
let tools: Vec<NearAiTool> = req
|
||||||
.tools
|
.tools
|
||||||
@@ -449,18 +565,58 @@ impl LlmProvider for NearAiProvider {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let request = NearAiRequest {
|
let request = NearAiRequest {
|
||||||
model: self.config.model.clone(),
|
model: self.active_model_name(),
|
||||||
instructions,
|
instructions: if chaining { None } else { instructions.clone() },
|
||||||
input,
|
input,
|
||||||
|
previous_response_id: previous_response_id.clone(),
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_output_tokens: req.max_tokens,
|
max_output_tokens: req.max_tokens,
|
||||||
stream: Some(false),
|
stream: Some(false),
|
||||||
tools: if tools.is_empty() { None } else { Some(tools) },
|
tools: if tools.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tools.clone())
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to get structured response, fall back to alternative formats
|
// Try to get structured response, fall back to alternative formats.
|
||||||
|
// If chaining fails (bad previous_response_id), retry with full history.
|
||||||
let response: NearAiResponse = match self.send_request("responses", &request).await {
|
let response: NearAiResponse = match self.send_request("responses", &request).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
Err(ref e) if chaining && is_chain_error(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Response chaining failed, retrying with full history: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if let Some(ref tid) = thread_id {
|
||||||
|
self.clear_chain(tid);
|
||||||
|
}
|
||||||
|
let (instructions_full, input_full) = split_messages(
|
||||||
|
// Rebuild from the original input (non-chaining mode)
|
||||||
|
{
|
||||||
|
let mut msgs = Vec::new();
|
||||||
|
if let Some(ref instr) = instructions {
|
||||||
|
msgs.push(ChatMessage::system(instr.clone()));
|
||||||
|
}
|
||||||
|
for item in &all_input {
|
||||||
|
msgs.push(item.to_chat_message());
|
||||||
|
}
|
||||||
|
msgs
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let retry_request = NearAiRequest {
|
||||||
|
model: self.active_model_name(),
|
||||||
|
instructions: instructions_full,
|
||||||
|
input: input_full,
|
||||||
|
previous_response_id: None,
|
||||||
|
temperature: request.temperature,
|
||||||
|
max_output_tokens: request.max_output_tokens,
|
||||||
|
stream: Some(false),
|
||||||
|
tools: request.tools.clone(),
|
||||||
|
};
|
||||||
|
self.send_request("responses", &retry_request).await?
|
||||||
|
}
|
||||||
Err(LlmError::InvalidResponse { reason, .. }) if reason.contains("Raw: ") => {
|
Err(LlmError::InvalidResponse { reason, .. }) if reason.contains("Raw: ") => {
|
||||||
let raw_text = reason.split("Raw: ").nth(1).unwrap_or("");
|
let raw_text = reason.split("Raw: ").nth(1).unwrap_or("");
|
||||||
|
|
||||||
@@ -490,6 +646,7 @@ impl LlmProvider for NearAiProvider {
|
|||||||
finish_reason,
|
finish_reason,
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens,
|
||||||
output_tokens: usage.output_tokens,
|
output_tokens: usage.output_tokens,
|
||||||
|
response_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +664,7 @@ impl LlmProvider for NearAiProvider {
|
|||||||
finish_reason: FinishReason::Stop,
|
finish_reason: FinishReason::Stop,
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
|
response_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
@@ -560,12 +718,18 @@ impl LlmProvider for NearAiProvider {
|
|||||||
FinishReason::ToolUse
|
FinishReason::ToolUse
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store response ID for chaining on subsequent calls
|
||||||
|
if let Some(ref tid) = thread_id {
|
||||||
|
self.store_chain(tid, response.id.clone(), total_input_count);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(ToolCompletionResponse {
|
Ok(ToolCompletionResponse {
|
||||||
content: if text.is_empty() { None } else { Some(text) },
|
content: if text.is_empty() { None } else { Some(text) },
|
||||||
tool_calls,
|
tool_calls,
|
||||||
finish_reason,
|
finish_reason,
|
||||||
input_tokens: response.usage.input_tokens,
|
input_tokens: response.usage.input_tokens,
|
||||||
output_tokens: response.usage.output_tokens,
|
output_tokens: response.usage.output_tokens,
|
||||||
|
response_id: Some(response.id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +748,30 @@ impl LlmProvider for NearAiProvider {
|
|||||||
let models = NearAiProvider::list_models(self).await?;
|
let models = NearAiProvider::list_models(self).await?;
|
||||||
Ok(models.into_iter().map(|m| m.name).collect())
|
Ok(models.into_iter().map(|m| m.name).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_model_name(&self) -> String {
|
||||||
|
self.active_model
|
||||||
|
.read()
|
||||||
|
.expect("active_model lock poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||||
|
let mut guard = self
|
||||||
|
.active_model
|
||||||
|
.write()
|
||||||
|
.expect("active_model lock poisoned");
|
||||||
|
*guard = model.to_string();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
|
||||||
|
self.seed_response_id(thread_id, response_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
|
||||||
|
self.get_response_id(thread_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEAR AI API types
|
// NEAR AI API types
|
||||||
@@ -597,8 +785,11 @@ struct NearAiRequest {
|
|||||||
/// System instructions (replaces sending system role in input)
|
/// System instructions (replaces sending system role in input)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
/// Input messages (user/assistant/tool only, NOT system)
|
/// Input items: messages and/or function_call_output entries.
|
||||||
input: Vec<NearAiMessage>,
|
input: Vec<NearAiInputItem>,
|
||||||
|
/// Chain this request to a previous response (avoids resending full context).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
previous_response_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
temperature: Option<f32>,
|
temperature: Option<f32>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -609,7 +800,7 @@ struct NearAiRequest {
|
|||||||
tools: Option<Vec<NearAiTool>>,
|
tools: Option<Vec<NearAiTool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
struct NearAiMessage {
|
struct NearAiMessage {
|
||||||
role: String,
|
role: String,
|
||||||
content: String,
|
content: String,
|
||||||
@@ -630,7 +821,68 @@ impl From<ChatMessage> for NearAiMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
/// Input item for the Responses API. Either a regular message or a
|
||||||
|
/// function_call_output (for returning tool results when chaining).
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum NearAiInputItem {
|
||||||
|
Message(NearAiMessage),
|
||||||
|
FunctionCallOutput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
item_type: String,
|
||||||
|
call_id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NearAiInputItem {
|
||||||
|
/// Convert back to a ChatMessage (used for fallback retry).
|
||||||
|
fn to_chat_message(&self) -> ChatMessage {
|
||||||
|
match self {
|
||||||
|
NearAiInputItem::Message(msg) => {
|
||||||
|
let role = match msg.role.as_str() {
|
||||||
|
"system" => Role::System,
|
||||||
|
"user" => Role::User,
|
||||||
|
"assistant" => Role::Assistant,
|
||||||
|
"tool" => Role::Tool,
|
||||||
|
_ => Role::User,
|
||||||
|
};
|
||||||
|
ChatMessage {
|
||||||
|
role,
|
||||||
|
content: msg.content.clone(),
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
tool_calls: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NearAiInputItem::FunctionCallOutput {
|
||||||
|
call_id, output, ..
|
||||||
|
} => ChatMessage {
|
||||||
|
role: Role::Tool,
|
||||||
|
content: output.clone(),
|
||||||
|
tool_call_id: Some(call_id.clone()),
|
||||||
|
name: None,
|
||||||
|
tool_calls: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an LLM error is likely caused by an invalid previous_response_id.
|
||||||
|
fn is_chain_error(err: &LlmError) -> bool {
|
||||||
|
match err {
|
||||||
|
LlmError::RequestFailed { reason, .. } => {
|
||||||
|
let lower = reason.to_lowercase();
|
||||||
|
lower.contains("previous_response_id")
|
||||||
|
|| lower.contains("previous response")
|
||||||
|
|| lower.contains("not found")
|
||||||
|
|| lower.contains("invalid response id")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct NearAiTool {
|
struct NearAiTool {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
tool_type: String,
|
tool_type: String,
|
||||||
@@ -833,14 +1085,17 @@ mod tests {
|
|||||||
ChatMessage::user("Hello"),
|
ChatMessage::user("Hello"),
|
||||||
ChatMessage::assistant("Hi there!"),
|
ChatMessage::assistant("Hi there!"),
|
||||||
];
|
];
|
||||||
let (instructions, input) = split_messages(messages);
|
let (instructions, input) = split_messages(messages, false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
instructions,
|
instructions,
|
||||||
Some("You are a helpful assistant".to_string())
|
Some("You are a helpful assistant".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(input.len(), 2);
|
assert_eq!(input.len(), 2);
|
||||||
assert_eq!(input[0].role, "user");
|
// Verify the input items are messages
|
||||||
assert_eq!(input[1].role, "assistant");
|
match &input[0] {
|
||||||
|
NearAiInputItem::Message(m) => assert_eq!(m.role, "user"),
|
||||||
|
_ => panic!("expected Message"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -849,7 +1104,7 @@ mod tests {
|
|||||||
ChatMessage::user("Hello"),
|
ChatMessage::user("Hello"),
|
||||||
ChatMessage::assistant("Hi there!"),
|
ChatMessage::assistant("Hi there!"),
|
||||||
];
|
];
|
||||||
let (instructions, input) = split_messages(messages);
|
let (instructions, input) = split_messages(messages, false);
|
||||||
assert!(instructions.is_none());
|
assert!(instructions.is_none());
|
||||||
assert_eq!(input.len(), 2);
|
assert_eq!(input.len(), 2);
|
||||||
}
|
}
|
||||||
@@ -861,11 +1116,44 @@ mod tests {
|
|||||||
ChatMessage::system("Second instruction"),
|
ChatMessage::system("Second instruction"),
|
||||||
ChatMessage::user("Hello"),
|
ChatMessage::user("Hello"),
|
||||||
];
|
];
|
||||||
let (instructions, input) = split_messages(messages);
|
let (instructions, input) = split_messages(messages, false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
instructions,
|
instructions,
|
||||||
Some("First instruction\n\nSecond instruction".to_string())
|
Some("First instruction\n\nSecond instruction".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(input.len(), 1);
|
assert_eq!(input.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_messages_chaining_converts_tool_results() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("Hello"),
|
||||||
|
ChatMessage::tool_result("call_123", "my_tool", "result data"),
|
||||||
|
];
|
||||||
|
let (_, input) = split_messages(messages, true);
|
||||||
|
assert_eq!(input.len(), 2);
|
||||||
|
match &input[1] {
|
||||||
|
NearAiInputItem::FunctionCallOutput {
|
||||||
|
call_id, output, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(call_id, "call_123");
|
||||||
|
assert_eq!(output, "result data");
|
||||||
|
}
|
||||||
|
_ => panic!("expected FunctionCallOutput"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_messages_no_chaining_keeps_tool_as_message() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("Hello"),
|
||||||
|
ChatMessage::tool_result("call_123", "my_tool", "result data"),
|
||||||
|
];
|
||||||
|
let (_, input) = split_messages(messages, false);
|
||||||
|
assert_eq!(input.len(), 2);
|
||||||
|
match &input[1] {
|
||||||
|
NearAiInputItem::Message(m) => assert_eq!(m.role, "tool"),
|
||||||
|
_ => panic!("expected Message"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-17
@@ -13,14 +13,15 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::config::NearAiConfig;
|
use crate::config::NearAiConfig;
|
||||||
use crate::error::LlmError;
|
use crate::error::LlmError;
|
||||||
use crate::llm::provider::{
|
use crate::llm::provider::{
|
||||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
||||||
ToolCompletionRequest, ToolCompletionResponse,
|
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// NEAR AI Chat Completions API provider.
|
/// NEAR AI Chat Completions API provider.
|
||||||
pub struct NearAiChatProvider {
|
pub struct NearAiChatProvider {
|
||||||
client: Client,
|
client: Client,
|
||||||
config: NearAiConfig,
|
config: NearAiConfig,
|
||||||
|
active_model: std::sync::RwLock<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NearAiChatProvider {
|
impl NearAiChatProvider {
|
||||||
@@ -37,7 +38,12 @@ impl NearAiChatProvider {
|
|||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new());
|
.unwrap_or_else(|_| Client::new());
|
||||||
|
|
||||||
Ok(Self { client, config })
|
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
active_model,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
@@ -65,6 +71,11 @@ impl NearAiChatProvider {
|
|||||||
|
|
||||||
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
|
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
|
||||||
|
|
||||||
|
// Log the request body for debugging tool call issues
|
||||||
|
if let Ok(json) = serde_json::to_string(body) {
|
||||||
|
tracing::debug!("NEAR AI Chat request body: {}", json);
|
||||||
|
}
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
@@ -111,8 +122,8 @@ impl NearAiChatProvider {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch available models.
|
/// Fetch available models with full metadata from the `/v1/models` endpoint.
|
||||||
pub async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
async fn fetch_models(&self) -> Result<Vec<ApiModelEntry>, LlmError> {
|
||||||
let url = self.api_url("models");
|
let url = self.api_url("models");
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
@@ -138,12 +149,7 @@ impl NearAiChatProvider {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ModelsResponse {
|
struct ModelsResponse {
|
||||||
data: Vec<ModelEntry>,
|
data: Vec<ApiModelEntry>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ModelEntry {
|
|
||||||
id: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp: ModelsResponse =
|
let resp: ModelsResponse =
|
||||||
@@ -152,10 +158,18 @@ impl NearAiChatProvider {
|
|||||||
reason: format!("JSON parse error: {}", e),
|
reason: format!("JSON parse error: {}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(resp.data.into_iter().map(|m| m.id).collect())
|
Ok(resp.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model entry as returned by the `/v1/models` API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApiModelEntry {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
context_length: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for NearAiChatProvider {
|
impl LlmProvider for NearAiChatProvider {
|
||||||
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
@@ -163,7 +177,7 @@ impl LlmProvider for NearAiChatProvider {
|
|||||||
req.messages.into_iter().map(|m| m.into()).collect();
|
req.messages.into_iter().map(|m| m.into()).collect();
|
||||||
|
|
||||||
let request = ChatCompletionRequest {
|
let request = ChatCompletionRequest {
|
||||||
model: self.config.model.clone(),
|
model: self.active_model_name(),
|
||||||
messages,
|
messages,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_tokens: req.max_tokens,
|
max_tokens: req.max_tokens,
|
||||||
@@ -197,6 +211,7 @@ impl LlmProvider for NearAiChatProvider {
|
|||||||
finish_reason,
|
finish_reason,
|
||||||
input_tokens: response.usage.prompt_tokens,
|
input_tokens: response.usage.prompt_tokens,
|
||||||
output_tokens: response.usage.completion_tokens,
|
output_tokens: response.usage.completion_tokens,
|
||||||
|
response_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,7 @@ impl LlmProvider for NearAiChatProvider {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let request = ChatCompletionRequest {
|
let request = ChatCompletionRequest {
|
||||||
model: self.config.model.clone(),
|
model: self.active_model_name(),
|
||||||
messages,
|
messages,
|
||||||
temperature: req.temperature,
|
temperature: req.temperature,
|
||||||
max_tokens: req.max_tokens,
|
max_tokens: req.max_tokens,
|
||||||
@@ -278,6 +293,7 @@ impl LlmProvider for NearAiChatProvider {
|
|||||||
finish_reason,
|
finish_reason,
|
||||||
input_tokens: response.usage.prompt_tokens,
|
input_tokens: response.usage.prompt_tokens,
|
||||||
output_tokens: response.usage.completion_tokens,
|
output_tokens: response.usage.completion_tokens,
|
||||||
|
response_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +307,34 @@ impl LlmProvider for NearAiChatProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
||||||
NearAiChatProvider::list_models(self).await
|
let models = self.fetch_models().await?;
|
||||||
|
Ok(models.into_iter().map(|m| m.id).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
|
||||||
|
let active = self.active_model_name();
|
||||||
|
let models = self.fetch_models().await?;
|
||||||
|
let current = models.iter().find(|m| m.id == active);
|
||||||
|
Ok(ModelMetadata {
|
||||||
|
id: active,
|
||||||
|
context_length: current.and_then(|m| m.context_length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_model_name(&self) -> String {
|
||||||
|
self.active_model
|
||||||
|
.read()
|
||||||
|
.expect("active_model lock poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_model(&self, model: &str) -> Result<(), crate::error::LlmError> {
|
||||||
|
let mut guard = self
|
||||||
|
.active_model
|
||||||
|
.write()
|
||||||
|
.expect("active_model lock poisoned");
|
||||||
|
*guard = model.to_string();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,12 +375,33 @@ impl From<ChatMessage> for ChatCompletionMessage {
|
|||||||
Role::Assistant => "assistant",
|
Role::Assistant => "assistant",
|
||||||
Role::Tool => "tool",
|
Role::Tool => "tool",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let tool_calls = msg.tool_calls.map(|calls| {
|
||||||
|
calls
|
||||||
|
.into_iter()
|
||||||
|
.map(|tc| ChatCompletionToolCall {
|
||||||
|
id: tc.id,
|
||||||
|
call_type: "function".to_string(),
|
||||||
|
function: ChatCompletionToolCallFunction {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(msg.content)
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
role: role.to_string(),
|
role: role.to_string(),
|
||||||
content: Some(msg.content),
|
content,
|
||||||
tool_call_id: msg.tool_call_id,
|
tool_call_id: msg.tool_call_id,
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
tool_calls: None,
|
tool_calls,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,4 +487,61 @@ mod tests {
|
|||||||
assert_eq!(chat_msg.tool_call_id, Some("call_123".to_string()));
|
assert_eq!(chat_msg.tool_call_id, Some("call_123".to_string()));
|
||||||
assert_eq!(chat_msg.name, Some("my_tool".to_string()));
|
assert_eq!(chat_msg.name, Some("my_tool".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assistant_with_tool_calls_conversion() {
|
||||||
|
use crate::llm::ToolCall;
|
||||||
|
|
||||||
|
let tool_calls = vec![
|
||||||
|
ToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "list_issues".to_string(),
|
||||||
|
arguments: serde_json::json!({"owner": "foo", "repo": "bar"}),
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
id: "call_2".to_string(),
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: serde_json::json!({"query": "test"}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let msg = ChatMessage::assistant_with_tool_calls(None, tool_calls);
|
||||||
|
let chat_msg: ChatCompletionMessage = msg.into();
|
||||||
|
|
||||||
|
assert_eq!(chat_msg.role, "assistant");
|
||||||
|
|
||||||
|
let tc = chat_msg.tool_calls.expect("tool_calls present");
|
||||||
|
assert_eq!(tc.len(), 2);
|
||||||
|
assert_eq!(tc[0].id, "call_1");
|
||||||
|
assert_eq!(tc[0].function.name, "list_issues");
|
||||||
|
assert_eq!(tc[0].call_type, "function");
|
||||||
|
assert_eq!(tc[1].id, "call_2");
|
||||||
|
assert_eq!(tc[1].function.name, "search");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assistant_without_tool_calls_has_none() {
|
||||||
|
let msg = ChatMessage::assistant("Hello");
|
||||||
|
let chat_msg: ChatCompletionMessage = msg.into();
|
||||||
|
assert!(chat_msg.tool_calls.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_call_arguments_serialized_to_string() {
|
||||||
|
use crate::llm::ToolCall;
|
||||||
|
|
||||||
|
let tc = ToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "test".to_string(),
|
||||||
|
arguments: serde_json::json!({"key": "value"}),
|
||||||
|
};
|
||||||
|
let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
|
||||||
|
let chat_msg: ChatCompletionMessage = msg.into();
|
||||||
|
|
||||||
|
let calls = chat_msg.tool_calls.unwrap();
|
||||||
|
// Arguments should be a JSON string, not a nested object
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&calls[0].function.arguments).expect("valid JSON string");
|
||||||
|
assert_eq!(parsed["key"], "value");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user