mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aca76b1a7 | ||
|
|
414c1b28b9 | ||
|
|
d771f99f9e | ||
|
|
1facda4a75 | ||
|
|
e8caab1a12 | ||
|
|
fac91aec3a | ||
|
|
0aae66c9dc | ||
|
|
aa808ca94e | ||
|
|
c7e6833d14 | ||
|
|
9fed8453c7 | ||
|
|
1885d61d46 | ||
|
|
da47903108 | ||
|
|
eaef335db6 | ||
|
|
225af29db2 | ||
|
|
a53b2c10b5 | ||
|
|
85196cd527 | ||
|
|
a0e01f04d3 | ||
|
|
408ae8a29a | ||
|
|
e982699e09 | ||
|
|
5e73dbbdc8 | ||
|
|
92863bf860 | ||
|
|
d9ff86d7e0 | ||
|
|
46c1daca5e | ||
|
|
e843c18141 | ||
|
|
54e9206f0b | ||
|
|
5df0d13b59 | ||
|
|
bbb68f7490 | ||
|
|
b3dee13954 | ||
|
|
33ef0a6ea5 | ||
|
|
e0a43c81f9 | ||
|
|
bada79ba4a | ||
|
|
a70c89d9e3 | ||
|
|
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_URL=postgres://ironclaw:password@localhost:5432/ironclaw
|
||||
DATABASE_URL=postgres://localhost/ironclaw
|
||||
DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider (NEAR AI)
|
||||
# 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.
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.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
|
||||
# CLI is always enabled
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Code Style
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
codestyle:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
- name: Check lints (cargo clippy)
|
||||
run: cargo clippy -- -D warnings
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 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
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- 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,300 @@
|
||||
# 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/*
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
# 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' }}
|
||||
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,21 @@
|
||||
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@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Tests
|
||||
run: cargo test --all-features -- --nocapture
|
||||
@@ -1,6 +1,10 @@
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
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).
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# 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.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Enabled builds caching during CI/CD
|
||||
- Disabled npm publishing as the name is already taken
|
||||
|
||||
## [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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
||||
@@ -59,7 +64,9 @@ src/
|
||||
│ ├── context_monitor.rs # Memory pressure detection
|
||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||
│ ├── 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
|
||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||
@@ -72,8 +79,33 @@ src/
|
||||
│ │ ├── overlay.rs # Approval overlays
|
||||
│ │ └── composer.rs # Message composition
|
||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||
│ ├── slack.rs # Stub
|
||||
│ └── telegram.rs # Stub
|
||||
│ ├── repl.rs # Simple REPL (for testing)
|
||||
│ ├── 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
|
||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||
@@ -96,6 +128,9 @@ src/
|
||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||
│ │ ├── shell.rs # Shell command execution
|
||||
│ │ ├── 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)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
@@ -116,6 +151,12 @@ src/
|
||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||
│ └── storage.rs # Linear memory persistence
|
||||
│
|
||||
├── db/ # Database abstraction layer
|
||||
│ ├── mod.rs # Database trait (~60 async methods)
|
||||
│ ├── postgres.rs # PostgreSQL backend (delegates to Store + Repository)
|
||||
│ ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
|
||||
│ └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
|
||||
│
|
||||
├── workspace/ # Persistent memory system (OpenClaw-inspired)
|
||||
│ ├── mod.rs # Workspace struct, memory operations
|
||||
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
||||
@@ -151,10 +192,15 @@ src/
|
||||
|
||||
## 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
|
||||
- Use `thiserror` for error types in `error.rs`
|
||||
- Never use `.unwrap()` in production code (tests are fine)
|
||||
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
|
||||
- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
|
||||
|
||||
### Async
|
||||
- All I/O is async with tokio
|
||||
@@ -162,6 +208,7 @@ src/
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
|
||||
### Traits for Extensibility
|
||||
- `Database` - Add new database backends (must implement all ~60 methods)
|
||||
- `Channel` - Add new input sources
|
||||
- `Tool` - Add new capabilities
|
||||
- `LlmProvider` - Add new LLM backends
|
||||
@@ -209,7 +256,12 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
|
||||
Environment variables (see `.env.example`):
|
||||
```bash
|
||||
# Database backend (default: postgres)
|
||||
DATABASE_BACKEND=postgres # or "libsql" / "turso"
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
|
||||
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
|
||||
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
|
||||
|
||||
# NEAR AI (required)
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
@@ -232,6 +284,30 @@ HEARTBEAT_ENABLED=true
|
||||
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
||||
HEARTBEAT_NOTIFY_CHANNEL=tui
|
||||
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
|
||||
@@ -245,7 +321,51 @@ Session tokens have the format `sess_xxx` (37 characters). They are authenticate
|
||||
|
||||
## Database
|
||||
|
||||
Single migration in `migrations/V1__initial.sql`. Tables:
|
||||
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
|
||||
|
||||
**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
|
||||
|
||||
### Backends
|
||||
|
||||
| Backend | Feature Flag | Default | Use Case |
|
||||
|---------|-------------|---------|----------|
|
||||
| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
|
||||
| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
|
||||
|
||||
```bash
|
||||
# Build with PostgreSQL only (default)
|
||||
cargo build
|
||||
|
||||
# Build with libSQL only
|
||||
cargo build --no-default-features --features libsql
|
||||
|
||||
# Build with both backends available
|
||||
cargo build --features "postgres,libsql"
|
||||
```
|
||||
|
||||
### Database Trait
|
||||
|
||||
The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
|
||||
- Conversations, messages, metadata
|
||||
- Jobs, actions, LLM calls, estimation snapshots
|
||||
- Sandbox jobs, job events
|
||||
- Routines, routine runs
|
||||
- Tool failures, settings
|
||||
- Workspace: documents, chunks, hybrid search
|
||||
|
||||
Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
|
||||
|
||||
### Schema
|
||||
|
||||
**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
|
||||
|
||||
**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
|
||||
- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
|
||||
- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
|
||||
- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
|
||||
- PL/pgSQL functions -> SQLite triggers
|
||||
|
||||
**Tables (both backends):**
|
||||
|
||||
**Core:**
|
||||
- `conversations` - Multi-channel conversation tracking
|
||||
@@ -257,12 +377,41 @@ Single migration in `migrations/V1__initial.sql`. Tables:
|
||||
|
||||
**Workspace/Memory:**
|
||||
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
||||
- `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) indexes
|
||||
- `memory_chunks` - Chunked content with FTS and vector indexes
|
||||
- `heartbeat_state` - Periodic execution tracking
|
||||
|
||||
Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
|
||||
**Other:**
|
||||
- `routines`, `routine_runs` - Scheduled/reactive execution
|
||||
- `settings` - Per-user key-value settings
|
||||
- `tool_failures` - Self-repair tracking
|
||||
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
|
||||
|
||||
Run migrations: `refinery migrate -c refinery.toml`
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Backend selection (default: postgres)
|
||||
DATABASE_BACKEND=libsql
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
|
||||
# libSQL (embedded)
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # Default path
|
||||
|
||||
# libSQL (Turso cloud sync)
|
||||
LIBSQL_URL=libsql://your-db.turso.io
|
||||
LIBSQL_AUTH_TOKEN=your-token # Required when LIBSQL_URL is set
|
||||
```
|
||||
|
||||
### Current Limitations (libSQL backend)
|
||||
|
||||
- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
|
||||
- **Secrets store** not yet available (still requires PostgresSecretsStore)
|
||||
- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
|
||||
- **Settings reload from DB** skipped (Config::from_db requires Store)
|
||||
- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
|
||||
- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
|
||||
- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
|
||||
|
||||
## Safety Layer
|
||||
|
||||
@@ -293,13 +442,14 @@ Key test patterns:
|
||||
|
||||
## Current Limitations / TODOs
|
||||
|
||||
1. **Slack/Telegram channels** - Stubs only, need implementation
|
||||
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||
3. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||
4. **MCP stdio transport** - Only HTTP transport implemented
|
||||
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||
1. **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. **MCP stdio transport** - Only HTTP transport implemented
|
||||
4. **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. **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
|
||||
|
||||
@@ -316,6 +466,14 @@ 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 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
|
||||
- ✅ **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
|
||||
- ✅ **libSQL/Turso backend** - Database trait abstraction (`src/db/`), feature-gated dual backend support (postgres/libsql), embedded SQLite for zero-dependency local mode
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
@@ -331,13 +489,13 @@ Key test patterns:
|
||||
|
||||
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`)
|
||||
3. Create `<name>.capabilities.json` declaring required permissions
|
||||
4. Build with `cargo build --target wasm32-wasip2 --release`
|
||||
5. Install with `ironclaw tool install path/to/tool.wasm`
|
||||
|
||||
See `examples/wasm-tools/` for examples.
|
||||
See `tools-src/` for examples.
|
||||
|
||||
## Tool Architecture Principles
|
||||
|
||||
@@ -419,6 +577,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.
|
||||
|
||||
### 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
|
||||
|
||||
1. Create `src/channels/my_channel.rs`
|
||||
@@ -447,6 +638,37 @@ RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||
- Keep functions focused, extract helpers when logic is reused
|
||||
- Comments for non-obvious logic only
|
||||
|
||||
## Review & Fix Discipline
|
||||
|
||||
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
|
||||
|
||||
### Fix the pattern, not just the instance
|
||||
When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
|
||||
|
||||
### Propagate architectural fixes to satellite types
|
||||
If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
|
||||
|
||||
### Schema translation is more than DDL
|
||||
When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
|
||||
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
|
||||
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
|
||||
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
|
||||
|
||||
### Feature flag testing
|
||||
When adding feature-gated code, test compilation with each feature in isolation:
|
||||
```bash
|
||||
cargo check # default features
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # all features
|
||||
```
|
||||
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
|
||||
|
||||
### Mechanical verification before committing
|
||||
Run these checks on changed files before committing:
|
||||
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
|
||||
- `grep -rn 'super::' <files>` -- use `crate::` imports
|
||||
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
|
||||
|
||||
## Workspace & Memory System
|
||||
|
||||
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
||||
@@ -521,7 +743,7 @@ Four tools for LLM use:
|
||||
|
||||
### Hybrid Search (RRF)
|
||||
|
||||
Combines full-text search (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) using Reciprocal Rank Fusion:
|
||||
Combines full-text search and vector similarity using Reciprocal Rank Fusion:
|
||||
|
||||
```
|
||||
score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
||||
@@ -529,6 +751,10 @@ score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
||||
|
||||
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
|
||||
|
||||
**Backend differences:**
|
||||
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
|
||||
- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
Proactive periodic execution (default: 30 minutes):
|
||||
|
||||
@@ -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
+1382
-233
File diff suppressed because it is too large
Load Diff
+95
-17
@@ -1,29 +1,41 @@
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.92"
|
||||
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"
|
||||
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]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Database
|
||||
deadpool-postgres = "0.14"
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"] }
|
||||
# Database - PostgreSQL (default, feature-gated)
|
||||
deadpool-postgres = { version = "0.14", optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
||||
|
||||
# Database - libSQL/Turso (optional embedded database)
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
@@ -39,7 +51,7 @@ dotenvy = "0.15"
|
||||
# Core types
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||
rust_decimal_macros = "1"
|
||||
|
||||
# Async traits
|
||||
@@ -48,26 +60,32 @@ async-trait = "0.1"
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# TUI
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
# Terminal
|
||||
crossterm = "0.28"
|
||||
rustyline = { version = "17", features = ["derive", "with-file-history"] }
|
||||
termimad = "0.34"
|
||||
|
||||
# Channel integrations
|
||||
axum = "0.8"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
|
||||
# Cron scheduling for routines
|
||||
cron = "0.13"
|
||||
|
||||
# Safety/sanitization
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "6"
|
||||
fs4 = "0.6"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
# URL encoding for OAuth flow
|
||||
# URL parsing and encoding
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Open URLs in browser
|
||||
@@ -75,7 +93,7 @@ open = "5"
|
||||
|
||||
# Vector embeddings for semantic search
|
||||
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
||||
pgvector = { version = "0.4", features = ["postgres"] }
|
||||
pgvector = { version = "0.4", features = ["postgres"], optional = true }
|
||||
|
||||
# WASM sandbox for untrusted tool execution
|
||||
wasmtime = { version = "28", features = ["component-model"] }
|
||||
@@ -88,6 +106,10 @@ hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
blake3 = "1"
|
||||
rand = "0.8"
|
||||
subtle = "2" # Constant-time comparisons for token validation
|
||||
|
||||
# Multi-provider LLM support
|
||||
rig-core = "0.30"
|
||||
|
||||
# Docker sandbox
|
||||
bollard = "0.18"
|
||||
@@ -98,6 +120,7 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
|
||||
http-body-util = "0.1"
|
||||
bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -110,10 +133,65 @@ zbus = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tokio-tungstenite = "0.26"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["postgres", "libsql"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
"dep:postgres-types",
|
||||
"dep:refinery",
|
||||
"dep:pgvector",
|
||||
"rust_decimal/db-tokio-postgres",
|
||||
]
|
||||
libsql = ["dep:libsql"]
|
||||
integration = []
|
||||
|
||||
[[example]]
|
||||
name = "test_heartbeat"
|
||||
required-features = ["postgres"]
|
||||
|
||||
# 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 = []
|
||||
# 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"
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 -t ironclaw:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run --env-file .env -p 3000:3000 ironclaw:latest
|
||||
|
||||
# Stage 1: Build
|
||||
FROM rust:1.92-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev cmake gcc g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Copy source and build artifacts
|
||||
COPY src/ src/
|
||||
COPY migrations/ migrations/
|
||||
COPY wit/ wit/
|
||||
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
||||
USER ironclaw
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=ironclaw=info
|
||||
|
||||
ENTRYPOINT ["ironclaw"]
|
||||
@@ -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.92-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.92.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"]
|
||||
+48
-38
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
|
||||
| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 |
|
||||
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
|
||||
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE |
|
||||
| Single-user system | ✅ | ✅ | |
|
||||
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
||||
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
||||
@@ -31,19 +31,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway control plane | ✅ | ❌ | Central WebSocket server |
|
||||
| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard |
|
||||
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
|
||||
| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints |
|
||||
| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions |
|
||||
| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker |
|
||||
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
||||
| Configuration hot-reload | ✅ | ❌ | |
|
||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||
| OpenAI-compatible HTTP API | ✅ | ❌ | /v1/chat/completions |
|
||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions |
|
||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||
| launchd/systemd integration | ✅ | ❌ | |
|
||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| `doctor` diagnostics | ✅ | ❌ | |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||
| Telegram | ✅ | ❌ | P1 | grammY (Bot API) |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ❌ | P2 | Browser-based chat |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ❌ | Verification for unknown senders |
|
||||
| Allowlist/blocklist | ✅ | ❌ | Per-channel access control |
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -99,17 +99,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `run` (agent) | ✅ | ✅ | - | Default command |
|
||||
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||
| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup |
|
||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||
| `config` | ✅ | ❌ | P2 | Read/write config |
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ❌ | P2 | System status |
|
||||
| `status` | ✅ | ✅ | - | System status |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||
| `memory` | ✅ | ❌ | P2 | Memory search CLI |
|
||||
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||
| `pairing` | ✅ | ❌ | P3 | Node pairing |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||
@@ -132,8 +132,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
||||
| RPC-based execution | ✅ | 🚧 | Worker isolation |
|
||||
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
||||
| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
|
||||
| Multi-provider failover | ✅ | ✅ | `FailoverProvider` tries providers sequentially on retryable errors |
|
||||
| Per-sender sessions | ✅ | ✅ | |
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
@@ -173,7 +173,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Auto-discovery | ✅ | ❌ | |
|
||||
| Failover chains | ✅ | ❌ | Provider fallback |
|
||||
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
|
||||
| Cooldown management | ✅ | ❌ | Skip failed providers |
|
||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||
@@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config |
|
||||
| Channel status view | ✅ | ❌ | P2 | |
|
||||
| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions |
|
||||
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
|
||||
| Agent management | ✅ | ❌ | P3 | |
|
||||
| Model selection | ✅ | ✅ | - | TUI only |
|
||||
| Config editing | ✅ | ❌ | P3 | |
|
||||
| Debug/logs viewer | ✅ | ❌ | P3 | |
|
||||
| WebChat interface | ✅ | ❌ | P2 | Browser chat |
|
||||
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
||||
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks |
|
||||
| Timezone support | ✅ | ❌ | P2 | |
|
||||
| One-shot/recurring jobs | ✅ | ❌ | P2 | |
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||
| `onMessage` hook | ✅ | ❌ | P2 | |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
@@ -346,18 +346,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
|
||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| DM pairing verification | ✅ | ❌ | |
|
||||
| Allowlist/blocklist | ✅ | ❌ | |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||
| Docker sandbox | ✅ | ❌ | Uses WASM sandbox |
|
||||
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||
| Tool policies | ✅ | ✅ | |
|
||||
| Elevated mode | ✅ | ❌ | |
|
||||
@@ -397,6 +397,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
### P0 - Core (Already Done)
|
||||
- ✅ TUI channel with approval overlays
|
||||
- ✅ HTTP webhook channel
|
||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||
- ✅ WASM tool sandbox
|
||||
- ✅ Workspace/memory with hybrid search
|
||||
- ✅ Prompt injection defense
|
||||
@@ -404,23 +405,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ Session management
|
||||
- ✅ Context compaction
|
||||
- ✅ 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
|
||||
- ❌ Slack channel (real implementation)
|
||||
- ❌ Telegram channel
|
||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||
- ❌ WhatsApp channel
|
||||
- ❌ Multi-provider failover
|
||||
- ❌ Gateway control plane + WebSocket
|
||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Cron job scheduling
|
||||
- ❌ Web Control UI
|
||||
- ❌ WebChat channel
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- 🚧 Media handling (caption support; no image/PDF processing)
|
||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||
- ❌ Ollama/local model support
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ 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
|
||||
|
||||
- **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
|
||||
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
||||
- **Self-repair** - Automatic detection and recovery of stuck operations
|
||||
@@ -65,10 +68,41 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.85+
|
||||
- PostgreSQL 15+ with pgvector extension
|
||||
- NEAR AI session token (or other LLM provider)
|
||||
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
|
||||
- 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>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
|
||||
# Clone the repository
|
||||
@@ -82,6 +116,10 @@ cargo build --release
|
||||
cargo test
|
||||
```
|
||||
|
||||
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
|
||||
|
||||
</details>
|
||||
|
||||
### Database Setup
|
||||
|
||||
```bash
|
||||
@@ -90,36 +128,19 @@ createdb ironclaw
|
||||
|
||||
# Enable pgvector
|
||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
# Run migrations
|
||||
refinery migrate -c refinery.toml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
Run the setup wizard to configure IronClaw:
|
||||
|
||||
```bash
|
||||
# Required
|
||||
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
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `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 |
|
||||
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||
and secrets encryption (using your system keychain). All settings are saved to
|
||||
`~/.ironclaw/settings.toml`.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -160,37 +181,42 @@ External content passes through multiple security layers:
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │
|
||||
│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │
|
||||
│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │
|
||||
│ └──────────┴─────────────┴────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────▼────┐ │
|
||||
│ │ Router │ Intent classification │
|
||||
│ └────┬────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Scheduler │ Parallel job management │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ └───────────────┼───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ Built-in │ │ │
|
||||
│ │ │ MCP │ │ │
|
||||
│ │ │ WASM Sandbox │ │ │
|
||||
│ │ └───────────────┘ │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||
│ │ │ │ └──────┬──────┘ │
|
||||
│ └─────────┴──────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Agent Loop │ Intent routing │
|
||||
│ └────┬──────────┬───┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ Scheduler │ │ Routines Engine │ │
|
||||
│ │(parallel jobs)│ │(cron, event, wh) │ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
||||
│ │ Local │ │ Orchestrator │ │
|
||||
│ │Workers │ │ ┌───────────────┐ │ │
|
||||
│ │(in-proc)│ │ │ Docker Sandbox│ │ │
|
||||
│ └───┬─────┘ │ │ Containers │ │ │
|
||||
│ │ │ │ ┌───────────┐ │ │ │
|
||||
│ │ │ │ │Worker / CC│ │ │ │
|
||||
│ │ │ │ └───────────┘ │ │ │
|
||||
│ │ │ └───────────────┘ │ │
|
||||
│ │ └─────────┬───────────┘ │
|
||||
│ └──────────────────┤ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ Built-in, MCP, WASM │ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Components
|
||||
@@ -201,33 +227,25 @@ External content passes through multiple security layers:
|
||||
| **Router** | Classifies user intent (command, query, task) |
|
||||
| **Scheduler** | Manages parallel job execution with priorities |
|
||||
| **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 |
|
||||
| **Safety Layer** | Prompt injection defense and content sanitization |
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Mode
|
||||
|
||||
```bash
|
||||
# Start interactive CLI
|
||||
# First-time setup (configures database, auth, etc.)
|
||||
ironclaw onboard
|
||||
|
||||
# Start interactive REPL
|
||||
cargo run
|
||||
|
||||
# With debug logging
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -238,12 +256,16 @@ cargo fmt
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# Run tests
|
||||
createdb ironclaw_test
|
||||
cargo test
|
||||
|
||||
# Run specific test
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
|
||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
// Re-export generated types
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, PollConfig,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
@@ -108,7 +108,10 @@ struct SlackPostMessageResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackConfig {
|
||||
/// 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")]
|
||||
#[allow(dead_code)]
|
||||
signing_secret_name: String,
|
||||
}
|
||||
|
||||
@@ -175,11 +178,7 @@ impl Guest for SlackChannel {
|
||||
// Actual event callback
|
||||
"event_callback" => {
|
||||
if let Some(event) = event_wrapper.event {
|
||||
handle_slack_event(
|
||||
event,
|
||||
event_wrapper.team_id,
|
||||
event_wrapper.event_id,
|
||||
);
|
||||
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id);
|
||||
}
|
||||
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
||||
json_response(200, serde_json::json!({"ok": true}))
|
||||
@@ -230,6 +229,7 @@ impl Guest for SlackChannel {
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -243,14 +243,15 @@ impl Guest for SlackChannel {
|
||||
|
||||
// Parse Slack response
|
||||
let slack_response: SlackPostMessageResponse =
|
||||
serde_json::from_slice(&http_response.body).map_err(|e| {
|
||||
format!("Failed to parse Slack response: {}", e)
|
||||
})?;
|
||||
serde_json::from_slice(&http_response.body)
|
||||
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
||||
|
||||
if !slack_response.ok {
|
||||
return Err(format!(
|
||||
"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() {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(
|
||||
event: SlackEvent,
|
||||
team_id: Option<String>,
|
||||
_event_id: Option<String>,
|
||||
) {
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot
|
||||
"app_mention" => {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -297,9 +299,12 @@ fn handle_slack_event(
|
||||
return;
|
||||
}
|
||||
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
// Only process DMs (channel IDs starting with D)
|
||||
if channel.starts_with('D') {
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
@@ -333,13 +338,18 @@ fn emit_message(
|
||||
team_id,
|
||||
};
|
||||
|
||||
let metadata_json =
|
||||
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize Slack metadata: {}", e),
|
||||
);
|
||||
"{}".to_string()
|
||||
});
|
||||
|
||||
// Strip @ mentions of the bot from the text for cleaner messages
|
||||
let cleaned_text = strip_bot_mention(&text);
|
||||
|
||||
channel_host::emit_message(EmittedMessage {
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: None, // Could fetch from Slack API if needed
|
||||
content: cleaned_text,
|
||||
@@ -362,7 +372,13 @@ fn strip_bot_mention(text: &str) -> String {
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize JSON response: {}", e),
|
||||
);
|
||||
Vec::new()
|
||||
});
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
|
||||
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
|
||||
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.near-agent/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your bot token to secrets:"
|
||||
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
||||
|
||||
+393
-101
@@ -72,6 +72,10 @@ struct TelegramMessage {
|
||||
/// Message text.
|
||||
text: Option<String>,
|
||||
|
||||
/// Caption for media (photo, video, document, etc.).
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
|
||||
/// Original message if this is a reply.
|
||||
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.
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -196,6 +215,14 @@ struct TelegramConfig {
|
||||
#[serde(default)]
|
||||
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).
|
||||
#[serde(default)]
|
||||
respond_to_all_group_messages: bool,
|
||||
@@ -257,6 +284,24 @@ 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
|
||||
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
||||
let webhook_mode = config.tunnel_url.is_some();
|
||||
@@ -388,7 +433,9 @@ impl Guest for TelegramChannel {
|
||||
|
||||
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 {
|
||||
Ok(response) => {
|
||||
@@ -461,72 +508,52 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
// Parse metadata to get chat info
|
||||
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Build sendMessage payload
|
||||
let mut payload = serde_json::json!({
|
||||
"chat_id": metadata.chat_id,
|
||||
"text": response.content,
|
||||
"parse_mode": "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),
|
||||
// Try sending with Markdown first; fall back to plain text if Telegram
|
||||
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
|
||||
let result = send_message(
|
||||
metadata.chat_id,
|
||||
&response.content,
|
||||
metadata.message_id,
|
||||
Some("Markdown"),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(http_response) => {
|
||||
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())
|
||||
));
|
||||
}
|
||||
|
||||
Ok(msg_id) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Sent message to chat {}: message_id={}",
|
||||
metadata.chat_id,
|
||||
api_response.result.map(|r| r.message_id).unwrap_or(0)
|
||||
metadata.chat_id, msg_id
|
||||
),
|
||||
);
|
||||
|
||||
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 +595,7 @@ impl Guest for TelegramChannel {
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -586,6 +614,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
|
||||
// ============================================================================
|
||||
@@ -604,6 +727,7 @@ fn delete_webhook() -> Result<(), String> {
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
||||
&headers.to_string(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -666,6 +790,7 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
||||
&headers.to_string(),
|
||||
Some(&body_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -700,6 +825,48 @@ 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),
|
||||
None,
|
||||
);
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -719,11 +886,16 @@ fn handle_update(update: TelegramUpdate) {
|
||||
|
||||
/// Process a single message.
|
||||
fn handle_message(message: TelegramMessage) {
|
||||
// Skip messages without text
|
||||
let text = match message.text {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return,
|
||||
};
|
||||
// Use text or caption (for media messages)
|
||||
let content = message
|
||||
.text
|
||||
.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)
|
||||
let from = match message.from {
|
||||
@@ -736,41 +908,105 @@ fn handle_message(message: TelegramMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Owner validation: silently drop messages from non-owner users
|
||||
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
|
||||
if !owner_id_str.is_empty() {
|
||||
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
|
||||
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;
|
||||
let is_private = message.chat.chat_type == "private";
|
||||
|
||||
// Owner validation: when owner_id is set, only that user can message
|
||||
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(ref id_str) = owner_id_str {
|
||||
if let Ok(owner_id) = id_str.parse::<i64>() {
|
||||
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, 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
|
||||
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
|
||||
if !is_private {
|
||||
// In groups, only respond if there's a bot mention or command
|
||||
// This is a simplified check - proper implementation would use entities
|
||||
let has_command = text.starts_with('/');
|
||||
let has_mention = text.contains('@');
|
||||
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
|
||||
.as_deref()
|
||||
.unwrap_or("false")
|
||||
== "true";
|
||||
|
||||
if !has_command && !has_mention {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Ignoring group message without mention: {}", text),
|
||||
);
|
||||
return;
|
||||
if !respond_to_all {
|
||||
let has_command = content.starts_with('/');
|
||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||
let has_bot_mention = if bot_username.is_empty() {
|
||||
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 +1028,30 @@ fn handle_message(message: TelegramMessage) {
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
// 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;
|
||||
}
|
||||
} else {
|
||||
cleaned_text
|
||||
};
|
||||
|
||||
// Emit the message to the agent
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id: from.id.to_string(),
|
||||
user_name: Some(user_name),
|
||||
content: cleaned_text,
|
||||
content: content_to_emit,
|
||||
thread_id: None, // Telegram doesn't have threads in the same way
|
||||
metadata_json,
|
||||
});
|
||||
@@ -817,7 +1066,8 @@ fn handle_message(message: TelegramMessage) {
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
// Remove leading /command
|
||||
@@ -832,11 +1082,30 @@ fn clean_message_text(text: &str) -> String {
|
||||
|
||||
// Remove leading @mention
|
||||
if result.starts_with('@') {
|
||||
if let Some(space_idx) = result.find(' ') {
|
||||
result = result[space_idx..].trim_start().to_string();
|
||||
if let Some(bot) = bot_username {
|
||||
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 {
|
||||
// Just a mention with no text
|
||||
return String::new();
|
||||
// No bot_username: strip any leading @mention
|
||||
if let Some(space_idx) = result.find(' ') {
|
||||
result = result[space_idx..].trim_start().to_string();
|
||||
} else {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,12 +1141,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clean_message_text() {
|
||||
assert_eq!(clean_message_text("/start hello"), "hello");
|
||||
assert_eq!(clean_message_text("@bot hello world"), "hello world");
|
||||
assert_eq!(clean_message_text("/start"), "");
|
||||
assert_eq!(clean_message_text("@botname"), "");
|
||||
assert_eq!(clean_message_text("just text"), "just text");
|
||||
assert_eq!(clean_message_text(" spaced "), "spaced");
|
||||
// Without bot_username: strips any leading @mention
|
||||
assert_eq!(clean_message_text("/start hello", None), "hello");
|
||||
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
|
||||
assert_eq!(clean_message_text("/start", None), "");
|
||||
assert_eq!(clean_message_text("@botname", None), "");
|
||||
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]
|
||||
@@ -945,4 +1224,17 @@ mod tests {
|
||||
assert_eq!(from.id, 789);
|
||||
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
|
||||
}
|
||||
}
|
||||
{"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":[]}}
|
||||
|
||||
Binary file not shown.
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
|
||||
// Re-export generated types
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
@@ -254,10 +254,19 @@ struct WhatsAppChannel;
|
||||
|
||||
impl Guest for WhatsAppChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
});
|
||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
||||
);
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -267,6 +276,9 @@ impl Guest for WhatsAppChannel {
|
||||
),
|
||||
);
|
||||
|
||||
// Persist api_version in workspace so on_respond() can read it
|
||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -327,11 +339,16 @@ impl Guest for WhatsAppChannel {
|
||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Read api_version from workspace (set during on_start), fallback to default
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
// Build WhatsApp API URL with token placeholder
|
||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||
let api_url = format!(
|
||||
"https://graph.facebook.com/v18.0/{}/messages",
|
||||
metadata.phone_number_id
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, metadata.phone_number_id
|
||||
);
|
||||
|
||||
// Build sendMessage payload
|
||||
@@ -361,6 +378,7 @@ impl Guest for WhatsAppChannel {
|
||||
&api_url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -417,6 +435,8 @@ impl Guest for WhatsAppChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Cloud SQL Auth Proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,27 @@
|
||||
# WARNING: Replace all CHANGE_ME values before deploying.
|
||||
# Do not use placeholder passwords in production.
|
||||
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
|
||||
|
||||
# NEAR AI
|
||||
NEARAI_SESSION_TOKEN=CHANGE_ME
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||
NEARAI_AUTH_URL=https://private.near.ai
|
||||
NEARAI_API_MODE=chat_completions
|
||||
|
||||
# Agent
|
||||
AGENT_NAME=ironclaw
|
||||
CLI_ENABLED=false
|
||||
|
||||
# Web Gateway
|
||||
GATEWAY_ENABLED=true
|
||||
# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
|
||||
# Use 127.0.0.1 if running outside Docker or for local-only access.
|
||||
GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=3000
|
||||
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
||||
|
||||
# Disabled for initial deploy
|
||||
SANDBOX_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
EMBEDDING_ENABLED=false
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=IronClaw AI Assistant
|
||||
After=cloud-sql-proxy.service docker.service
|
||||
Requires=cloud-sql-proxy.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
|
||||
ExecStart=/usr/bin/docker run --rm \
|
||||
--name ironclaw \
|
||||
--env-file /opt/ironclaw/.env \
|
||||
--network=host \
|
||||
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
|
||||
--no-onboard
|
||||
ExecStop=/usr/bin/docker stop ironclaw
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# VM bootstrap script for IronClaw on GCP Compute Engine.
|
||||
#
|
||||
# Run on a fresh Debian 12 VM after SSH:
|
||||
# sudo bash setup.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - VM has the ironclaw-vm service account attached
|
||||
# - Cloud SQL Auth Proxy accessible via IAM
|
||||
# - Artifact Registry image pushed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Must run as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (sudo bash setup.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Installing Docker"
|
||||
apt-get update
|
||||
apt-get install -y docker.io
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
echo "==> Installing Cloud SQL Auth Proxy"
|
||||
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
|
||||
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
|
||||
chmod +x /usr/local/bin/cloud-sql-proxy
|
||||
|
||||
echo "==> Installing systemd services"
|
||||
cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
|
||||
cp /tmp/deploy/ironclaw.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "==> Starting Cloud SQL Auth Proxy"
|
||||
systemctl enable cloud-sql-proxy
|
||||
systemctl start cloud-sql-proxy
|
||||
|
||||
echo "==> Configuring Docker registry auth"
|
||||
# The VM service account provides Artifact Registry access
|
||||
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
echo "==> Creating config directory"
|
||||
# Owned by root, readable only by root. Docker reads --env-file as root
|
||||
# before dropping to uid 1000 (ironclaw) inside the container.
|
||||
mkdir -p /opt/ironclaw
|
||||
chmod 700 /opt/ironclaw
|
||||
|
||||
if [ ! -f /opt/ironclaw/.env ]; then
|
||||
echo "WARNING: /opt/ironclaw/.env does not exist."
|
||||
echo "Create it with your configuration before starting IronClaw."
|
||||
echo "See deploy/env.example for the required variables."
|
||||
echo ""
|
||||
echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
|
||||
else
|
||||
chmod 600 /opt/ironclaw/.env
|
||||
echo "==> Starting IronClaw"
|
||||
systemctl enable ironclaw
|
||||
systemctl start ironclaw
|
||||
fi
|
||||
|
||||
echo "==> Setup complete"
|
||||
echo ""
|
||||
echo "Verify with:"
|
||||
echo " systemctl status cloud-sql-proxy"
|
||||
echo " systemctl status ironclaw"
|
||||
echo " docker logs ironclaw"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Local development only — do NOT use these credentials in production.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ironclaw
|
||||
POSTGRES_USER: ironclaw
|
||||
POSTGRES_PASSWORD: ironclaw # dev-only, change for any non-local deployment
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ironclaw"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -246,13 +246,46 @@ Create `my-channel.capabilities.json`:
|
||||
|
||||
## 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
|
||||
# Build the WASM component
|
||||
cd channels/my-channel
|
||||
cargo component build --release
|
||||
cd channels-src/my-channel
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# 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/
|
||||
```
|
||||
|
||||
|
||||
@@ -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,121 @@
|
||||
//! 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(),
|
||||
})
|
||||
.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"
|
||||
+1210
-235
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 crate::channels::OutgoingResponse;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Configuration for the heartbeat runner.
|
||||
@@ -178,7 +178,7 @@ impl HeartbeatRunner {
|
||||
pub async fn check_heartbeat(&self) -> HeartbeatResult {
|
||||
// Get the heartbeat checklist
|
||||
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,
|
||||
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)
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3); // Lower temperature for more focused responses
|
||||
.with_max_tokens(max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let response = match self.llm.complete(request).await {
|
||||
Ok(r) => r,
|
||||
@@ -228,6 +245,20 @@ impl HeartbeatRunner {
|
||||
|
||||
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
|
||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_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.
|
||||
///
|
||||
/// Returns a handle that can be used to stop the runner.
|
||||
@@ -301,4 +371,107 @@ mod tests {
|
||||
let disabled = HeartbeatConfig::default().disabled();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@
|
||||
//! - Tool invocation with safety
|
||||
//! - Self-repair for stuck jobs
|
||||
//! - Proactive heartbeat execution
|
||||
//! - Routine-based scheduled and reactive jobs
|
||||
//! - Turn-based session management with undo
|
||||
//! - Context compaction for long conversations
|
||||
|
||||
@@ -14,6 +15,8 @@ pub mod compaction;
|
||||
pub mod context_monitor;
|
||||
mod heartbeat;
|
||||
mod router;
|
||||
pub mod routine;
|
||||
pub mod routine_engine;
|
||||
mod scheduler;
|
||||
mod self_repair;
|
||||
pub mod session;
|
||||
@@ -23,14 +26,17 @@ pub mod task;
|
||||
pub mod undo;
|
||||
pub mod worker;
|
||||
|
||||
pub(crate) use agent_loop::truncate_for_preview;
|
||||
pub use agent_loop::{Agent, AgentDeps};
|
||||
pub use compaction::{CompactionResult, ContextCompactor};
|
||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::RoutineEngine;
|
||||
pub use scheduler::Scheduler;
|
||||
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 submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
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,601 @@
|
||||
//! 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::sync::atomic::{AtomicUsize, Ordering};
|
||||
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::db::Database;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
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<AtomicUsize>,
|
||||
/// 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<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
store,
|
||||
llm,
|
||||
workspace,
|
||||
notify_tx,
|
||||
running_count: Arc::new(AtomicUsize::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
|
||||
&& 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.load(Ordering::Relaxed) >= 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.load(Ordering::Relaxed) >= 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<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
running_count: Arc<AtomicUsize>,
|
||||
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 (atomic: survives panics in the execution below)
|
||||
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
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
|
||||
ctx.running_count.fetch_sub(1, Ordering::Relaxed);
|
||||
|
||||
// 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 {
|
||||
let end = crate::util::floor_char_boundary(s, max);
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-73
@@ -12,8 +12,8 @@ use crate::agent::task::{Task, TaskContext, TaskOutput};
|
||||
use crate::agent::worker::{Worker, WorkerDeps};
|
||||
use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::history::Store;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -48,7 +48,7 @@ pub struct Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<Store>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -63,7 +63,7 @@ impl Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<Store>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -79,63 +79,63 @@ impl Scheduler {
|
||||
|
||||
/// Schedule a job for execution.
|
||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||
// Check if already scheduled
|
||||
if self.jobs.read().await.contains_key(&job_id) {
|
||||
return Ok(());
|
||||
}
|
||||
// Hold write lock for the entire check-insert sequence to prevent
|
||||
// TOCTOU races where two concurrent calls both pass the checks.
|
||||
{
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
// Check capacity
|
||||
let current_count = self.jobs.read().await.len();
|
||||
if current_count >= self.config.max_parallel_jobs {
|
||||
return Err(JobError::MaxJobsExceeded {
|
||||
max: self.config.max_parallel_jobs,
|
||||
});
|
||||
}
|
||||
|
||||
// Transition job to in_progress
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(
|
||||
JobState::InProgress,
|
||||
Some("Scheduled for execution".to_string()),
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.map_err(|s| JobError::ContextError {
|
||||
id: job_id,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Create worker channel
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
|
||||
// Create worker with shared dependencies
|
||||
let deps = WorkerDeps {
|
||||
context_manager: self.context_manager.clone(),
|
||||
llm: self.llm.clone(),
|
||||
safety: self.safety.clone(),
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
// Spawn worker task
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = worker.run(rx).await {
|
||||
tracing::error!("Worker for job {} failed: {}", job_id, e);
|
||||
if jobs.contains_key(&job_id) {
|
||||
return Ok(());
|
||||
}
|
||||
});
|
||||
|
||||
// Start the worker
|
||||
let _ = tx.send(WorkerMessage::Start).await;
|
||||
if jobs.len() >= self.config.max_parallel_jobs {
|
||||
return Err(JobError::MaxJobsExceeded {
|
||||
max: self.config.max_parallel_jobs,
|
||||
});
|
||||
}
|
||||
|
||||
// Store the scheduled job
|
||||
self.jobs
|
||||
.write()
|
||||
.await
|
||||
.insert(job_id, ScheduledJob { handle, tx });
|
||||
// Transition job to in_progress
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(
|
||||
JobState::InProgress,
|
||||
Some("Scheduled for execution".to_string()),
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.map_err(|s| JobError::ContextError {
|
||||
id: job_id,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Create worker channel
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
|
||||
// Create worker with shared dependencies
|
||||
let deps = WorkerDeps {
|
||||
context_manager: self.context_manager.clone(),
|
||||
llm: self.llm.clone(),
|
||||
safety: self.safety.clone(),
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
// Spawn worker task
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = worker.run(rx).await {
|
||||
tracing::error!("Worker for job {} failed: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the worker
|
||||
let _ = tx.send(WorkerMessage::Start).await;
|
||||
|
||||
// Insert while still holding the write lock
|
||||
jobs.insert(job_id, ScheduledJob { handle, tx });
|
||||
}
|
||||
|
||||
// Cleanup task for this job to avoid capacity leaks
|
||||
let jobs = Arc::clone(&self.jobs);
|
||||
@@ -373,23 +373,23 @@ impl Scheduler {
|
||||
.into());
|
||||
}
|
||||
|
||||
// Execute with timeout
|
||||
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||
tool.execute(params, &job_ctx).await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Tool(crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: Duration::from_secs(60),
|
||||
})
|
||||
})?
|
||||
.map_err(|e| {
|
||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
})?;
|
||||
// Execute with per-tool timeout
|
||||
let tool_timeout = tool.execution_timeout();
|
||||
let result =
|
||||
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Tool(crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: tool_timeout,
|
||||
})
|
||||
})?
|
||||
.map_err(|e| {
|
||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(TaskOutput::new(result.result, start.elapsed()))
|
||||
}
|
||||
|
||||
+21
-21
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RepairError;
|
||||
use crate::history::Store;
|
||||
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
||||
|
||||
/// A job that has been detected as stuck.
|
||||
@@ -69,7 +69,7 @@ pub struct DefaultSelfRepair {
|
||||
#[allow(dead_code)] // Will be used for time-based stuck detection
|
||||
stuck_threshold: Duration,
|
||||
max_repair_attempts: u32,
|
||||
store: Option<Arc<Store>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||
#[allow(dead_code)] // Will be used for tool hot-reload after repair
|
||||
tools: Option<Arc<ToolRegistry>>,
|
||||
@@ -94,7 +94,7 @@ impl DefaultSelfRepair {
|
||||
|
||||
/// Add a Store for tool failure tracking.
|
||||
#[allow(dead_code)] // Public API for configuring repair with persistence
|
||||
pub fn with_store(mut self, store: Arc<Store>) -> Self {
|
||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||
self.store = Some(store);
|
||||
self
|
||||
}
|
||||
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
let mut stuck_jobs = Vec::new();
|
||||
|
||||
for job_id in stuck_ids {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
if ctx.state == JobState::Stuck {
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.state == JobState::Stuck
|
||||
{
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
}
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+491
-5
@@ -121,6 +121,18 @@ pub enum ThreadState {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingApproval {
|
||||
@@ -158,6 +170,13 @@ pub struct Thread {
|
||||
/// Pending approval request (when state is AwaitingApproval).
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -173,6 +192,25 @@ impl Thread {
|
||||
updated_at: now,
|
||||
metadata: serde_json::Value::Null,
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn interrupt(&mut self) {
|
||||
if let Some(turn) = self.turns.last_mut() {
|
||||
@@ -296,11 +346,11 @@ impl Thread {
|
||||
let mut turn = Turn::new(turn_number, &msg.content);
|
||||
|
||||
// Check if next is assistant response
|
||||
if let Some(next) = iter.peek() {
|
||||
if next.role == crate::llm::Role::Assistant {
|
||||
let response = iter.next().expect("peeked");
|
||||
turn.complete(&response.content);
|
||||
}
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == crate::llm::Role::Assistant
|
||||
{
|
||||
let response = iter.next().expect("peeked");
|
||||
turn.complete(&response.content);
|
||||
}
|
||||
|
||||
self.turns.push(turn);
|
||||
@@ -511,4 +561,440 @@ mod tests {
|
||||
assert_eq!(thread.turns[1].user_input, "How are you?");
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
||||
// Fast path
|
||||
@@ -164,10 +199,10 @@ impl SessionManager {
|
||||
{
|
||||
let sessions = self.sessions.read().await;
|
||||
for user_id in &stale_users {
|
||||
if let Some(session) = sessions.get(user_id) {
|
||||
if let Ok(sess) = session.try_lock() {
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
if let Some(session) = sessions.get(user_id)
|
||||
&& let Ok(sess) = session.try_lock()
|
||||
{
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,4 +331,344 @@ mod tests {
|
||||
.await;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+246
-8
@@ -43,22 +43,76 @@ impl SubmissionParser {
|
||||
if lower == "/thread new" || lower == "/new" {
|
||||
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
|
||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||
let rest = rest.trim();
|
||||
if rest != "new" {
|
||||
if let Ok(id) = Uuid::parse_str(rest) {
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
}
|
||||
if rest != "new"
|
||||
&& let Ok(id) = Uuid::parse_str(rest)
|
||||
{
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
}
|
||||
}
|
||||
|
||||
// /resume <uuid> - resume from checkpoint
|
||||
if let Some(rest) = lower.strip_prefix("/resume ") {
|
||||
if let Ok(id) = Uuid::parse_str(rest.trim()) {
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
}
|
||||
if let Some(rest) = lower.strip_prefix("/resume ")
|
||||
&& let Ok(id) = Uuid::parse_str(rest.trim())
|
||||
{
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
}
|
||||
|
||||
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||
if trimmed.starts_with('{')
|
||||
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
|
||||
&& matches!(submission, Submission::ExecApproval { .. })
|
||||
{
|
||||
return submission;
|
||||
}
|
||||
|
||||
// Approval responses (simple yes/no/always for pending approvals)
|
||||
@@ -157,6 +211,18 @@ pub enum Submission {
|
||||
|
||||
/// Suggest next steps based on the current thread.
|
||||
Suggest,
|
||||
|
||||
/// Quit the agent. Bypasses thread-state checks.
|
||||
Quit,
|
||||
|
||||
/// System command (help, model, version, tools, ping, debug).
|
||||
/// Bypasses thread-state checks and safety validation.
|
||||
SystemCommand {
|
||||
/// The command name (e.g. "help", "model", "version").
|
||||
command: String,
|
||||
/// Arguments to the command.
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Submission {
|
||||
@@ -223,6 +289,7 @@ impl Submission {
|
||||
| Self::Heartbeat
|
||||
| Self::Summarize
|
||||
| Self::Suggest
|
||||
| Self::SystemCommand { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -407,4 +474,175 @@ mod tests {
|
||||
let submission = SubmissionParser::parse("/unknown");
|
||||
assert!(matches!(submission, Submission::UserInput { content } if content == "/unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_json_exec_approval() {
|
||||
let req_id = Uuid::new_v4();
|
||||
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||
request_id: req_id,
|
||||
approved: true,
|
||||
always: false,
|
||||
})
|
||||
.expect("serialize");
|
||||
|
||||
let submission = SubmissionParser::parse(&json);
|
||||
assert!(
|
||||
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||
if request_id == req_id && approved && !always)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_json_exec_approval_always() {
|
||||
let req_id = Uuid::new_v4();
|
||||
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||
request_id: req_id,
|
||||
approved: true,
|
||||
always: true,
|
||||
})
|
||||
.expect("serialize");
|
||||
|
||||
let submission = SubmissionParser::parse(&json);
|
||||
assert!(
|
||||
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||
if request_id == req_id && approved && always)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_json_exec_approval_deny() {
|
||||
let req_id = Uuid::new_v4();
|
||||
let json = serde_json::to_string(&Submission::ExecApproval {
|
||||
request_id: req_id,
|
||||
approved: false,
|
||||
always: false,
|
||||
})
|
||||
.expect("serialize");
|
||||
|
||||
let submission = SubmissionParser::parse(&json);
|
||||
assert!(
|
||||
matches!(submission, Submission::ExecApproval { request_id, approved, always }
|
||||
if request_id == req_id && !approved && !always)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_json_non_approval_stays_user_input() {
|
||||
// A JSON UserInput should NOT be intercepted, it should be treated as text
|
||||
let json = r#"{"UserInput":{"content":"hello"}}"#;
|
||||
let submission = SubmissionParser::parse(json);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+162
-35
@@ -10,8 +10,8 @@ use uuid::Uuid;
|
||||
use crate::agent::scheduler::WorkerMessage;
|
||||
use crate::agent::task::TaskOutput;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::Error;
|
||||
use crate::history::Store;
|
||||
use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
@@ -28,7 +28,7 @@ pub struct WorkerDeps {
|
||||
pub llm: Arc<dyn LlmProvider>,
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub store: Option<Arc<Store>>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub timeout: Duration,
|
||||
pub use_planning: bool,
|
||||
}
|
||||
@@ -67,7 +67,7 @@ impl Worker {
|
||||
&self.deps.tools
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&Arc<Store>> {
|
||||
fn store(&self) -> Option<&Arc<dyn Database>> {
|
||||
self.deps.store.as_ref()
|
||||
}
|
||||
|
||||
@@ -227,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Check for cancellation
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
|
||||
if ctx.state == JobState::Cancelled {
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||
&& ctx.state == JobState::Cancelled
|
||||
{
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
iteration += 1;
|
||||
@@ -248,16 +248,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
|
||||
if selections.is_empty() {
|
||||
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
||||
let respond_result = reasoning.respond_with_tools(reason_ctx).await?;
|
||||
let respond_output = reasoning.respond_with_tools(reason_ctx).await?;
|
||||
|
||||
match respond_result {
|
||||
match respond_output.result {
|
||||
RespondResult::Text(response) => {
|
||||
// Check for completion keywords
|
||||
let response_lower = response.to_lowercase();
|
||||
if response_lower.contains("complete")
|
||||
|| response_lower.contains("finished")
|
||||
|| response_lower.contains("done")
|
||||
{
|
||||
// Check for explicit completion phrases. Use word-boundary
|
||||
// aware checks to avoid false positives like "incomplete",
|
||||
// "not done", or "unfinished". Only the LLM's own response
|
||||
// (not tool output) can trigger this.
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
self.mark_completed().await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -272,7 +271,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
|
||||
tracing::debug!(
|
||||
"Job {} respond_with_tools returned {} tool calls",
|
||||
@@ -280,6 +282,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
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 {
|
||||
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
||||
|
||||
@@ -289,6 +299,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
parameters: tc.arguments.clone(),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: tc.id.clone(),
|
||||
};
|
||||
|
||||
self.process_tool_result(reason_ctx, &selection, result)
|
||||
@@ -371,7 +382,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tools: Arc<ToolRegistry>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
store: Option<Arc<Store>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
@@ -417,14 +428,51 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.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 result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||
let result = tokio::time::timeout(tool_timeout, async {
|
||||
tool.execute(params.clone(), &job_ctx).await
|
||||
})
|
||||
.await;
|
||||
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
|
||||
let action = match &result {
|
||||
Ok(Ok(output)) => {
|
||||
@@ -479,7 +527,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
let output = result
|
||||
.map_err(|_| crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: Duration::from_secs(60),
|
||||
timeout: tool_timeout,
|
||||
})?
|
||||
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
@@ -518,17 +566,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
);
|
||||
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
"tool_call_id",
|
||||
&selection.tool_call_id,
|
||||
&selection.tool_name,
|
||||
wrapped,
|
||||
));
|
||||
|
||||
// Check if job is complete
|
||||
if output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") {
|
||||
self.mark_completed().await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Tool output never drives job completion. A malicious tool could
|
||||
// emit "TASK_COMPLETE" to force premature completion. Only the LLM's
|
||||
// own structured response (in execution_loop) can mark a job done.
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -553,7 +598,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
"tool_call_id",
|
||||
&selection.tool_call_id,
|
||||
&selection.tool_name,
|
||||
format!("Error: {}", e),
|
||||
));
|
||||
@@ -603,12 +648,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.execute_tool(&action.tool_name, &action.parameters)
|
||||
.await;
|
||||
|
||||
// Create a synthetic ToolSelection for process_tool_result
|
||||
// Create a synthetic ToolSelection for process_tool_result.
|
||||
// Plan actions don't originate from an LLM tool_call response so
|
||||
// there is no real tool_call_id; generate a unique one.
|
||||
let selection = ToolSelection {
|
||||
tool_name: action.tool_name.clone(),
|
||||
parameters: action.parameters.clone(),
|
||||
reasoning: action.reasoning.clone(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: format!("plan_{}_{}", self.job_id, i),
|
||||
};
|
||||
|
||||
// Process the result
|
||||
@@ -632,11 +680,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
let response = reasoning.respond(reason_ctx).await?;
|
||||
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
||||
|
||||
let response_lower = response.to_lowercase();
|
||||
if response_lower.contains("complete")
|
||||
|| response_lower.contains("finished")
|
||||
|| response_lower.contains("done")
|
||||
{
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
self.mark_completed().await?;
|
||||
} else {
|
||||
// Job not complete, could re-plan or fall back to direct selection
|
||||
@@ -731,3 +775,86 @@ impl From<TaskOutput> for Result<String, Error> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::llm::ToolSelection;
|
||||
use crate::util::llm_signals_completion;
|
||||
|
||||
#[test]
|
||||
fn test_tool_selection_preserves_call_id() {
|
||||
let selection = ToolSelection {
|
||||
tool_name: "memory_search".to_string(),
|
||||
parameters: serde_json::json!({"query": "test"}),
|
||||
reasoning: "Need to search memory".to_string(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_abc123".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(selection.tool_call_id, "call_abc123");
|
||||
assert_ne!(
|
||||
selection.tool_call_id, "tool_call_id",
|
||||
"tool_call_id must not be the hardcoded placeholder string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_positive_signals() {
|
||||
assert!(llm_signals_completion("The job is complete."));
|
||||
assert!(llm_signals_completion(
|
||||
"I have completed the task successfully."
|
||||
));
|
||||
assert!(llm_signals_completion("The task is done."));
|
||||
assert!(llm_signals_completion("The task is finished."));
|
||||
assert!(llm_signals_completion(
|
||||
"All steps are complete and verified."
|
||||
));
|
||||
assert!(llm_signals_completion(
|
||||
"I've done all the work. The work is done."
|
||||
));
|
||||
assert!(llm_signals_completion(
|
||||
"Successfully completed the migration."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_negative_signals_block_false_positives() {
|
||||
// These contain completion keywords but also negation, should NOT trigger.
|
||||
assert!(!llm_signals_completion("The task is not complete yet."));
|
||||
assert!(!llm_signals_completion("This is not done."));
|
||||
assert!(!llm_signals_completion("The work is incomplete."));
|
||||
assert!(!llm_signals_completion(
|
||||
"The migration is not yet finished."
|
||||
));
|
||||
assert!(!llm_signals_completion("The job isn't done yet."));
|
||||
assert!(!llm_signals_completion("This remains unfinished."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_does_not_match_bare_substrings() {
|
||||
// Bare words embedded in other text should NOT trigger completion.
|
||||
assert!(!llm_signals_completion(
|
||||
"I need to complete more work first."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"Let me finish the remaining steps."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"I'm done analyzing, now let me fix it."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"I completed step 1 but step 2 remains."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_tool_output_injection() {
|
||||
// A malicious tool output echoed by the LLM should not trigger
|
||||
// completion unless it forms a genuine completion phrase.
|
||||
assert!(!llm_signals_completion("TASK_COMPLETE"));
|
||||
assert!(!llm_signals_completion("JOB_DONE"));
|
||||
assert!(!llm_signals_completion(
|
||||
"The tool returned: TASK_COMPLETE signal"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Bootstrap helpers for IronClaw.
|
||||
//!
|
||||
//! The only setting that truly needs disk persistence before the database is
|
||||
//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
|
||||
//! it). Everything else is auto-detected or read from env vars.
|
||||
//!
|
||||
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
|
||||
pub fn ironclaw_env_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join(".env")
|
||||
}
|
||||
|
||||
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
||||
///
|
||||
/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
|
||||
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
||||
/// existing env vars, so the effective priority is:
|
||||
///
|
||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
|
||||
///
|
||||
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
||||
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
||||
/// upgrade from the old config format).
|
||||
pub fn load_ironclaw_env() {
|
||||
let path = ironclaw_env_path();
|
||||
|
||||
if !path.exists() {
|
||||
// One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
|
||||
migrate_bootstrap_json_to_env(&path);
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let _ = dotenvy::from_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
|
||||
fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
||||
let ironclaw_dir = env_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let bootstrap_path = ironclaw_dir.join("bootstrap.json");
|
||||
|
||||
if !bootstrap_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&bootstrap_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Minimal parse: just grab database_url from the JSON
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
|
||||
if let Some(parent) = env_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
eprintln!("Warning: failed to create {}: {}", parent.display(), e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
|
||||
eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
|
||||
return;
|
||||
}
|
||||
rename_to_migrated(&bootstrap_path);
|
||||
eprintln!(
|
||||
"Migrated DATABASE_URL from bootstrap.json to {}",
|
||||
env_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write database bootstrap vars to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// These settings form the chicken-and-egg layer: they must be available
|
||||
/// from the filesystem (env vars) BEFORE any database connection, because
|
||||
/// they determine which database to connect to. Everything else is stored
|
||||
/// in the database itself.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist.
|
||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// and other shell-special characters are preserved by dotenvy.
|
||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut content = String::new();
|
||||
for (key, value) in vars {
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||
}
|
||||
std::fs::write(&path, content)
|
||||
}
|
||||
|
||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
|
||||
/// paths. Prefer `save_bootstrap_env` for new code.
|
||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||
save_bootstrap_env(&[("DATABASE_URL", url)])
|
||||
}
|
||||
|
||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||
///
|
||||
/// Only runs when a `settings.json` exists on disk AND the DB has no settings
|
||||
/// yet. After the wizard writes directly to the DB, this path is only hit by
|
||||
/// users upgrading from the old disk-only configuration.
|
||||
///
|
||||
/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
|
||||
pub async fn migrate_disk_to_db(
|
||||
store: &dyn crate::db::Database,
|
||||
user_id: &str,
|
||||
) -> Result<(), MigrationError> {
|
||||
let ironclaw_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
||||
|
||||
if !legacy_settings_path.exists() {
|
||||
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If DB already has settings, this is not a first boot, the wizard already
|
||||
// wrote directly to the DB. Just clean up the stale file.
|
||||
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::info!("DB already has settings, renaming stale settings.json");
|
||||
rename_to_migrated(&legacy_settings_path);
|
||||
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 DATABASE_URL to ~/.ironclaw/.env
|
||||
if let Some(ref url) = settings.database_url {
|
||||
save_database_url(url)
|
||||
.map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
|
||||
tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
|
||||
}
|
||||
|
||||
// 3. Migrate mcp-servers.json if it exists
|
||||
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_token", &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);
|
||||
|
||||
// 6. Clean up old bootstrap.json if it exists (superseded by .env)
|
||||
let old_bootstrap = ironclaw_dir.join("bootstrap.json");
|
||||
if old_bootstrap.exists() {
|
||||
rename_to_migrated(&old_bootstrap);
|
||||
tracing::info!("Renamed old bootstrap.json to .migrated");
|
||||
}
|
||||
|
||||
tracing::info!("Disk-to-DB migration complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a file to `<name>.migrated` as a safety net.
|
||||
fn rename_to_migrated(path: &std::path::Path) {
|
||||
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_save_and_load_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write in the quoted format that save_database_url uses
|
||||
let url = "postgres://localhost:5432/ironclaw_test";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
// Verify the content is a valid dotenv line (quoted)
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
|
||||
);
|
||||
|
||||
// Verify dotenvy can parse it (strips quotes automatically)
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_database_url_with_hash_in_password() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// URLs with # in the password are common (URL-encoded special chars).
|
||||
// Without quoting, dotenvy treats # as a comment delimiter.
|
||||
let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_database_url_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let nested = dir.path().join("deep").join("nested");
|
||||
let env_path = nested.join(".env");
|
||||
|
||||
// Parent doesn't exist yet
|
||||
assert!(!nested.exists());
|
||||
|
||||
// The global function uses a fixed path, so we test the logic directly
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();
|
||||
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert!(content.contains("DATABASE_URL=postgres://test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_env_path() {
|
||||
let path = ironclaw_env_path();
|
||||
assert!(path.ends_with(".ironclaw/.env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_to_env() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// Write a legacy bootstrap.json
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"database_url": "postgres://localhost/ironclaw_upgrade",
|
||||
"database_pool_size": 5,
|
||||
"secrets_master_key_source": "keychain",
|
||||
"onboard_completed": true
|
||||
});
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!env_path.exists());
|
||||
assert!(bootstrap_path.exists());
|
||||
|
||||
// Run the migration
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should now exist with DATABASE_URL
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
|
||||
);
|
||||
|
||||
// bootstrap.json should be renamed to .migrated
|
||||
assert!(!bootstrap_path.exists());
|
||||
assert!(dir.path().join("bootstrap.json.migrated").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_no_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// bootstrap.json with no database_url
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"onboard_completed": false
|
||||
});
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should NOT be created
|
||||
assert!(!env_path.exists());
|
||||
// bootstrap.json should remain (no migration happened)
|
||||
assert!(bootstrap_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// No bootstrap.json at all
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// Nothing should happen
|
||||
assert!(!env_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_multiple_vars() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join("nested").join(".env");
|
||||
|
||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "libsql"),
|
||||
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
|
||||
];
|
||||
|
||||
// Write manually to the temp path (save_bootstrap_env uses the global path)
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
// Verify dotenvy can parse all entries
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(
|
||||
parsed[0],
|
||||
("DATABASE_BACKEND".to_string(), "libsql".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed[1],
|
||||
(
|
||||
"LIBSQL_PATH".to_string(),
|
||||
"/home/user/.ironclaw/ironclaw.db".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_overwrites_previous() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write initial content
|
||||
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
|
||||
|
||||
// Overwrite with new vars (simulating save_bootstrap_env behavior)
|
||||
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
|
||||
std::fs::write(&env_path, content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
// Old DATABASE_URL should be gone
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
|
||||
}
|
||||
}
|
||||
@@ -108,10 +108,38 @@ pub enum StatusUpdate {
|
||||
ToolStarted { name: String },
|
||||
/// Tool execution completed.
|
||||
ToolCompleted { name: String, success: bool },
|
||||
/// Brief preview of tool execution output.
|
||||
ToolResult { name: String, preview: String },
|
||||
/// Streaming text chunk.
|
||||
StreamChunk(String),
|
||||
/// General status message.
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -32,8 +31,6 @@ struct HttpChannelState {
|
||||
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
/// Pending responses keyed by message ID.
|
||||
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).
|
||||
webhook_secret: Option<String>,
|
||||
/// Fixed user ID for this HTTP channel.
|
||||
@@ -74,7 +71,6 @@ impl HttpChannel {
|
||||
state: Arc::new(HttpChannelState {
|
||||
tx: RwLock::new(None),
|
||||
pending_responses: RwLock::new(std::collections::HashMap::new()),
|
||||
shutdown_tx: RwLock::new(None),
|
||||
webhook_secret,
|
||||
user_id,
|
||||
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)]
|
||||
@@ -303,53 +317,11 @@ impl Channel for HttpChannel {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
*self.state.tx.write().await = Some(tx);
|
||||
|
||||
let state = self.state.clone();
|
||||
let host = self.config.host.clone();
|
||||
let port = 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);
|
||||
}
|
||||
});
|
||||
tracing::info!(
|
||||
"HTTP channel ready ({}:{})",
|
||||
self.config.host,
|
||||
self.config.port
|
||||
);
|
||||
|
||||
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) {
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
// Check if we have an active sender
|
||||
if self.state.tx.read().await.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -380,11 +349,6 @@ impl Channel for HttpChannel {
|
||||
}
|
||||
|
||||
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;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+7
-5
@@ -9,9 +9,9 @@
|
||||
//! ┌─────────────────────────────────────────────────────────────────────┐
|
||||
//! │ ChannelManager │
|
||||
//! │ │
|
||||
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │
|
||||
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
//! │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │
|
||||
//! │ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
//! │ │ │ │ │
|
||||
//! │ └─────────────────┴─────────────────┘ │
|
||||
//! │ │ │
|
||||
@@ -28,14 +28,16 @@
|
||||
//! See the [`wasm`] module for details.
|
||||
|
||||
mod channel;
|
||||
pub mod cli;
|
||||
mod http;
|
||||
mod manager;
|
||||
mod repl;
|
||||
pub mod wasm;
|
||||
pub mod web;
|
||||
mod webhook_server;
|
||||
|
||||
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
pub use cli::{AppEvent, TuiChannel};
|
||||
pub use http::HttpChannel;
|
||||
pub use manager::ChannelManager;
|
||||
pub use repl::ReplChannel;
|
||||
pub use web::GatewayChannel;
|
||||
pub use webhook_server::{WebhookServer, WebhookServerConfig};
|
||||
|
||||
+392
-80
@@ -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
|
||||
//!
|
||||
@@ -14,23 +16,174 @@
|
||||
//! - `/new` - Start a new thread
|
||||
//! - `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::atomic::{AtomicBool, Ordering};
|
||||
|
||||
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_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::agent::truncate_for_preview;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// REPL channel for interactive agent debugging.
|
||||
/// Max characters for tool result previews in the terminal.
|
||||
const CLI_TOOL_RESULT_MAX: usize = 200;
|
||||
|
||||
/// Max characters for thinking/status messages in the terminal.
|
||||
const CLI_STATUS_MAX: usize = 200;
|
||||
|
||||
/// 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 {
|
||||
/// Optional single message to send (for -m flag).
|
||||
single_message: Option<String>,
|
||||
/// Debug mode flag (shared with input thread).
|
||||
debug_mode: Arc<AtomicBool>,
|
||||
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||
is_streaming: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ReplChannel {
|
||||
@@ -39,6 +192,7 @@ impl ReplChannel {
|
||||
Self {
|
||||
single_message: None,
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +201,7 @@ impl ReplChannel {
|
||||
Self {
|
||||
single_message: Some(message),
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,32 +217,41 @@ impl Default for ReplChannel {
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
r#"
|
||||
IronClaw REPL - Interactive debugging mode
|
||||
// Bold white for section headers, bold cyan for commands, dim gray for descriptions
|
||||
let h = "\x1b[1m"; // bold (section headers)
|
||||
let c = "\x1b[1;36m"; // bold cyan (commands)
|
||||
let d = "\x1b[90m"; // dim gray (descriptions)
|
||||
let r = "\x1b[0m"; // reset
|
||||
|
||||
Commands:
|
||||
/help Show this help message
|
||||
/quit, /exit Exit the REPL
|
||||
/debug Toggle debug mode (verbose output)
|
||||
/undo Undo the last turn
|
||||
/redo Redo an undone turn
|
||||
/clear Clear the conversation
|
||||
/compact Compact the context window
|
||||
/new Start a new conversation thread
|
||||
/interrupt Stop the current operation
|
||||
println!();
|
||||
println!(" {h}IronClaw REPL{r}");
|
||||
println!();
|
||||
println!(" {h}Commands{r}");
|
||||
println!(" {c}/help{r} {d}show this help{r}");
|
||||
println!(" {c}/debug{r} {d}toggle verbose output{r}");
|
||||
println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}");
|
||||
println!();
|
||||
println!(" {h}Conversation{r}");
|
||||
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):
|
||||
yes, y Approve the tool execution
|
||||
no, n Deny the tool execution
|
||||
always Approve and auto-approve this tool for the session
|
||||
|
||||
Tips:
|
||||
- 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
|
||||
"#
|
||||
);
|
||||
/// Get the history file path (~/.ironclaw/history).
|
||||
fn history_path() -> std::path::PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("history")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -102,47 +266,60 @@ impl Channel for ReplChannel {
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
|
||||
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 {
|
||||
let incoming = IncomingMessage::new("repl", "user", &msg);
|
||||
if tx.blocking_send(incoming).is_err() {
|
||||
return;
|
||||
}
|
||||
// Wait a bit for response, then the channel will close
|
||||
let incoming = IncomingMessage::new("repl", "default", &msg);
|
||||
let _ = tx.blocking_send(incoming);
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive REPL mode
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
// Set up rustyline
|
||||
let config = Config::builder()
|
||||
.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!();
|
||||
|
||||
loop {
|
||||
// Print prompt
|
||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||
"[debug] > "
|
||||
"\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m "
|
||||
} else {
|
||||
"> "
|
||||
"\x1b[1;36m\u{203A}\x1b[0m "
|
||||
};
|
||||
print!("{}", prompt);
|
||||
let _ = stdout.flush();
|
||||
|
||||
// Read line
|
||||
let mut line = String::new();
|
||||
match stdin.lock().read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
match rl.readline(prompt) {
|
||||
Ok(line) => {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle local REPL commands
|
||||
// Handle local REPL commands (only commands that need
|
||||
// immediate local handling stay here)
|
||||
match line.to_lowercase().as_str() {
|
||||
"/quit" | "/exit" => break,
|
||||
"/help" | "/?" => {
|
||||
"/help" => {
|
||||
print_help();
|
||||
continue;
|
||||
}
|
||||
@@ -150,23 +327,42 @@ impl Channel for ReplChannel {
|
||||
let current = debug_mode.load(Ordering::Relaxed);
|
||||
debug_mode.store(!current, Ordering::Relaxed);
|
||||
if !current {
|
||||
println!("Debug mode ON - showing verbose tool output");
|
||||
println!("\x1b[90mdebug mode on\x1b[0m");
|
||||
} else {
|
||||
println!("Debug mode OFF");
|
||||
println!("\x1b[90mdebug mode off\x1b[0m");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let msg = IncomingMessage::new("repl", "user", line);
|
||||
let msg = IncomingMessage::new("repl", "default", line);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
// Ctrl+C: send /interrupt
|
||||
let msg = IncomingMessage::new("repl", "default", "/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", "default", "/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)))
|
||||
@@ -177,8 +373,27 @@ impl Channel for ReplChannel {
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
println!();
|
||||
println!("{}", response.content);
|
||||
let width = crossterm::terminal::size()
|
||||
.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!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -192,38 +407,129 @@ impl Channel for ReplChannel {
|
||||
|
||||
match status {
|
||||
StatusUpdate::Thinking(msg) => {
|
||||
if debug {
|
||||
eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg);
|
||||
} else {
|
||||
eprint!(".");
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::ToolStarted { name } => {
|
||||
if debug {
|
||||
eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name);
|
||||
} else {
|
||||
eprintln!("\x1b[33m⚡ {}\x1b[0m", name);
|
||||
}
|
||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::ToolCompleted { name, success } => {
|
||||
if debug {
|
||||
if success {
|
||||
eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name);
|
||||
} else {
|
||||
eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name);
|
||||
}
|
||||
} else if !success {
|
||||
eprintln!("\x1b[31m✗ {} failed\x1b[0m", name);
|
||||
if success {
|
||||
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
||||
} else {
|
||||
eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m");
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolResult { name: _, preview } => {
|
||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
}
|
||||
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();
|
||||
}
|
||||
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) => {
|
||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||
eprintln!("\x1b[90m[status] {}\x1b[0m", msg);
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m{display}\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 +541,15 @@ impl Channel for ReplChannel {
|
||||
_user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
println!();
|
||||
println!("\x1b[36m[notification]\x1b[0m {}", response.content);
|
||||
println!();
|
||||
let skin = make_skin();
|
||||
let width = crossterm::terminal::size()
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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::schema::ChannelCapabilitiesFile;
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::pairing::PairingStore;
|
||||
|
||||
/// Loads WASM channels from the filesystem.
|
||||
pub struct WasmChannelLoader {
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
}
|
||||
|
||||
impl WasmChannelLoader {
|
||||
/// Create a new loader with the given runtime.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
/// Create a new loader with the given runtime and pairing store.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
pairing_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a single WASM channel from a file pair.
|
||||
@@ -114,7 +119,13 @@ impl WasmChannelLoader {
|
||||
.await?;
|
||||
|
||||
// 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!(
|
||||
name = name,
|
||||
@@ -151,17 +162,18 @@ impl WasmChannelLoader {
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
|
||||
// Only process .wasm files
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract channel name from filename
|
||||
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
@@ -173,15 +185,20 @@ impl WasmChannelLoader {
|
||||
}
|
||||
};
|
||||
|
||||
// Look for sidecar capabilities file
|
||||
let cap_path = path.with_extension("capabilities.json");
|
||||
let cap_path_option = if cap_path.exists() {
|
||||
Some(cap_path.as_path())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_cap = cap_path.exists();
|
||||
channel_entries.push((name, path, if has_cap { Some(cap_path) } 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) => {
|
||||
results.loaded.push(loaded);
|
||||
}
|
||||
@@ -346,6 +363,7 @@ mod tests {
|
||||
|
||||
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
use crate::pairing::PairingStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -402,7 +420,7 @@ mod tests {
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
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 wasm_path = dir.path().join("test.wasm");
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod bundled;
|
||||
mod capabilities;
|
||||
mod error;
|
||||
mod host;
|
||||
@@ -88,6 +89,7 @@ mod schema;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel};
|
||||
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
||||
pub use error::WasmChannelError;
|
||||
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
||||
@@ -95,9 +97,7 @@ pub use loader::{
|
||||
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
|
||||
discover_channels,
|
||||
};
|
||||
pub use router::{
|
||||
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
|
||||
};
|
||||
pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router};
|
||||
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||
pub use schema::{
|
||||
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! registered paths. Handles secret validation at the host level.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
@@ -469,56 +468,6 @@ pub fn create_wasm_channel_router(
|
||||
.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)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -529,6 +478,7 @@ mod tests {
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::tools::wasm::ResourceLimits;
|
||||
|
||||
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
||||
@@ -550,6 +500,7 @@ mod tests {
|
||||
prepared,
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+413
-53
@@ -48,6 +48,7 @@ use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
|
||||
use crate::channels::wasm::schema::ChannelConfig;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::tools::wasm::LogLevel;
|
||||
use crate::tools::wasm::WasmResourceLimiter;
|
||||
@@ -73,6 +74,11 @@ struct ChannelStoreData {
|
||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||
credentials: HashMap<String, String>,
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
|
||||
/// Reused across multiple `http_request` calls within one execution.
|
||||
http_runtime: Option<tokio::runtime::Runtime>,
|
||||
}
|
||||
|
||||
impl ChannelStoreData {
|
||||
@@ -81,6 +87,7 @@ impl ChannelStoreData {
|
||||
channel_name: &str,
|
||||
capabilities: ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Self {
|
||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||
let wasi = WasiCtxBuilder::new().build();
|
||||
@@ -91,6 +98,8 @@ impl ChannelStoreData {
|
||||
wasi,
|
||||
table: ResourceTable::new(),
|
||||
credentials,
|
||||
pairing_store,
|
||||
http_runtime: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,18 +138,34 @@ impl ChannelStoreData {
|
||||
if result.contains('{') && result.contains('}') {
|
||||
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
||||
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
||||
if let Some(re) = brace_pattern {
|
||||
if re.is_match(&result) {
|
||||
tracing::warn!(
|
||||
context = %context,
|
||||
"String may contain unresolved credential placeholders"
|
||||
);
|
||||
}
|
||||
if let Some(re) = brace_pattern
|
||||
&& re.is_match(&result)
|
||||
{
|
||||
tracing::warn!(
|
||||
context = %context,
|
||||
"String may contain unresolved credential placeholders"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -187,6 +212,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
url: String,
|
||||
headers_json: String,
|
||||
body: Option<Vec<u8>>,
|
||||
timeout_ms: Option<u32>,
|
||||
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
||||
tracing::info!(
|
||||
method = %method,
|
||||
@@ -251,10 +277,35 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
.scan_http_request(&url, &header_vec, body.as_deref())
|
||||
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
||||
|
||||
// Make the HTTP request using blocking I/O
|
||||
// We're already in a spawn_blocking context, so we can use block_on
|
||||
let result = tokio::runtime::Handle::current().block_on(async {
|
||||
let client = reqwest::Client::new();
|
||||
// Get the max response size from capabilities (default 10MB).
|
||||
let max_response_bytes = self
|
||||
.host_state
|
||||
.capabilities()
|
||||
.tool_capabilities
|
||||
.http
|
||||
.as_ref()
|
||||
.map(|h| h.max_response_bytes)
|
||||
.unwrap_or(10 * 1024 * 1024);
|
||||
|
||||
// Make the HTTP request using a dedicated single-threaded runtime.
|
||||
// We're inside spawn_blocking, so we can't rely on the main runtime's
|
||||
// I/O driver (it may be busy with WASM compilation or other startup work).
|
||||
// A dedicated runtime gives us our own I/O driver and avoids contention.
|
||||
// The runtime is lazily created and reused across calls within one execution.
|
||||
if self.http_runtime.is_none() {
|
||||
self.http_runtime = Some(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP runtime: {e}"))?,
|
||||
);
|
||||
}
|
||||
let rt = self.http_runtime.as_ref().expect("just initialized");
|
||||
let result = rt.block_on(async {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
@@ -276,12 +327,21 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
request = request.body(body_bytes);
|
||||
}
|
||||
|
||||
// Send request with timeout
|
||||
let response = request
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
// Send request with caller-specified timeout (default 30s, max 5min).
|
||||
let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64;
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
let response = request.timeout(timeout).send().await.map_err(|e| {
|
||||
// Walk the full error chain so we get the actual root cause
|
||||
// (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 response_headers: std::collections::HashMap<String, String> = response
|
||||
@@ -294,11 +354,29 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
})
|
||||
.collect();
|
||||
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
|
||||
|
||||
// Enforce max response body size to prevent memory exhaustion.
|
||||
let max_response = max_response_bytes;
|
||||
if let Some(cl) = response.content_length()
|
||||
&& cl as usize > max_response
|
||||
{
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?
|
||||
.to_vec();
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
if body.len() > max_response {
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
body.len(),
|
||||
max_response
|
||||
));
|
||||
}
|
||||
let body = body.to_vec();
|
||||
|
||||
tracing::info!(
|
||||
status = status,
|
||||
@@ -330,6 +408,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 {
|
||||
Ok(resp) => {
|
||||
tracing::info!(status = resp.status, "http_request completed successfully");
|
||||
@@ -372,6 +455,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.
|
||||
@@ -424,6 +544,9 @@ pub struct WasmChannel {
|
||||
/// Background task that repeats typing indicators every 4 seconds.
|
||||
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
||||
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
@@ -433,6 +556,7 @@ impl WasmChannel {
|
||||
prepared: Arc<PreparedChannelModule>,
|
||||
capabilities: ChannelCapabilities,
|
||||
config_json: String,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Self {
|
||||
let name = prepared.name.clone();
|
||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||
@@ -452,6 +576,7 @@ impl WasmChannel {
|
||||
endpoints: RwLock::new(Vec::new()),
|
||||
credentials: Arc::new(RwLock::new(HashMap::new())),
|
||||
typing_task: RwLock::new(None),
|
||||
pairing_store,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,6 +658,7 @@ impl WasmChannel {
|
||||
prepared: &PreparedChannelModule,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||
let engine = runtime.engine();
|
||||
let limits = &prepared.limits;
|
||||
@@ -543,6 +669,7 @@ impl WasmChannel {
|
||||
&prepared.name,
|
||||
capabilities.clone(),
|
||||
credentials,
|
||||
pairing_store,
|
||||
);
|
||||
let mut store = Store::new(engine, store_data);
|
||||
|
||||
@@ -643,12 +770,18 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_start using the generated typed interface
|
||||
@@ -681,7 +814,21 @@ impl WasmChannel {
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok((config, _host_state))) => {
|
||||
Ok(Ok((config, mut host_state))) => {
|
||||
// Surface WASM guest logs (errors/warnings from webhook setup, etc.)
|
||||
for entry in host_state.take_logs() {
|
||||
match entry.level {
|
||||
crate::tools::wasm::LogLevel::Error => {
|
||||
tracing::error!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Warn => {
|
||||
tracing::warn!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
display_name = %config.display_name,
|
||||
@@ -753,6 +900,7 @@ impl WasmChannel {
|
||||
let capabilities = self.capabilities.clone();
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare request data
|
||||
let method = method.to_string();
|
||||
@@ -766,8 +914,13 @@ impl WasmChannel {
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Build the WIT request type
|
||||
@@ -840,12 +993,18 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
@@ -929,6 +1088,7 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare response data
|
||||
let message_id_str = message_id.to_string();
|
||||
@@ -942,8 +1102,13 @@ impl WasmChannel {
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
tracing::info!("Creating WASM store for on_respond");
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
|
||||
tracing::info!("Instantiating WASM component for on_respond");
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1036,13 +1201,19 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let wit_update = status_to_wit(status, metadata);
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
@@ -1080,12 +1251,14 @@ impl WasmChannel {
|
||||
///
|
||||
/// Static method for use by the background typing repeat task (which
|
||||
/// doesn't have access to `&self`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_status(
|
||||
channel_name: &str,
|
||||
runtime: &Arc<WasmChannelRuntime>,
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
wit_update: wit_channel::StatusUpdate,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
@@ -1101,8 +1274,13 @@ impl WasmChannel {
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
let channel_iface = instance.near_agent_channel();
|
||||
@@ -1170,6 +1348,7 @@ impl WasmChannel {
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let wit_update = status_to_wit(&status, metadata);
|
||||
|
||||
@@ -1189,6 +1368,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
wit_update_clone,
|
||||
)
|
||||
@@ -1319,6 +1499,7 @@ impl WasmChannel {
|
||||
let message_tx = self.message_tx.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -1340,14 +1521,15 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(emitted_messages) => {
|
||||
// Process any emitted messages
|
||||
if !emitted_messages.is_empty() {
|
||||
if let Err(e) = Self::dispatch_emitted_messages(
|
||||
if !emitted_messages.is_empty()
|
||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||
&channel_name,
|
||||
emitted_messages,
|
||||
&message_tx,
|
||||
@@ -1359,7 +1541,6 @@ impl WasmChannel {
|
||||
"Failed to dispatch emitted messages from poll"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -1391,6 +1572,7 @@ impl WasmChannel {
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
||||
// Skip if no WASM bytes (testing mode)
|
||||
@@ -1411,8 +1593,13 @@ impl WasmChannel {
|
||||
// Execute in blocking task with timeout
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut store =
|
||||
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||
let mut store = Self::create_store(
|
||||
&runtime,
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
|
||||
// Call on_poll using the generated typed interface
|
||||
@@ -1583,22 +1770,22 @@ impl Channel for WasmChannel {
|
||||
*self.endpoints.write().await = endpoints;
|
||||
|
||||
// Start polling if configured
|
||||
if let Some(poll_config) = &config.poll {
|
||||
if poll_config.enabled {
|
||||
let interval = self
|
||||
.capabilities
|
||||
.validate_poll_interval(poll_config.interval_ms)
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: self.name.clone(),
|
||||
reason: e,
|
||||
})?;
|
||||
if let Some(poll_config) = &config.poll
|
||||
&& poll_config.enabled
|
||||
{
|
||||
let interval = self
|
||||
.capabilities
|
||||
.validate_poll_interval(poll_config.interval_ms)
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: self.name.clone(),
|
||||
reason: e,
|
||||
})?;
|
||||
|
||||
// Create shutdown channel for polling and store the sender to keep it alive
|
||||
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
||||
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
|
||||
// Create shutdown channel for polling and store the sender to keep it alive
|
||||
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
||||
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
|
||||
|
||||
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
|
||||
}
|
||||
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -1826,6 +2013,11 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
|
||||
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 {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: chunk.clone(),
|
||||
@@ -1844,6 +2036,38 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
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 +2139,7 @@ mod tests {
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::tools::wasm::ResourceLimits;
|
||||
|
||||
fn create_test_channel() -> WasmChannel {
|
||||
@@ -1930,7 +2155,13 @@ mod tests {
|
||||
|
||||
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]
|
||||
@@ -2005,6 +2236,7 @@ mod tests {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
Arc::new(PairingStore::new()),
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
@@ -2098,7 +2330,13 @@ mod tests {
|
||||
.with_path("/webhook/poll")
|
||||
.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
|
||||
let _stream = channel.start().await.expect("Channel should start");
|
||||
@@ -2336,4 +2574,126 @@ mod tests {
|
||||
assert_eq!(cloned.message, "hello");
|
||||
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 WASM HTTP host functions work using a dedicated
|
||||
/// current-thread runtime inside spawn_blocking.
|
||||
#[tokio::test]
|
||||
async fn test_dedicated_runtime_inside_spawn_blocking() {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build runtime");
|
||||
rt.block_on(async { 42 })
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking panicked");
|
||||
assert_eq!(result, 42);
|
||||
}
|
||||
|
||||
/// Verify a real HTTP request works using the dedicated-runtime pattern.
|
||||
/// This catches DNS, TLS, and I/O driver issues that trivial tests miss.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires network
|
||||
async fn test_dedicated_runtime_real_http() {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build runtime");
|
||||
rt.block_on(async {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build client");
|
||||
let resp = client
|
||||
.get("https://api.telegram.org/bot000/getMe")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(r) => r.status().as_u16(),
|
||||
Err(e) if e.is_timeout() => panic!("request timed out: {e}"),
|
||||
Err(e) => panic!("unexpected error: {e}"),
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking panicked");
|
||||
// 404 because "000" is not a valid bot token
|
||||
assert_eq!(result, 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Bearer token authentication middleware for the web gateway.
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// 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 (constant-time comparison)
|
||||
if let Some(auth_header) = headers.get("authorization")
|
||||
&& let Ok(value) = auth_header.to_str()
|
||||
&& let Some(token) = value.strip_prefix("Bearer ")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
||||
if let Some(query) = request.uri().query() {
|
||||
for pair in query.split('&') {
|
||||
if let Some(token) = pair.strip_prefix("token=")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
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,358 @@
|
||||
//! 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;
|
||||
|
||||
use crate::safety::LeakDetector;
|
||||
|
||||
/// 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>>,
|
||||
/// Scrubs secrets from log messages before broadcasting to SSE clients.
|
||||
leak_detector: LeakDetector,
|
||||
}
|
||||
|
||||
impl LogBroadcaster {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(512);
|
||||
Self {
|
||||
tx,
|
||||
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
|
||||
leak_detector: LeakDetector::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, mut entry: LogEntry) {
|
||||
// Scrub secrets from the message before it reaches any subscriber.
|
||||
// This is defense-in-depth: even if code elsewhere accidentally logs
|
||||
// a secret, it won't be broadcast to SSE clients.
|
||||
entry.message = self
|
||||
.leak_detector
|
||||
.scan_and_clean(&entry.message)
|
||||
.unwrap_or_else(|_| "[log message redacted: contained blocked secret]".to_string());
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// Log messages are scrubbed through `LeakDetector` in `LogBroadcaster::send()`
|
||||
/// (the single funnel point for all log output, including late-joiner history).
|
||||
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),
|
||||
};
|
||||
|
||||
// LeakDetector scrubbing happens inside broadcaster.send()
|
||||
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(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_broadcaster_has_leak_detector() {
|
||||
let broadcaster = LogBroadcaster::new();
|
||||
// Verify the leak detector is initialized with default patterns
|
||||
assert!(broadcaster.leak_detector.pattern_count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leak_detector_scrubs_api_key_in_log() {
|
||||
let detector = crate::safety::LeakDetector::new();
|
||||
let msg = "Connecting with token sk-proj-test1234567890abcdefghij";
|
||||
let result = detector.scan_and_clean(msg);
|
||||
// Should be blocked (OpenAI key pattern)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leak_detector_passes_clean_log() {
|
||||
let detector = crate::safety::LeakDetector::new();
|
||||
let msg = "Request completed status=200 url=https://api.example.com/data";
|
||||
let result = detector.scan_and_clean(msg);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 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 openai_compat;
|
||||
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::db::Database;
|
||||
use crate::error::ChannelError;
|
||||
use crate::extensions::ExtensionManager;
|
||||
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())),
|
||||
llm_provider: None,
|
||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
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(),
|
||||
llm_provider: self.state.llm_provider.clone(),
|
||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
||||
};
|
||||
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<dyn Database>) -> 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
|
||||
}
|
||||
|
||||
/// Inject the LLM provider for OpenAI-compatible API proxy.
|
||||
pub fn with_llm_provider(mut self, llm: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
||||
self.rebuild_state(|s| s.llm_provider = Some(llm));
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
//! 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;
|
||||
|
||||
/// Maximum number of concurrent SSE/WebSocket connections.
|
||||
/// Prevents resource exhaustion from connection flooding.
|
||||
const MAX_CONNECTIONS: u64 = 100;
|
||||
|
||||
/// Manages SSE broadcast to all connected browser tabs.
|
||||
pub struct SseManager {
|
||||
tx: broadcast::Sender<SseEvent>,
|
||||
connection_count: Arc<AtomicU64>,
|
||||
max_connections: u64,
|
||||
}
|
||||
|
||||
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)),
|
||||
max_connections: MAX_CONNECTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Returns `None` if the maximum connection limit has been reached.
|
||||
pub fn subscribe_raw(&self) -> Option<impl Stream<Item = SseEvent> + Send + 'static + use<>> {
|
||||
// Atomically increment only if below the limit. This prevents
|
||||
// concurrent callers from overshooting max_connections.
|
||||
let counter = Arc::clone(&self.connection_count);
|
||||
let max = self.max_connections;
|
||||
counter
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
if current < max {
|
||||
Some(current + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
let rx = self.tx.subscribe();
|
||||
|
||||
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
|
||||
|
||||
Some(CountedStream {
|
||||
inner: stream,
|
||||
counter,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new SSE stream for a client connection.
|
||||
///
|
||||
/// Returns `None` if the maximum connection limit has been reached.
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> {
|
||||
// Atomically increment only if below the limit.
|
||||
let counter = Arc::clone(&self.connection_count);
|
||||
let max = self.max_connections;
|
||||
counter
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
||||
if current < max {
|
||||
Some(current + 1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
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,
|
||||
};
|
||||
|
||||
Some(
|
||||
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().expect("should subscribe"));
|
||||
|
||||
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().expect("should subscribe"));
|
||||
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().expect("should subscribe"));
|
||||
let mut s2 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_raw_rejects_over_limit() {
|
||||
let mut manager = SseManager::new();
|
||||
manager.max_connections = 2; // Low limit for testing
|
||||
|
||||
let _s1 = Box::pin(manager.subscribe_raw().expect("first should succeed"));
|
||||
let _s2 = Box::pin(manager.subscribe_raw().expect("second should succeed"));
|
||||
assert_eq!(manager.connection_count(), 2);
|
||||
|
||||
// Third should be rejected
|
||||
assert!(manager.subscribe_raw().is_none());
|
||||
assert!(manager.subscribe().is_none());
|
||||
}
|
||||
}
|
||||
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,492 @@
|
||||
//! 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).
|
||||
// Reject if we've hit the connection limit.
|
||||
let Some(raw_stream) = state.sse.subscribe_raw() else {
|
||||
tracing::warn!("WebSocket rejected: too many connections");
|
||||
// Decrement the WS tracker we already incremented above.
|
||||
if let Some(ref tracker) = tracker_for_drop {
|
||||
tracker.decrement();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut event_stream = Box::pin(raw_stream);
|
||||
|
||||
// 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())),
|
||||
llm_provider: None,
|
||||
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-58
@@ -1,6 +1,9 @@
|
||||
//! Configuration management CLI commands.
|
||||
//!
|
||||
//! Commands for viewing and modifying settings.
|
||||
//! Settings are stored in the database (env > DB > default).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
@@ -36,41 +39,81 @@ pub enum ConfigCommand {
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Show the settings file path
|
||||
/// Show the settings storage info
|
||||
Path,
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
// Try to connect to the DB for settings access
|
||||
let db: Option<Arc<dyn crate::db::Database>> = match connect_db().await {
|
||||
Ok(d) => Some(d),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: Could not connect to database ({}), using disk fallback",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let db_ref = db.as_deref();
|
||||
match cmd {
|
||||
ConfigCommand::List { filter } => list_settings(filter),
|
||||
ConfigCommand::Get { path } => get_setting(&path),
|
||||
ConfigCommand::Set { path, value } => set_setting(&path, &value),
|
||||
ConfigCommand::Reset { path } => reset_setting(&path),
|
||||
ConfigCommand::Path => show_path(),
|
||||
ConfigCommand::List { filter } => list_settings(db_ref, filter).await,
|
||||
ConfigCommand::Get { path } => get_setting(db_ref, &path).await,
|
||||
ConfigCommand::Set { path, value } => set_setting(db_ref, &path, &value).await,
|
||||
ConfigCommand::Reset { path } => reset_setting(db_ref, &path).await,
|
||||
ConfigCommand::Path => show_path(db_ref.is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap a DB connection for config commands (backend-agnostic).
|
||||
async fn connect_db() -> anyhow::Result<Arc<dyn crate::db::Database>> {
|
||||
let config = crate::config::Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
crate::db::connect_from_config(&config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||
}
|
||||
|
||||
const DEFAULT_USER_ID: &str = "default";
|
||||
|
||||
/// Load settings: DB if available, else disk.
|
||||
async fn load_settings(store: Option<&dyn crate::db::Database>) -> 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::default()
|
||||
}
|
||||
|
||||
/// List all settings.
|
||||
fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
|
||||
let settings = Settings::load();
|
||||
async fn list_settings(
|
||||
store: Option<&dyn crate::db::Database>,
|
||||
filter: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let settings = load_settings(store).await;
|
||||
let all = settings.list();
|
||||
|
||||
// Find the longest key for alignment
|
||||
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!();
|
||||
|
||||
for (key, value) in all {
|
||||
// Skip if filter is set and doesn't match
|
||||
if let Some(ref f) = filter {
|
||||
if !key.starts_with(f) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref f) = filter
|
||||
&& !key.starts_with(f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Truncate long values for display
|
||||
let display_value = if value.len() > 60 {
|
||||
format!("{}...", &value[..57])
|
||||
} else {
|
||||
@@ -84,8 +127,8 @@ fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Get a specific setting.
|
||||
fn get_setting(path: &str) -> anyhow::Result<()> {
|
||||
let settings = Settings::load();
|
||||
async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
|
||||
let settings = load_settings(store).await;
|
||||
|
||||
match settings.get(path) {
|
||||
Some(value) => {
|
||||
@@ -99,68 +142,63 @@ fn get_setting(path: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Set a setting value.
|
||||
fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
|
||||
let mut settings = Settings::load();
|
||||
async fn set_setting(
|
||||
store: Option<&dyn crate::db::Database>,
|
||||
path: &str,
|
||||
value: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut settings = load_settings(store).await;
|
||||
|
||||
// Try to set the value
|
||||
settings
|
||||
.set(path, value)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
// Save to disk
|
||||
settings.save()?;
|
||||
let store = store.ok_or_else(|| {
|
||||
anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.")
|
||||
})?;
|
||||
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))?;
|
||||
|
||||
println!("Set {} = {}", path, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a setting to default.
|
||||
fn reset_setting(path: &str) -> anyhow::Result<()> {
|
||||
let mut settings = Settings::load();
|
||||
|
||||
// Get the default value for display
|
||||
async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
|
||||
let default = Settings::default();
|
||||
let default_value = default
|
||||
.get(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
||||
|
||||
// Reset it
|
||||
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
// Save to disk
|
||||
settings.save()?;
|
||||
let store = store.ok_or_else(|| {
|
||||
anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.")
|
||||
})?;
|
||||
store
|
||||
.delete_setting(DEFAULT_USER_ID, path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
|
||||
|
||||
println!("Reset {} to default: {}", path, default_value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show the settings file path.
|
||||
fn show_path() -> anyhow::Result<()> {
|
||||
let path = Settings::default_path();
|
||||
println!("{}", 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);
|
||||
}
|
||||
}
|
||||
/// Show the settings storage info.
|
||||
fn show_path(has_db: bool) -> anyhow::Result<()> {
|
||||
if has_db {
|
||||
println!("Settings stored in: database (settings table)");
|
||||
} else {
|
||||
println!(" (does not exist, using defaults)");
|
||||
println!("Settings stored in: PostgreSQL (not connected, using defaults)");
|
||||
}
|
||||
println!(
|
||||
"Env config: {}",
|
||||
crate::bootstrap::ironclaw_env_path().display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+120
-22
@@ -8,14 +8,14 @@ use std::sync::Arc;
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::history::Store;
|
||||
use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||
use crate::db::Database;
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::secrets::PostgresSecretsStore;
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::tools::mcp::{
|
||||
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||
auth::{authorize_mcp_server, is_authenticated},
|
||||
config::{
|
||||
add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers,
|
||||
},
|
||||
config::{self, McpServersFile},
|
||||
};
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
@@ -173,8 +173,11 @@ async fn add_server(
|
||||
// Validate
|
||||
config.validate()?;
|
||||
|
||||
// Save
|
||||
add_mcp_server(config).await?;
|
||||
// Save (DB if available, else disk)
|
||||
let db = connect_db().await;
|
||||
let mut servers = load_servers(db.as_deref()).await?;
|
||||
servers.upsert(config);
|
||||
save_servers(db.as_deref(), &servers).await?;
|
||||
|
||||
println!();
|
||||
println!(" ✓ Added MCP server '{}'", name);
|
||||
@@ -192,7 +195,12 @@ async fn add_server(
|
||||
|
||||
/// Remove an MCP server.
|
||||
async fn remove_server(name: String) -> anyhow::Result<()> {
|
||||
remove_mcp_server(&name).await?;
|
||||
let db = connect_db().await;
|
||||
let mut servers = load_servers(db.as_deref()).await?;
|
||||
if !servers.remove(&name) {
|
||||
anyhow::bail!("Server '{}' not found", name);
|
||||
}
|
||||
save_servers(db.as_deref(), &servers).await?;
|
||||
|
||||
println!();
|
||||
println!(" ✓ Removed MCP server '{}'", name);
|
||||
@@ -203,7 +211,8 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
|
||||
|
||||
/// List configured MCP servers.
|
||||
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
||||
let servers = load_mcp_servers().await?;
|
||||
let db = connect_db().await;
|
||||
let servers = load_servers(db.as_deref()).await?;
|
||||
|
||||
if servers.servers.is_empty() {
|
||||
println!();
|
||||
@@ -261,7 +270,12 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
||||
/// Authenticate with an MCP server.
|
||||
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
// Get server config
|
||||
let server = get_mcp_server(&name).await?;
|
||||
let db = connect_db().await;
|
||||
let servers = load_servers(db.as_deref()).await?;
|
||||
let server = servers
|
||||
.get(&name)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
|
||||
|
||||
// Initialize secrets store
|
||||
let secrets = get_secrets_store().await?;
|
||||
@@ -329,7 +343,12 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
/// Test connection to an MCP server.
|
||||
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
// Get server config
|
||||
let server = get_mcp_server(&name).await?;
|
||||
let db = connect_db().await;
|
||||
let servers = load_servers(db.as_deref()).await?;
|
||||
let server = servers
|
||||
.get(&name)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?;
|
||||
|
||||
println!();
|
||||
println!(" Testing connection to '{}'...", name);
|
||||
@@ -420,7 +439,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
|
||||
/// Toggle server enabled/disabled state.
|
||||
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
|
||||
let mut servers = load_mcp_servers().await?;
|
||||
let db = connect_db().await;
|
||||
let mut servers = load_servers(db.as_deref()).await?;
|
||||
|
||||
let server = servers
|
||||
.get_mut(&name)
|
||||
@@ -435,7 +455,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
||||
};
|
||||
|
||||
server.enabled = new_state;
|
||||
save_mcp_servers(&servers).await?;
|
||||
save_servers(db.as_deref(), &servers).await?;
|
||||
|
||||
let status = if new_state { "enabled" } else { "disabled" };
|
||||
println!();
|
||||
@@ -445,22 +465,100 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DEFAULT_USER_ID: &str = "default";
|
||||
|
||||
/// Try to connect to the database (backend-agnostic).
|
||||
async fn connect_db() -> Option<Arc<dyn Database>> {
|
||||
let config = Config::from_env().await.ok()?;
|
||||
crate::db::connect_from_config(&config.database).await.ok()
|
||||
}
|
||||
|
||||
/// Load MCP servers (DB if available, else disk).
|
||||
async fn load_servers(db: Option<&dyn Database>) -> Result<McpServersFile, config::ConfigError> {
|
||||
if let Some(db) = db {
|
||||
config::load_mcp_servers_from_db(db, DEFAULT_USER_ID).await
|
||||
} else {
|
||||
config::load_mcp_servers().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Save MCP servers (DB if available, else disk).
|
||||
async fn save_servers(
|
||||
db: Option<&dyn Database>,
|
||||
servers: &McpServersFile,
|
||||
) -> Result<(), config::ConfigError> {
|
||||
if let Some(db) = db {
|
||||
config::save_mcp_servers_to_db(db, DEFAULT_USER_ID, servers).await
|
||||
} else {
|
||||
config::save_mcp_servers(servers).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize and return the secrets store.
|
||||
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(|| {
|
||||
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?;
|
||||
store.run_migrations().await?;
|
||||
|
||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||
Ok(Arc::new(PostgresSecretsStore::new(
|
||||
store.pool(),
|
||||
Arc::new(crypto),
|
||||
)))
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
let store = crate::history::Store::new(&config.database).await?;
|
||||
store.run_migrations().await?;
|
||||
Ok(Arc::new(PostgresSecretsStore::new(
|
||||
store.pool(),
|
||||
Arc::new(crypto),
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "libsql", not(feature = "postgres")))]
|
||||
{
|
||||
use crate::db::Database as _;
|
||||
use crate::db::libsql_backend::LibSqlBackend;
|
||||
use secrecy::ExposeSecret as _;
|
||||
|
||||
let default_path = crate::config::default_libsql_path();
|
||||
let db_path = config
|
||||
.database
|
||||
.libsql_path
|
||||
.as_deref()
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = config.database.libsql_url {
|
||||
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
} else {
|
||||
LibSqlBackend::new_local(db_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
};
|
||||
backend
|
||||
.run_migrations()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
return Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
backend.shared_db(),
|
||||
Arc::new(crypto),
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
let _ = crypto;
|
||||
anyhow::bail!(
|
||||
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+26
-1
@@ -9,6 +9,30 @@ use clap::Subcommand;
|
||||
|
||||
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
|
||||
|
||||
/// Run a memory command using the Database trait (works with any backend).
|
||||
pub async fn run_memory_command_with_db(
|
||||
cmd: MemoryCommand,
|
||||
db: std::sync::Arc<dyn crate::db::Database>,
|
||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut workspace = Workspace::new_with_db("default", db);
|
||||
if let Some(emb) = embeddings {
|
||||
workspace = workspace.with_embeddings(emb);
|
||||
}
|
||||
|
||||
match cmd {
|
||||
MemoryCommand::Search { query, limit } => search(&workspace, &query, limit).await,
|
||||
MemoryCommand::Read { path } => read(&workspace, &path).await,
|
||||
MemoryCommand::Write {
|
||||
path,
|
||||
content,
|
||||
append,
|
||||
} => write(&workspace, &path, content, append).await,
|
||||
MemoryCommand::Tree { path, depth } => tree(&workspace, &path, depth).await,
|
||||
MemoryCommand::Status => status(&workspace).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum MemoryCommand {
|
||||
/// Search workspace memory (hybrid full-text + semantic)
|
||||
@@ -55,7 +79,8 @@ pub enum MemoryCommand {
|
||||
Status,
|
||||
}
|
||||
|
||||
/// Run a memory command.
|
||||
/// Run a memory command (PostgreSQL backend).
|
||||
#[cfg(feature = "postgres")]
|
||||
pub async fn run_memory_command(
|
||||
cmd: MemoryCommand,
|
||||
pool: deadpool_postgres::Pool,
|
||||
|
||||
+52
-10
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Provides subcommands for:
|
||||
//! - Running the agent (`run`)
|
||||
//! - Interactive setup wizard (`setup`)
|
||||
//! - Interactive onboarding wizard (`onboard`)
|
||||
//! - Managing configuration (`config list`, `config get`, `config set`)
|
||||
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
||||
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
||||
@@ -12,12 +12,18 @@
|
||||
mod config;
|
||||
mod mcp;
|
||||
pub mod memory;
|
||||
pub mod oauth_defaults;
|
||||
mod pairing;
|
||||
pub mod status;
|
||||
mod tool;
|
||||
|
||||
pub use config::{ConfigCommand, run_config_command};
|
||||
pub use mcp::{McpCommand, run_mcp_command};
|
||||
pub use memory::{MemoryCommand, run_memory_command};
|
||||
pub use memory::MemoryCommand;
|
||||
#[cfg(feature = "postgres")]
|
||||
pub use memory::run_memory_command;
|
||||
pub use memory::run_memory_command_with_db;
|
||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||
pub use status::run_status_command;
|
||||
pub use tool::{ToolCommand, run_tool_command};
|
||||
|
||||
@@ -41,10 +47,6 @@ pub struct Cli {
|
||||
#[arg(long, global = true)]
|
||||
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
|
||||
#[arg(short, long, global = true)]
|
||||
pub message: Option<String>,
|
||||
@@ -53,9 +55,9 @@ pub struct Cli {
|
||||
#[arg(short, long, global = true)]
|
||||
pub config: Option<std::path::PathBuf>,
|
||||
|
||||
/// Skip first-run setup check
|
||||
/// Skip first-run onboarding check
|
||||
#[arg(long, global = true)]
|
||||
pub no_setup: bool,
|
||||
pub no_onboard: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -63,8 +65,8 @@ pub enum Command {
|
||||
/// Run the agent (default if no subcommand given)
|
||||
Run,
|
||||
|
||||
/// Interactive setup wizard
|
||||
Setup {
|
||||
/// Interactive onboarding wizard
|
||||
Onboard {
|
||||
/// Skip authentication (use existing session)
|
||||
#[arg(long)]
|
||||
skip_auth: bool,
|
||||
@@ -90,8 +92,48 @@ pub enum Command {
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommand),
|
||||
|
||||
/// DM pairing (approve inbound requests from unknown senders)
|
||||
#[command(subcommand)]
|
||||
Pairing(PairingCommand),
|
||||
|
||||
/// Show system health and diagnostics
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages.
|
||||
//!
|
||||
//! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login)
|
||||
//! uses the same callback port, landing page, and listener logic from this module.
|
||||
//!
|
||||
//! # Built-in Credentials
|
||||
//!
|
||||
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
|
||||
//! so users don't need to register their own OAuth app. Google explicitly
|
||||
//! documents that client_secret for "Desktop App" / "Installed App" types
|
||||
//! is NOT actually secret.
|
||||
//!
|
||||
//! Default credentials are hardcoded below. They can be overridden at:
|
||||
//!
|
||||
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
|
||||
//! env vars before building to replace the hardcoded defaults.
|
||||
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||
//! env vars, which take priority over built-in defaults.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// ── Built-in credentials ────────────────────────────────────────────────
|
||||
|
||||
pub struct OAuthCredentials {
|
||||
pub client_id: &'static str,
|
||||
pub client_secret: &'static str,
|
||||
}
|
||||
|
||||
/// Google OAuth "Desktop App" credentials, shared across all Google tools.
|
||||
/// Compile-time env vars override the hardcoded defaults below.
|
||||
const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") {
|
||||
Some(v) => v,
|
||||
None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com",
|
||||
};
|
||||
const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") {
|
||||
Some(v) => v,
|
||||
None => "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",
|
||||
};
|
||||
|
||||
/// Returns built-in OAuth credentials for a provider, keyed by secret_name.
|
||||
///
|
||||
/// The secret_name comes from the tool's capabilities.json `auth.secret_name` field.
|
||||
/// Returns `None` if no built-in credentials are configured for that provider.
|
||||
pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
|
||||
match secret_name {
|
||||
"google_oauth_token" => Some(OAuthCredentials {
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared callback server ──────────────────────────────────────────────
|
||||
|
||||
/// Fixed port for all OAuth callbacks.
|
||||
///
|
||||
/// Every redirect URI registered with providers must use this port:
|
||||
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
|
||||
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
|
||||
|
||||
/// Error from the OAuth callback listener.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OAuthCallbackError {
|
||||
#[error("Port {0} is in use (another auth flow running?): {1}")]
|
||||
PortInUse(u16, String),
|
||||
|
||||
#[error("Authorization denied by user")]
|
||||
Denied,
|
||||
|
||||
#[error("Timed out waiting for authorization")]
|
||||
Timeout,
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Bind the OAuth callback listener on the fixed port.
|
||||
///
|
||||
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
|
||||
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
|
||||
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
|
||||
/// than `AddrInUse`. If the port is already occupied, fails immediately.
|
||||
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
||||
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
|
||||
match TcpListener::bind(&ipv4_addr).await {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||
return Err(OAuthCallbackError::PortInUse(
|
||||
OAUTH_CALLBACK_PORT,
|
||||
e.to_string(),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
// IPv4 not available, fall back to IPv6
|
||||
}
|
||||
}
|
||||
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
|
||||
} else {
|
||||
OAuthCallbackError::Io(e.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for an OAuth callback and extract a query parameter value.
|
||||
///
|
||||
/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"),
|
||||
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
|
||||
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
|
||||
///
|
||||
/// Times out after 5 minutes.
|
||||
pub async fn wait_for_callback(
|
||||
listener: TcpListener,
|
||||
path_prefix: &str,
|
||||
param_name: &str,
|
||||
display_name: &str,
|
||||
) -> Result<String, OAuthCallbackError> {
|
||||
let path_prefix = path_prefix.to_string();
|
||||
let param_name = param_name.to_string();
|
||||
let display_name = display_name.to_string();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(300), async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader
|
||||
.read_line(&mut request_line)
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
|
||||
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with(&path_prefix)
|
||||
&& let Some(query) = path.split('?').nth(1)
|
||||
{
|
||||
// Check for error first
|
||||
if query.contains("error=") {
|
||||
let html = landing_html(&display_name, false);
|
||||
let response = format!(
|
||||
"HTTP/1.1 400 Bad Request\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
{}",
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
return Err(OAuthCallbackError::Denied);
|
||||
}
|
||||
|
||||
// Look for the target parameter
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == param_name {
|
||||
let value = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
|
||||
let html = landing_html(&display_name, true);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
{}",
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not the callback we're looking for
|
||||
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| OAuthCallbackError::Timeout)?
|
||||
}
|
||||
|
||||
/// Escape a string for safe interpolation into HTML content.
|
||||
fn html_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// HTML landing page shown in the browser after an OAuth redirect.
|
||||
pub fn landing_html(provider_name: &str, success: bool) -> String {
|
||||
let safe_name = html_escape(provider_name);
|
||||
let (icon, heading, subtitle, accent) = if success {
|
||||
(
|
||||
r##"<div style="width:64px;height:64px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>"##,
|
||||
format!("{} Connected", safe_name),
|
||||
"You can close this window and return to your terminal.",
|
||||
"#22c55e",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r##"<div style="width:64px;height:64px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</div>"##,
|
||||
"Authorization Failed".to_string(),
|
||||
"The request was denied. You can close this window and try again.",
|
||||
"#ef4444",
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>IronClaw - {heading}</title>
|
||||
<style>
|
||||
* {{ margin:0; padding:0; box-sizing:border-box }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
.card {{
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 420px;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
background: #141414;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #fafafa;
|
||||
}}
|
||||
p {{
|
||||
font-size: 14px;
|
||||
color: #a3a3a3;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.accent {{ color: {accent}; }}
|
||||
.brand {{
|
||||
margin-top: 32px;
|
||||
font-size: 12px;
|
||||
color: #525252;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{icon}
|
||||
<h1>{heading}</h1>
|
||||
<p>{subtitle}</p>
|
||||
<div class="brand">IronClaw</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
heading = heading,
|
||||
icon = icon,
|
||||
subtitle = subtitle,
|
||||
accent = accent,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::cli::oauth_defaults::{builtin_credentials, landing_html};
|
||||
|
||||
#[test]
|
||||
fn test_unknown_provider_returns_none() {
|
||||
assert!(builtin_credentials("unknown_token").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_returns_based_on_compile_env() {
|
||||
let creds = builtin_credentials("google_oauth_token");
|
||||
assert!(creds.is_some());
|
||||
let creds = creds.unwrap();
|
||||
assert!(!creds.client_id.is_empty());
|
||||
assert!(!creds.client_secret.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_success_contains_key_elements() {
|
||||
let html = landing_html("Google", true);
|
||||
assert!(html.contains("Google Connected"));
|
||||
assert!(html.contains("charset"));
|
||||
assert!(html.contains("IronClaw"));
|
||||
assert!(html.contains("#22c55e")); // green accent
|
||||
assert!(!html.contains("Failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_escapes_provider_name() {
|
||||
let html = landing_html("<script>alert(1)</script>", true);
|
||||
assert!(!html.contains("<script>"));
|
||||
assert!(html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_error_contains_key_elements() {
|
||||
let html = landing_html("Notion", false);
|
||||
assert!(html.contains("Authorization Failed"));
|
||||
assert!(html.contains("charset"));
|
||||
assert!(html.contains("IronClaw"));
|
||||
assert!(html.contains("#ef4444")); // red accent
|
||||
assert!(!html.contains("Connected"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+52
-25
@@ -9,7 +9,7 @@ use crate::settings::Settings;
|
||||
|
||||
/// Run the status command, printing system health info.
|
||||
pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
let settings = Settings::load();
|
||||
let settings = Settings::default();
|
||||
|
||||
println!("IronClaw Status");
|
||||
println!("===============\n");
|
||||
@@ -22,16 +22,36 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// Database
|
||||
let db_url_set = settings.database_url.is_some() || std::env::var("DATABASE_URL").is_ok();
|
||||
print!(" Database: ");
|
||||
if db_url_set {
|
||||
// Try to connect
|
||||
match check_database().await {
|
||||
Ok(()) => println!("connected"),
|
||||
Err(e) => println!("error ({})", e),
|
||||
let db_backend = std::env::var("DATABASE_BACKEND")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "postgres".to_string());
|
||||
match db_backend.as_str() {
|
||||
"libsql" | "turso" | "sqlite" => {
|
||||
let path = std::env::var("LIBSQL_PATH")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
||||
if path.exists() {
|
||||
let turso = if std::env::var("LIBSQL_URL").is_ok() {
|
||||
" + Turso sync"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!("libSQL ({}{})", path.display(), turso);
|
||||
} else {
|
||||
println!("libSQL (file missing: {})", path.display());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if std::env::var("DATABASE_URL").is_ok() {
|
||||
match check_database().await {
|
||||
Ok(()) => println!("connected (PostgreSQL)"),
|
||||
Err(e) => println!("error ({})", e),
|
||||
}
|
||||
} else {
|
||||
println!("not configured");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not configured");
|
||||
}
|
||||
|
||||
// Session / Auth
|
||||
@@ -40,18 +60,20 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
if session_path.exists() {
|
||||
println!("found ({})", session_path.display());
|
||||
} else {
|
||||
println!("not found (run `ironclaw setup`)");
|
||||
println!("not found (run `ironclaw onboard`)");
|
||||
}
|
||||
|
||||
// Secrets
|
||||
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
||||
// triggering macOS system password dialogs on a simple status check)
|
||||
print!(" Secrets: ");
|
||||
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|
||||
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|
||||
|| crate::secrets::keychain::has_master_key();
|
||||
if secrets_configured {
|
||||
println!("configured ({:?})", settings.secrets_master_key_source);
|
||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||
println!("configured (env)");
|
||||
} else {
|
||||
println!("not configured");
|
||||
// We don't probe the keychain here because get_generic_password()
|
||||
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
||||
// a read-only status command. If onboarding completed with keychain
|
||||
// storage, the key is there; we just can't cheaply verify it.
|
||||
println!("env not set (keychain may be configured)");
|
||||
}
|
||||
|
||||
// Embeddings
|
||||
@@ -129,19 +151,18 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
Err(_) => println!("none configured"),
|
||||
}
|
||||
|
||||
// Settings path
|
||||
println!("\n Settings: {}", Settings::default_path().display());
|
||||
// Config path
|
||||
println!(
|
||||
"\n Config: {}",
|
||||
crate::bootstrap::ironclaw_env_path().display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
async fn check_database() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
let settings = Settings::load();
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.ok()
|
||||
.or(settings.database_url)
|
||||
.ok_or_else(|| anyhow::anyhow!("no URL"))?;
|
||||
let url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))?;
|
||||
|
||||
let config: deadpool_postgres::Config = deadpool_postgres::Config {
|
||||
url: Some(url),
|
||||
@@ -167,6 +188,12 @@ async fn check_database() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
async fn check_database() -> anyhow::Result<()> {
|
||||
// For non-postgres backends, just report configured
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_wasm_files(dir: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(dir)
|
||||
.map(|entries| {
|
||||
|
||||
+239
-165
@@ -11,8 +11,11 @@ use clap::Subcommand;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::history::Store;
|
||||
use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||
#[allow(unused_imports)]
|
||||
use crate::db::Database;
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::secrets::PostgresSecretsStore;
|
||||
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
|
||||
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||
|
||||
/// Default tools directory.
|
||||
@@ -420,11 +423,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
||||
// Simple TOML parsing for [package] name
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("name") {
|
||||
if let Some((_, value)) = line.split_once('=') {
|
||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
if line.starts_with("name")
|
||||
&& let Some((_, value)) = line.split_once('=')
|
||||
{
|
||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,10 +491,10 @@ async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
|
||||
|
||||
if has_caps {
|
||||
let caps_path = path.with_extension("capabilities.json");
|
||||
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
||||
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
||||
print_capabilities_summary(&caps);
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&caps_path).await
|
||||
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
||||
{
|
||||
print_capabilities_summary(&caps);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
@@ -604,16 +607,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||
}
|
||||
if let Some(ref secrets) = caps.secrets
|
||||
&& !secrets.allowed_names.is_empty()
|
||||
{
|
||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
parts.push("workspace: read".to_string());
|
||||
}
|
||||
if let Some(ref ws) = caps.workspace
|
||||
&& !ws.allowed_prefixes.is_empty()
|
||||
{
|
||||
parts.push("workspace: read".to_string());
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
@@ -650,30 +653,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
}
|
||||
if let Some(ref secrets) = caps.secrets
|
||||
&& !secrets.allowed_names.is_empty()
|
||||
{
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref tool_invoke) = caps.tool_invoke {
|
||||
if !tool_invoke.aliases.is_empty() {
|
||||
println!(" Tool aliases:");
|
||||
for (alias, real_name) in &tool_invoke.aliases {
|
||||
println!(" {} -> {}", alias, real_name);
|
||||
}
|
||||
if let Some(ref tool_invoke) = caps.tool_invoke
|
||||
&& !tool_invoke.aliases.is_empty()
|
||||
{
|
||||
println!(" Tool aliases:");
|
||||
for (alias, real_name) in &tool_invoke.aliases {
|
||||
println!(" {} -> {}", alias, real_name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
println!(" Workspace read prefixes:");
|
||||
for prefix in &ws.allowed_prefixes {
|
||||
println!(" {}", prefix);
|
||||
}
|
||||
if let Some(ref ws) = caps.workspace
|
||||
&& !ws.allowed_prefixes.is_empty()
|
||||
{
|
||||
println!(" Workspace read prefixes:");
|
||||
for prefix in &ws.allowed_prefixes {
|
||||
println!(" {}", prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,16 +718,65 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
println!();
|
||||
|
||||
// Initialize secrets store
|
||||
let config = Config::from_env()?;
|
||||
let config = Config::from_env().await?;
|
||||
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?;
|
||||
store.run_migrations().await?;
|
||||
|
||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||
let secrets_store = Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
|
||||
|
||||
let secrets_store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
let store = crate::history::Store::new(&config.database).await?;
|
||||
store.run_migrations().await?;
|
||||
Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)))
|
||||
}
|
||||
#[cfg(all(feature = "libsql", not(feature = "postgres")))]
|
||||
{
|
||||
use crate::db::Database as _;
|
||||
use crate::db::libsql_backend::LibSqlBackend;
|
||||
use secrecy::ExposeSecret as _;
|
||||
|
||||
let default_path = crate::config::default_libsql_path();
|
||||
let db_path = config
|
||||
.database
|
||||
.libsql_path
|
||||
.as_deref()
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = config.database.libsql_url {
|
||||
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
} else {
|
||||
LibSqlBackend::new_local(db_path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
};
|
||||
backend
|
||||
.run_migrations()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
backend.shared_db(),
|
||||
Arc::new(crypto),
|
||||
))
|
||||
}
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
let _ = crypto;
|
||||
anyhow::bail!(
|
||||
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already configured
|
||||
let already_configured = secrets_store
|
||||
@@ -750,51 +802,103 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
}
|
||||
|
||||
// Check for environment variable
|
||||
if let Some(ref env_var) = auth.env_var {
|
||||
if let Ok(token) = std::env::var(env_var) {
|
||||
if !token.is_empty() {
|
||||
println!(" Found {} in environment.", env_var);
|
||||
println!();
|
||||
if let Some(ref env_var) = auth.env_var
|
||||
&& let Ok(token) = std::env::var(env_var)
|
||||
&& !token.is_empty()
|
||||
{
|
||||
println!(" Found {} in environment.", env_var);
|
||||
println!();
|
||||
|
||||
// Validate if endpoint is provided
|
||||
if let Some(ref validation) = auth.validation_endpoint {
|
||||
print!(" Validating token...");
|
||||
std::io::stdout().flush()?;
|
||||
// Validate if endpoint is provided
|
||||
if let Some(ref validation) = auth.validation_endpoint {
|
||||
print!(" Validating token...");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
match validate_token(&token, validation, &auth.secret_name).await {
|
||||
Ok(()) => {
|
||||
println!(" ✓");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗");
|
||||
println!(" Validation failed: {}", e);
|
||||
println!();
|
||||
println!(" Falling back to manual entry...");
|
||||
return auth_tool_manual(&secrets_store, &user_id, &auth).await;
|
||||
}
|
||||
}
|
||||
match validate_token(&token, validation, &auth.secret_name).await {
|
||||
Ok(()) => {
|
||||
println!(" ✓");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗");
|
||||
println!(" Validation failed: {}", e);
|
||||
println!();
|
||||
println!(" Falling back to manual entry...");
|
||||
return auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await;
|
||||
}
|
||||
|
||||
// Save the token
|
||||
save_token(&secrets_store, &user_id, &auth, &token).await?;
|
||||
print_success(display_name);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Save the token
|
||||
save_token(secrets_store.as_ref(), &user_id, &auth, &token, None, None).await?;
|
||||
print_success(display_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check for OAuth configuration
|
||||
if let Some(ref oauth) = auth.oauth {
|
||||
return auth_tool_oauth(&secrets_store, &user_id, &auth, oauth).await;
|
||||
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
|
||||
// combine scopes from all installed tools so one auth covers everything.
|
||||
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
|
||||
if combined.scopes.len() > oauth.scopes.len() {
|
||||
let extra = combined.scopes.len() - oauth.scopes.len();
|
||||
println!(
|
||||
" Including scopes from {} other installed tool(s) sharing this credential.",
|
||||
extra
|
||||
);
|
||||
println!();
|
||||
}
|
||||
return auth_tool_oauth(secrets_store.as_ref(), &user_id, &auth, &combined).await;
|
||||
}
|
||||
|
||||
// Fall back to manual entry
|
||||
auth_tool_manual(&secrets_store, &user_id, &auth).await
|
||||
auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await
|
||||
}
|
||||
|
||||
/// Scan the tools directory for all capabilities files sharing the same secret_name
|
||||
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
|
||||
/// for ALL installed Google tools, so one login covers everything.
|
||||
async fn combine_provider_scopes(
|
||||
tools_dir: &Path,
|
||||
secret_name: &str,
|
||||
base_oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||
) -> crate::tools::wasm::OAuthConfigSchema {
|
||||
let mut all_scopes: std::collections::HashSet<String> =
|
||||
base_oauth.scopes.iter().cloned().collect();
|
||||
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(tools_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
if !name.ends_with(".capabilities.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await
|
||||
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
||||
&& let Some(auth) = &caps.auth
|
||||
&& auth.secret_name == secret_name
|
||||
&& let Some(oauth) = &auth.oauth
|
||||
{
|
||||
all_scopes.extend(oauth.scopes.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut combined = base_oauth.clone();
|
||||
combined.scopes = all_scopes.into_iter().collect();
|
||||
combined.scopes.sort(); // deterministic ordering
|
||||
combined
|
||||
}
|
||||
|
||||
/// OAuth browser-based login flow.
|
||||
async fn auth_tool_oauth(
|
||||
store: &PostgresSecretsStore,
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||
@@ -802,12 +906,14 @@ async fn auth_tool_oauth(
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
||||
|
||||
// Get client_id from config or env
|
||||
// Get client_id: capabilities file > runtime env var > built-in defaults
|
||||
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
|
||||
|
||||
let client_id = oauth
|
||||
.client_id
|
||||
.clone()
|
||||
@@ -817,41 +923,32 @@ async fn auth_tool_oauth(
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"OAuth client_id not configured.\n\
|
||||
Set it in the capabilities file or via environment variable."
|
||||
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
|
||||
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get client_secret if provided
|
||||
let client_secret = oauth.client_secret.clone().or_else(|| {
|
||||
oauth
|
||||
.client_secret_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
});
|
||||
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
||||
let client_secret = oauth
|
||||
.client_secret
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
oauth
|
||||
.client_secret_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
|
||||
|
||||
println!(" Starting OAuth authentication...");
|
||||
println!();
|
||||
|
||||
// Find an available port for the callback
|
||||
let mut listener = None;
|
||||
let mut port = 0;
|
||||
|
||||
for p in 9876..=9886 {
|
||||
match TcpListener::bind(format!("127.0.0.1:{}", p)).await {
|
||||
Ok(l) => {
|
||||
listener = Some(l);
|
||||
port = p;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.ok_or_else(|| anyhow::anyhow!("Could not find available port"))?;
|
||||
let redirect_uri = format!("http://localhost:{}/callback", port);
|
||||
let listener = oauth_defaults::bind_callback_listener().await?;
|
||||
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
|
||||
|
||||
// Generate PKCE verifier and challenge
|
||||
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
||||
@@ -910,65 +1007,8 @@ async fn auth_tool_oauth(
|
||||
|
||||
println!(" Waiting for authorization...");
|
||||
|
||||
// Wait for callback with timeout
|
||||
let timeout = std::time::Duration::from_secs(300);
|
||||
let code = tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).await?;
|
||||
|
||||
// Parse GET /callback?code=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
||||
if path.starts_with("/callback") {
|
||||
if let Some(query) = path.split('?').nth(1) {
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == "code" {
|
||||
let code = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
|
||||
// Send success response
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html\r\n\
|
||||
\r\n\
|
||||
<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
||||
display: flex; justify-content: center; align-items: center; \
|
||||
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
||||
<div style=\"text-align: center;\">\
|
||||
<h1>✓ {} Connected!</h1>\
|
||||
<p>You can close this window.</p>\
|
||||
</div></body></html>",
|
||||
display_name
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok::<_, anyhow::Error>(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if query.contains("error=") {
|
||||
let response =
|
||||
"HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
return Err(anyhow::anyhow!("Authorization denied by user"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out waiting for authorization"))??;
|
||||
let code =
|
||||
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
|
||||
|
||||
println!();
|
||||
println!(" Exchanging code for token...");
|
||||
@@ -1019,8 +1059,19 @@ async fn auth_tool_oauth(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Save the token
|
||||
save_token(store, user_id, auth, access_token).await?;
|
||||
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str());
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
|
||||
// Save the token (with refresh token and expiry if provided)
|
||||
save_token(
|
||||
store,
|
||||
user_id,
|
||||
auth,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Extract any additional info for display
|
||||
let workspace_name = token_data
|
||||
@@ -1042,7 +1093,7 @@ async fn auth_tool_oauth(
|
||||
|
||||
/// Manual token entry flow.
|
||||
async fn auth_tool_manual(
|
||||
store: &PostgresSecretsStore,
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -1122,8 +1173,8 @@ async fn auth_tool_manual(
|
||||
}
|
||||
}
|
||||
|
||||
// Save the token
|
||||
save_token(store, user_id, auth, &token).await?;
|
||||
// Save the token (manual path: no refresh token or expiry)
|
||||
save_token(store, user_id, auth, &token, None, None).await?;
|
||||
print_success(display_name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1214,11 +1265,16 @@ async fn validate_token(
|
||||
}
|
||||
|
||||
/// Save token to secrets store.
|
||||
///
|
||||
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
|
||||
/// sets `expires_at` on the access token so the runtime can auto-refresh.
|
||||
async fn save_token(
|
||||
store: &PostgresSecretsStore,
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||
token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
||||
|
||||
@@ -1226,11 +1282,29 @@ async fn save_token(
|
||||
params = params.with_provider(provider);
|
||||
}
|
||||
|
||||
if let Some(secs) = expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
|
||||
params = params.with_expiry(expires_at);
|
||||
}
|
||||
|
||||
store
|
||||
.create(user_id, params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
|
||||
|
||||
// Store refresh token separately (no expiry, it's long-lived)
|
||||
if let Some(rt) = refresh_token {
|
||||
let refresh_name = format!("{}_refresh_token", auth.secret_name);
|
||||
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
|
||||
if let Some(ref provider) = auth.provider {
|
||||
refresh_params = refresh_params.with_provider(provider);
|
||||
}
|
||||
store
|
||||
.create(user_id, refresh_params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save refresh token: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+671
-173
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user