mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e62d71567 |
@@ -1,97 +0,0 @@
|
|||||||
---
|
|
||||||
description: Fetch a GitHub issue, create a branch, research the codebase, plan the fix, implement with tests, and commit
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
|
||||||
argument-hint: "<issue-number or github-issue-url>"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fix GitHub Issue
|
|
||||||
|
|
||||||
## Step 1: Resolve the issue
|
|
||||||
|
|
||||||
Parse `$ARGUMENTS` to extract the issue number:
|
|
||||||
- If it's a URL like `https://github.com/owner/repo/issues/42`, extract `42`.
|
|
||||||
- If it's a bare number, use it directly.
|
|
||||||
- If empty, stop and ask the user for an issue number.
|
|
||||||
|
|
||||||
Fetch the issue:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh issue view {number} --json title,body,labels,assignees,comments,state
|
|
||||||
```
|
|
||||||
|
|
||||||
If the issue is closed, warn the user and ask if they still want to proceed.
|
|
||||||
|
|
||||||
## Step 2: Create a branch
|
|
||||||
|
|
||||||
Create a fresh branch off the latest main:
|
|
||||||
|
|
||||||
1. Fetch latest: `git fetch origin`
|
|
||||||
2. Detect default branch: `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`
|
|
||||||
3. Create and switch to a new branch: `git checkout -b fix/{number}-{short-slug} origin/{default-branch}`
|
|
||||||
- `{short-slug}` is 3-5 words from the issue title, lowercase, hyphenated (e.g. `fix/42-idor-workspace-check`)
|
|
||||||
|
|
||||||
If the working tree has uncommitted changes, warn the user and stop. Do not stash or discard their work.
|
|
||||||
|
|
||||||
## Step 3: Understand the issue
|
|
||||||
|
|
||||||
Summarize the issue in 2-3 sentences. Identify:
|
|
||||||
- **What's broken or missing** (the symptom or feature request)
|
|
||||||
- **Acceptance criteria** (what "done" looks like, from the issue body or comments)
|
|
||||||
- **Constraints** (mentioned technologies, backward compatibility, performance requirements)
|
|
||||||
|
|
||||||
If the issue is unclear or ambiguous, list the open questions. These will be addressed during planning.
|
|
||||||
|
|
||||||
## Step 4: Research the codebase
|
|
||||||
|
|
||||||
Before planning, gather context:
|
|
||||||
|
|
||||||
1. **Find relevant code** - Search for files, functions, types, and patterns mentioned in the issue. Read them in full.
|
|
||||||
2. **Trace the flow** - If the issue is about a specific behavior, trace the code path from the entry point (route handler, CLI command, etc.) through to the relevant logic.
|
|
||||||
3. **Check existing tests** - Find tests related to the affected code. Understand what's already covered.
|
|
||||||
4. **Check for prior art** - Look for similar patterns in the codebase that solve analogous problems. Prefer consistency with existing patterns.
|
|
||||||
|
|
||||||
## Step 5: Enter planning mode
|
|
||||||
|
|
||||||
Enter planning mode to design the implementation. The plan MUST cover:
|
|
||||||
|
|
||||||
1. **Root cause** (for bugs) or **design approach** (for features)
|
|
||||||
2. **Files to modify** with specific descriptions of what changes in each
|
|
||||||
3. **New files** (if any) with justification for why they're needed
|
|
||||||
4. **Tests to add** - every code path introduced or changed needs a test:
|
|
||||||
- Happy path (expected input produces expected output)
|
|
||||||
- Error paths (invalid input, missing data, permission denied)
|
|
||||||
- Edge cases (empty collections, boundary values, concurrent access)
|
|
||||||
5. **IronClaw-specific concerns**:
|
|
||||||
- If the change touches persistence, both database backends must be updated (`postgres.rs` and `libsql_backend.rs`)
|
|
||||||
- New `Database` trait methods need implementations in both backends
|
|
||||||
- No `.unwrap()` or `.expect()` in production code
|
|
||||||
- Use `crate::` imports, not `super::`
|
|
||||||
- Error types via `thiserror` in `error.rs`
|
|
||||||
6. **Migration or compatibility concerns** (if any)
|
|
||||||
|
|
||||||
Follow the project's CLAUDE.md guidance for architecture decisions.
|
|
||||||
|
|
||||||
Wait for user approval before implementing.
|
|
||||||
|
|
||||||
## Step 6: Implement
|
|
||||||
|
|
||||||
After the plan is approved:
|
|
||||||
|
|
||||||
1. Implement each change from the plan.
|
|
||||||
2. Write all planned tests.
|
|
||||||
3. Run IronClaw's full quality gate:
|
|
||||||
- `cargo fmt`
|
|
||||||
- `cargo clippy --all --benches --tests --examples --all-features` (zero warnings)
|
|
||||||
- `cargo test --lib` (all tests pass)
|
|
||||||
4. If any check fails, fix it before proceeding.
|
|
||||||
|
|
||||||
Note: Integration tests (`--test workspace_integration`) require PostgreSQL and are expected to fail locally. Only `--lib` test failures are blocking.
|
|
||||||
|
|
||||||
## Step 7: Commit and summarize
|
|
||||||
|
|
||||||
1. Commit with a descriptive message referencing the issue (e.g. `fix: prevent IDOR in function call outputs (#42)`).
|
|
||||||
2. Summarize what was done:
|
|
||||||
- Files changed with line references
|
|
||||||
- Tests added and what they cover
|
|
||||||
- Any follow-up work or open questions
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
---
|
|
||||||
description: Respond to PR review comments — triage, plan fixes, implement after confirmation, push, and reply to reviewers
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(gh pr list:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git branch:*), Bash(git status:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
|
||||||
argument-hint: "[pr-number (optional, auto-detects from branch)]"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Review and Address PR Comments
|
|
||||||
|
|
||||||
## Step 1: Find the PR
|
|
||||||
|
|
||||||
If `$ARGUMENTS` is provided, use that as the PR number. Otherwise, detect the PR for the current branch:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh pr list --head $(git branch --show-current) --json number,title,url --jq '.[0]'
|
|
||||||
```
|
|
||||||
|
|
||||||
If no PR is found, tell the user and stop.
|
|
||||||
|
|
||||||
## Step 2: Fetch all review comments
|
|
||||||
|
|
||||||
Resolve the repo owner and name:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetch the full set of review comments (not issue-level comments):
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
|
|
||||||
```
|
|
||||||
|
|
||||||
Also fetch the review summaries:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
|
|
||||||
```
|
|
||||||
|
|
||||||
Deduplicate comments that appear multiple times (bots sometimes post the same finding under different IDs). Group by the actual issue being raised, not by comment ID.
|
|
||||||
|
|
||||||
## Step 3: Triage and plan
|
|
||||||
|
|
||||||
For each unique issue raised in the comments:
|
|
||||||
|
|
||||||
1. **Check if already addressed** - Read the current code at the referenced location. If a prior commit already fixed it, note it as "already resolved".
|
|
||||||
2. **Assess validity** - Determine if the comment identifies a real problem or is a false positive. Be honest about false positives but explain why.
|
|
||||||
3. **Classify severity** - Critical (security/data loss), High (bugs/broken behavior), Medium (correctness/robustness), Low (style/naming/nits).
|
|
||||||
4. **Plan the fix** - For each valid unresolved issue, describe the specific code change needed.
|
|
||||||
|
|
||||||
Present the plan as a table to the user:
|
|
||||||
|
|
||||||
| # | Issue | File:Line | Severity | Status | Planned Fix |
|
|
||||||
|---|-------|-----------|----------|--------|-------------|
|
|
||||||
|
|
||||||
Wait for user confirmation before proceeding to implementation.
|
|
||||||
|
|
||||||
## Step 4: Implement fixes
|
|
||||||
|
|
||||||
After user confirms:
|
|
||||||
|
|
||||||
1. Implement each fix in the plan.
|
|
||||||
2. Run IronClaw's quality gate to verify nothing breaks:
|
|
||||||
- `cargo fmt`
|
|
||||||
- `cargo clippy --all --benches --tests --examples --all-features`
|
|
||||||
- `cargo test --lib`
|
|
||||||
3. Commit with a descriptive message referencing the PR review.
|
|
||||||
4. Push to the branch.
|
|
||||||
|
|
||||||
## Step 5: Reply to comments
|
|
||||||
|
|
||||||
For each comment addressed, reply on the PR with a short message stating what was fixed and the commit SHA. For false positives or already-resolved items, reply explaining why no change was needed.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Never guess at code you haven't read. Always read the referenced file and line before assessing a comment.
|
|
||||||
- Group duplicate comments (same issue reported by multiple bots) and reply to all of them.
|
|
||||||
- Do not make changes beyond what the review comments ask for. Stay focused.
|
|
||||||
- If a comment suggests a change you disagree with, present your reasoning to the user during the planning phase rather than silently ignoring it.
|
|
||||||
- Follow IronClaw conventions: no `.unwrap()` in production code, use `crate::` imports, `thiserror` errors.
|
|
||||||
- If changes touch persistence, verify both database backends are updated.
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
---
|
|
||||||
description: Deep audit of the IronClaw crate for vulnerabilities, bugs, unfinished work, inconsistencies, and oversights
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo audit:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(wc:*), Read, Grep, Glob, Task
|
|
||||||
argument-hint: "[path/to/crate]"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Rust Crate Audit
|
|
||||||
|
|
||||||
You are performing a thorough audit of a Rust crate. Your goal is to find every vulnerability, bug, unfinished piece of work, inconsistency, and oversight before it ships. Leave no stone unturned.
|
|
||||||
|
|
||||||
## Step 1: Locate the crate
|
|
||||||
|
|
||||||
Parse `$ARGUMENTS`:
|
|
||||||
- If a path is provided, use it as the crate root.
|
|
||||||
- If empty, use the current working directory.
|
|
||||||
|
|
||||||
Verify it's a valid Rust crate by checking for `Cargo.toml`. If not found, stop and ask the user.
|
|
||||||
|
|
||||||
## Step 2: Understand the crate
|
|
||||||
|
|
||||||
Read `Cargo.toml` to understand:
|
|
||||||
- Crate name, version, edition
|
|
||||||
- Dependencies (look for outdated, unmaintained, or suspicious crates)
|
|
||||||
- Feature flags and their implications
|
|
||||||
- Build scripts (`build.rs`) if any
|
|
||||||
|
|
||||||
Read `CLAUDE.md`, `README.md`, or top-level documentation if present to understand intent and architecture.
|
|
||||||
|
|
||||||
Read `src/lib.rs` or `src/main.rs` to get the module tree. Then read each module's `mod.rs` or top-level file to build a mental map of the crate's structure before diving into details.
|
|
||||||
Read all Rust files (`src/*.rs`) to make sure everything is in context when you are reasoning.
|
|
||||||
|
|
||||||
## Step 3: Run the compiler's checks
|
|
||||||
|
|
||||||
Run these commands and capture output. Do NOT fix anything, just collect findings:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo fmt --check 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo clippy --all --benches --tests --examples --all-features -- -W clippy::all -W clippy::pedantic -W clippy::nursery 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo test --lib 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
If any of these fail, record the failures as findings. If `cargo test` has ignored tests, note which ones and why.
|
|
||||||
|
|
||||||
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
|
|
||||||
|
|
||||||
## Step 4: Scan for unfinished work
|
|
||||||
|
|
||||||
Search the entire `src/` tree for:
|
|
||||||
|
|
||||||
```
|
|
||||||
todo!
|
|
||||||
unimplemented!
|
|
||||||
fixme
|
|
||||||
FIXME
|
|
||||||
TODO
|
|
||||||
HACK
|
|
||||||
XXX
|
|
||||||
SAFETY:
|
|
||||||
stub
|
|
||||||
placeholder
|
|
||||||
temporary
|
|
||||||
```
|
|
||||||
|
|
||||||
For each match:
|
|
||||||
- Is it in production code or test code?
|
|
||||||
- Is it a genuine incomplete feature or a deliberate placeholder?
|
|
||||||
- Is there a tracking issue referenced?
|
|
||||||
- Could this panic at runtime?
|
|
||||||
|
|
||||||
Any `todo!()` or `unimplemented!()` in non-test code is **High severity** (runtime panic).
|
|
||||||
|
|
||||||
## Step 5: Audit for vulnerabilities and unsafe code
|
|
||||||
|
|
||||||
### 5a. Unsafe code
|
|
||||||
|
|
||||||
Search for all `unsafe` blocks. For each one:
|
|
||||||
- Is the safety invariant documented with a `// SAFETY:` comment?
|
|
||||||
- Is the invariant actually upheld by the surrounding code?
|
|
||||||
- Could the unsafe block be replaced with a safe alternative?
|
|
||||||
- Are there any pointer dereferences, transmutes, or FFI calls?
|
|
||||||
|
|
||||||
### 5b. Unwrap and panic paths
|
|
||||||
|
|
||||||
Search for `.unwrap()`, `.expect(`, `panic!`, `unreachable!` in non-test code. For each:
|
|
||||||
- Can this actually panic in production?
|
|
||||||
- Is there a code path that reaches this with None/Err?
|
|
||||||
- Should it be replaced with proper error handling (`?`, `.ok()`, `.unwrap_or_default()`)?
|
|
||||||
|
|
||||||
IronClaw convention: `.unwrap()` and `.expect()` are banned in production code. Any occurrence outside `#[cfg(test)]` blocks is a **High severity** finding.
|
|
||||||
|
|
||||||
### 5c. SQL and injection vectors
|
|
||||||
|
|
||||||
Search for string formatting used in SQL queries, shell commands, or HTML:
|
|
||||||
- `format!` used near `.execute(`, `.query(`, `Command::new(`
|
|
||||||
- String interpolation in query construction vs parameterized queries
|
|
||||||
- User input flowing into file paths (`Path::new`, `std::fs::`)
|
|
||||||
|
|
||||||
IronClaw has two database backends (PostgreSQL and libSQL). Check both for injection vectors.
|
|
||||||
|
|
||||||
### 5d. Cryptographic issues
|
|
||||||
|
|
||||||
If the crate uses crypto:
|
|
||||||
- Are comparisons constant-time? (look for `==` on secrets/hashes vs `subtle::ConstantTimeEq`)
|
|
||||||
- Is randomness from `OsRng` / `thread_rng` and not a fixed seed?
|
|
||||||
- Are keys/secrets zeroized after use? (`secrecy`, `zeroize` crates)
|
|
||||||
- Are deprecated algorithms used? (MD5, SHA1 for security, RC4, DES)
|
|
||||||
|
|
||||||
### 5e. Resource exhaustion
|
|
||||||
|
|
||||||
- Are there unbounded allocations? (`Vec` growing from user input without limits)
|
|
||||||
- Are there unbounded loops? (retry loops without max attempts)
|
|
||||||
- Are file reads bounded? (`std::fs::read_to_string` on user-provided paths)
|
|
||||||
- Are timeouts set on all network operations?
|
|
||||||
- Are there connection/resource leaks? (opened but never closed, missing `Drop`)
|
|
||||||
|
|
||||||
### 5f. Error handling
|
|
||||||
|
|
||||||
- Are errors swallowed silently? (`let _ = ...`, `.ok()` discarding errors that matter)
|
|
||||||
- Do error types carry enough context to debug in production?
|
|
||||||
- Are there error type mismatches? (returning generic `anyhow::Error` where a typed error would prevent confusion)
|
|
||||||
- Is `thiserror` used consistently for error types (IronClaw convention)?
|
|
||||||
|
|
||||||
## Step 6: Check for inconsistencies
|
|
||||||
|
|
||||||
### 6a. Naming conventions
|
|
||||||
|
|
||||||
- Are types, functions, modules named consistently? (e.g., mixing `get_` and `fetch_`, `create_` and `new_`)
|
|
||||||
- Do similar operations follow the same patterns?
|
|
||||||
|
|
||||||
### 6b. Duplicate or near-duplicate code
|
|
||||||
|
|
||||||
Look for:
|
|
||||||
- Functions that do nearly the same thing with minor variations (candidates for generics or shared helpers)
|
|
||||||
- Repeated error mapping patterns that should be extracted
|
|
||||||
- Copy-pasted SQL queries or string templates with slight differences
|
|
||||||
- Identical struct definitions or conversion logic in different modules
|
|
||||||
|
|
||||||
### 6c. API consistency
|
|
||||||
|
|
||||||
- Do similar functions take arguments in the same order?
|
|
||||||
- Are return types consistent? (e.g., some functions return `Option<T>`, similar ones return `Result<T, E>`)
|
|
||||||
- Are visibility modifiers consistent? (`pub` where it should be `pub(crate)`, or vice versa)
|
|
||||||
|
|
||||||
### 6d. Dead code and unused items
|
|
||||||
|
|
||||||
- Are there functions, structs, or modules that nothing references?
|
|
||||||
- Are there `#[allow(dead_code)]` annotations that should be investigated?
|
|
||||||
- Are there feature-gated items where the feature is never enabled?
|
|
||||||
|
|
||||||
### 6e. Import style
|
|
||||||
|
|
||||||
IronClaw convention: use `crate::` imports, not `super::`. Flag any `super::` imports in non-test code.
|
|
||||||
|
|
||||||
## Step 7: Inspect for change oversights
|
|
||||||
|
|
||||||
### 7a. Partial refactors
|
|
||||||
|
|
||||||
- Are there old patterns coexisting with new patterns?
|
|
||||||
- Are there renamed types/functions where some call sites still use the old name via a compatibility alias?
|
|
||||||
- Are there comments referencing behavior that no longer exists?
|
|
||||||
|
|
||||||
### 7b. Trait implementation gaps
|
|
||||||
|
|
||||||
- If a trait is defined, do all intended types implement it?
|
|
||||||
- Are there `impl` blocks that look incomplete?
|
|
||||||
- Are `Default` implementations sensible?
|
|
||||||
|
|
||||||
IronClaw key traits: `Database` (~60 methods), `Channel`, `Tool`, `LlmProvider`, `SuccessEvaluator`, `EmbeddingProvider`. If any new methods were added to `Database`, verify both `postgres.rs` and `libsql_backend.rs` implement them.
|
|
||||||
|
|
||||||
### 7c. Test coverage gaps
|
|
||||||
|
|
||||||
- Are there public functions without any test?
|
|
||||||
- Are there error paths without tests?
|
|
||||||
- Are there recently-changed functions where the tests still assert old behavior?
|
|
||||||
|
|
||||||
### 7d. Documentation drift
|
|
||||||
|
|
||||||
- Do doc comments match actual function behavior?
|
|
||||||
- Are examples in doc comments still valid and compilable?
|
|
||||||
|
|
||||||
## Step 8: Dependency audit
|
|
||||||
|
|
||||||
Review `Cargo.toml` and `Cargo.lock`:
|
|
||||||
- Are there duplicate versions of the same crate in the lock file? (potential version conflicts)
|
|
||||||
- Are there dependencies with known security advisories? Run `cargo audit` to check (install with `cargo install cargo-audit` if not present).
|
|
||||||
- Are there heavy dependencies used for trivial functionality?
|
|
||||||
- Are dependency features minimal?
|
|
||||||
|
|
||||||
## Step 9: Present findings
|
|
||||||
|
|
||||||
Compile all findings into a structured report. Group by severity, then by category.
|
|
||||||
|
|
||||||
### Format
|
|
||||||
|
|
||||||
For each finding:
|
|
||||||
|
|
||||||
```
|
|
||||||
### [Severity] Category: One-line summary
|
|
||||||
|
|
||||||
**Location:** `file_path:line_number`
|
|
||||||
**Category:** Vulnerability | Bug | Unfinished | Inconsistency | Duplicate | Oversight | Style
|
|
||||||
|
|
||||||
**Description:**
|
|
||||||
Detailed explanation of the issue, why it matters, and how it could manifest.
|
|
||||||
|
|
||||||
**Suggested fix:**
|
|
||||||
Concrete suggestion with code if applicable.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Severity levels
|
|
||||||
|
|
||||||
- **Critical**: Security vulnerability, data loss, or crash in production
|
|
||||||
- **High**: Bug that causes incorrect behavior, `todo!()`/`unimplemented!()` in prod code, or missing validation on trust boundaries
|
|
||||||
- **Medium**: Inconsistency, duplicate code, incomplete error handling, missing tests for important paths
|
|
||||||
- **Low**: Naming inconsistency, unnecessary complexity, documentation drift, minor dead code
|
|
||||||
- **Nit**: Style preference, optional improvement
|
|
||||||
|
|
||||||
### Summary table
|
|
||||||
|
|
||||||
End with a summary table:
|
|
||||||
|
|
||||||
| # | Severity | Category | File:Line | Finding |
|
|
||||||
|---|----------|----------|-----------|---------|
|
|
||||||
|
|
||||||
And a final tally: X Critical, Y High, Z Medium, W Low, V Nit.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Read every file before reporting on it. Never guess about code you haven't seen.
|
|
||||||
- Be specific. "This might have issues" is worthless. "Line 42 calls `.unwrap()` on a `Result` that returns `Err` when the DB connection is dropped" is useful.
|
|
||||||
- Distinguish certainty levels: "this IS a bug" vs "this COULD be a bug if X".
|
|
||||||
- Don't invent problems to look thorough. If the code is solid, say so.
|
|
||||||
- Focus on substance over style. Don't flag formatting unless it causes real confusion.
|
|
||||||
- Respect existing project conventions (check CLAUDE.md). Don't flag patterns the project explicitly endorses.
|
|
||||||
- When in doubt about severity, round up.
|
|
||||||
- For large crates (>50 files), prioritize: core logic > public API > internal utilities > tests > examples.
|
|
||||||
- Use the Task tool to parallelize file reading across modules when the crate is large.
|
|
||||||
- Do NOT fix anything. This is a read-only audit. Report findings for the user to action.
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
description: Paranoid architect review of a PR — fetches diff, reads changed files, deep review across 6 lenses, posts findings as GitHub comments
|
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git diff:*), Bash(git log:*), Read, Grep, Glob
|
|
||||||
argument-hint: "<pr-number or github-pr-url>"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Paranoid Architect Code Review
|
|
||||||
|
|
||||||
You are reviewing this PR as a paranoid architect. Your job is to find every bug, vulnerability, race condition, edge case, and undocumented assumption before it ships. Assume adversarial users, concurrent access, and Murphy's law.
|
|
||||||
|
|
||||||
## Step 1: Resolve the PR
|
|
||||||
|
|
||||||
Parse `$ARGUMENTS` to extract the PR number:
|
|
||||||
- If it's a URL like `https://github.com/owner/repo/pull/123`, extract `123`.
|
|
||||||
- If it's a bare number, use it directly.
|
|
||||||
- If empty, stop and ask the user for a PR number.
|
|
||||||
|
|
||||||
Fetch PR metadata (including head commit SHA for posting line comments later):
|
|
||||||
|
|
||||||
```
|
|
||||||
gh pr view {number} --json title,body,baseRefName,headRefName,headRefOid,files,additions,deletions
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the `headRefOid` value, you'll need it as `commit_id` in Step 6.
|
|
||||||
|
|
||||||
## Step 2: Load the full diff
|
|
||||||
|
|
||||||
```
|
|
||||||
gh pr diff {number}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also get the list of changed files:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh pr diff {number} --name-only
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Read every changed file in full
|
|
||||||
|
|
||||||
For each changed file, read the ENTIRE current file (not just the diff hunks). You need surrounding context to catch:
|
|
||||||
- Callers of modified functions that now behave differently
|
|
||||||
- Trait/interface contracts that the change may violate
|
|
||||||
- Invariants established elsewhere that the diff breaks
|
|
||||||
|
|
||||||
If the PR touches more than 20 files, still read all of them, but process in this priority order: service logic > routes/handlers > models/types > tests > docs. Batch reads in groups of ~20 if needed.
|
|
||||||
|
|
||||||
## Step 4: Deep review
|
|
||||||
|
|
||||||
Go through the changes with each of these lenses. For every finding, note the file, line range, severity, and a concrete description.
|
|
||||||
|
|
||||||
### IronClaw-specific checks
|
|
||||||
|
|
||||||
In addition to the general lenses below, check IronClaw conventions (see CLAUDE.md):
|
|
||||||
- No `.unwrap()` or `.expect()` in production code (tests are fine)
|
|
||||||
- Use `crate::` imports, not `super::`
|
|
||||||
- Error types use `thiserror` in `error.rs`
|
|
||||||
- If the change touches persistence, verify both database backends are updated (PostgreSQL in `postgres.rs` AND libSQL in `libsql_backend.rs`)
|
|
||||||
- New tools must implement the `Tool` trait correctly and be registered in `registry.rs`
|
|
||||||
- External tool output must pass through the safety layer
|
|
||||||
|
|
||||||
### 4a. Correctness and bugs
|
|
||||||
|
|
||||||
- Off-by-one errors, wrong comparison operators, inverted conditions
|
|
||||||
- Unreachable code, dead branches, impossible match arms
|
|
||||||
- Type confusion (mixing up IDs, using wrong enum variant)
|
|
||||||
- Incorrect error propagation (swallowed errors, wrong error type/status code)
|
|
||||||
- Broken invariants (e.g. uniqueness assumptions violated, ordering assumptions wrong)
|
|
||||||
- Concurrency issues (TOCTOU, missing locks, race conditions between check and use)
|
|
||||||
|
|
||||||
### 4b. Edge cases and failure handling
|
|
||||||
|
|
||||||
- What happens with empty input, None/null, zero-length collections?
|
|
||||||
- What happens when external services fail (DB down, HTTP timeout, malformed response)?
|
|
||||||
- What happens at integer boundaries (overflow, underflow, i64::MAX)?
|
|
||||||
- What happens with malformed or adversarial input (invalid UTF-8, huge payloads, deeply nested JSON)?
|
|
||||||
- Are all error paths tested? Does every `?` propagation make sense?
|
|
||||||
- Are partial failures handled (e.g. wrote to DB but failed to emit event)?
|
|
||||||
|
|
||||||
### 4c. Security (assume a malicious actor)
|
|
||||||
|
|
||||||
- **Authentication/Authorization bypass**: Can an unauthenticated user reach this? Can workspace A's user access workspace B's data? Are there IDOR vulnerabilities?
|
|
||||||
- **Injection**: SQL injection via string interpolation? Command injection? Log injection? Header injection?
|
|
||||||
- **Data leakage**: Are secrets, PII, or conversation content logged? Returned in error messages? Exposed in API responses?
|
|
||||||
- **Resource exhaustion / DoS**: Can an attacker send unbounded input? Trigger expensive operations without rate limits? Cause OOM via large allocations?
|
|
||||||
- **Financial abuse**: Can tokens/credits be consumed without being tracked? Can usage limits be bypassed?
|
|
||||||
- **Replay / race conditions**: Can the same request be replayed for double-spend? Can concurrent requests bypass limits?
|
|
||||||
- **Cryptographic issues**: Timing attacks on comparisons? Weak randomness? Missing HMAC verification?
|
|
||||||
|
|
||||||
### 4d. Test coverage
|
|
||||||
|
|
||||||
- Is every new public function/method tested?
|
|
||||||
- Are error paths tested (not just happy paths)?
|
|
||||||
- Are edge cases covered (empty input, boundary values, concurrent access)?
|
|
||||||
- Do existing tests still make sense with the new changes, or do they assert stale behavior?
|
|
||||||
- Are there integration/e2e tests for the full flow?
|
|
||||||
- If a test is missing, describe exactly what test should be written.
|
|
||||||
|
|
||||||
### 4e. Documentation and assumptions
|
|
||||||
|
|
||||||
- Are new assumptions documented in comments? (e.g. "this field is always non-empty because X")
|
|
||||||
- Are non-obvious algorithms or business rules explained?
|
|
||||||
- Are API contracts (request/response shapes, error codes, status codes) documented?
|
|
||||||
- Are there TODO/FIXME/HACK comments that should be tracked as issues?
|
|
||||||
|
|
||||||
### 4f. Architectural concerns
|
|
||||||
|
|
||||||
- Does this change follow existing patterns in the codebase, or does it introduce a new one without justification?
|
|
||||||
- Are there unnecessary abstractions or premature generalizations?
|
|
||||||
- Is there duplicated logic that should be extracted?
|
|
||||||
- Are dependencies between modules clean, or does this create circular/tight coupling?
|
|
||||||
- Will this change make future work harder?
|
|
||||||
|
|
||||||
## Step 5: Present findings
|
|
||||||
|
|
||||||
Summarize findings to the user as a table:
|
|
||||||
|
|
||||||
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|
|
||||||
|---|----------|----------|-----------|---------|---------------|
|
|
||||||
|
|
||||||
Severity levels:
|
|
||||||
- **Critical**: Security vulnerability, data loss, or financial exploit
|
|
||||||
- **High**: Bug that will cause incorrect behavior in production
|
|
||||||
- **Medium**: Robustness issue, missing validation, or incomplete error handling
|
|
||||||
- **Low**: Style, naming, documentation, or minor improvement
|
|
||||||
- **Nit**: Optional suggestion, take-it-or-leave-it
|
|
||||||
|
|
||||||
Ask the user which findings to post as PR comments. Default: all Critical, High, and Medium.
|
|
||||||
|
|
||||||
## Step 6: Post comments on GitHub
|
|
||||||
|
|
||||||
Resolve the repo owner and name if not already known:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
|
||||||
```
|
|
||||||
|
|
||||||
For each approved finding, post a review comment on the PR at the specific file and line. Use the `headRefOid` from Step 1 as the `commit_id`:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh api repos/{owner}/{repo}/pulls/{number}/comments \
|
|
||||||
-f body="..." \
|
|
||||||
-f path="..." \
|
|
||||||
-f commit_id="{headRefOid}" \
|
|
||||||
-F line=... \
|
|
||||||
-f side="RIGHT"
|
|
||||||
```
|
|
||||||
|
|
||||||
For findings that span multiple locations or are architectural, post as a regular PR comment:
|
|
||||||
|
|
||||||
```
|
|
||||||
gh pr comment {number} --body "..."
|
|
||||||
```
|
|
||||||
|
|
||||||
Format each comment clearly:
|
|
||||||
- Severity tag (e.g. `**High Severity**`)
|
|
||||||
- One-line summary
|
|
||||||
- Detailed explanation of the issue
|
|
||||||
- Concrete suggestion for the fix (with code if possible)
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Read every changed file in full before writing a single finding. Context matters.
|
|
||||||
- Never post a comment about code you haven't actually read. Verify line numbers against the actual file.
|
|
||||||
- Be specific. "This might have issues" is useless. "Line 42 returns 404 but should return 400 because X" is useful.
|
|
||||||
- Distinguish between "this IS a bug" and "this COULD be a bug if X". Be honest about certainty.
|
|
||||||
- Don't nitpick formatting or style unless it causes actual confusion. Focus on substance.
|
|
||||||
- If the code is good and you find nothing, say so. Don't invent problems to look thorough.
|
|
||||||
- Respect the project's CLAUDE.md privacy rules: never include customer data, secrets, or PII in comments.
|
|
||||||
- When in doubt about severity, round up. It's cheaper to dismiss a false alarm than to miss a real bug.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
target/
|
|
||||||
.git/
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.md
|
|
||||||
!CLAUDE.md
|
|
||||||
node_modules/
|
|
||||||
tools-src/
|
|
||||||
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=postgres://localhost/ironclaw
|
DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_POOL_SIZE=10
|
||||||
|
|
||||||
# LLM Provider (NEAR AI)
|
# LLM Provider (NEAR AI)
|
||||||
# NEAR AI provides a unified interface to all models with user authentication
|
# NEAR AI provides a unified interface to all models with user authentication
|
||||||
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
|
# Session token is stored in ~/.near-agent/session.json and managed automatically.
|
||||||
# On first run, the agent will open a browser for OAuth authentication.
|
# On first run, the agent will open a browser for OAuth authentication.
|
||||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||||
NEARAI_AUTH_URL=https://private.near.ai
|
NEARAI_AUTH_URL=https://private.near.ai
|
||||||
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
|
# NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
# CLI is always enabled
|
# CLI is always enabled
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# 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:
|
|
||||||
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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
-12
@@ -1,18 +1,6 @@
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Claude Code worktrees
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|
||||||
# Sidecar tool data
|
|
||||||
.sidecar/
|
|
||||||
.todos/
|
|
||||||
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# WASM build artifacts (loaded from disk, not bundled)
|
|
||||||
*.wasm
|
|
||||||
|
|
||||||
|
|||||||
-171
@@ -1,171 +0,0 @@
|
|||||||
# 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.5.0](https://github.com/nearai/ironclaw/compare/v0.4.0...v0.5.0) - 2026-02-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- add cooldown management to FailoverProvider ([#114](https://github.com/nearai/ironclaw/pull/114))
|
|
||||||
|
|
||||||
## [0.4.0](https://github.com/nearai/ironclaw/compare/v0.3.0...v0.4.0) - 2026-02-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- move per-invocation approval check into Tool trait ([#119](https://github.com/nearai/ironclaw/pull/119))
|
|
||||||
- add polished boot screen on CLI startup ([#118](https://github.com/nearai/ironclaw/pull/118))
|
|
||||||
- Add lifecycle hooks system with 6 interception points ([#18](https://github.com/nearai/ironclaw/pull/18))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- remove accidentally committed .sidecar and .todos directories ([#123](https://github.com/nearai/ironclaw/pull/123))
|
|
||||||
|
|
||||||
## [0.3.0](https://github.com/nearai/ironclaw/compare/v0.2.0...v0.3.0) - 2026-02-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- direct api key and cheap model ([#116](https://github.com/nearai/ironclaw/pull/116))
|
|
||||||
|
|
||||||
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
|
|
||||||
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
|
|
||||||
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
|
|
||||||
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
|
|
||||||
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
|
|
||||||
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
|
|
||||||
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
|
|
||||||
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
|
|
||||||
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Explicitly enable cargo-dist caching for binary artifacts building
|
|
||||||
- Skip building binary artifacts on every PR
|
|
||||||
- add module specification rules to CLAUDE.md
|
|
||||||
- add setup/onboarding specification (src/setup/README.md)
|
|
||||||
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
|
|
||||||
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
|
|
||||||
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
|
|
||||||
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
|
|
||||||
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
|
|
||||||
|
|
||||||
## [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,13 +11,8 @@
|
|||||||
- **Always available** - Multi-channel access with proactive background execution
|
- **Always available** - Multi-channel access with proactive background execution
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
|
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels)
|
||||||
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
||||||
- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern
|
|
||||||
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
|
|
||||||
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
|
|
||||||
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
|
|
||||||
- **Extension management**: Install, auth, activate MCP/WASM extensions
|
|
||||||
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
||||||
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
||||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
||||||
@@ -64,9 +59,7 @@ src/
|
|||||||
│ ├── context_monitor.rs # Memory pressure detection
|
│ ├── context_monitor.rs # Memory pressure detection
|
||||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||||
│ ├── task.rs # Sub-task execution framework
|
│ └── task.rs # Sub-task execution framework
|
||||||
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
|
||||||
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
|
||||||
│
|
│
|
||||||
├── channels/ # Multi-channel input
|
├── channels/ # Multi-channel input
|
||||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||||
@@ -79,33 +72,8 @@ src/
|
|||||||
│ │ ├── overlay.rs # Approval overlays
|
│ │ ├── overlay.rs # Approval overlays
|
||||||
│ │ └── composer.rs # Message composition
|
│ │ └── composer.rs # Message composition
|
||||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||||
│ ├── repl.rs # Simple REPL (for testing)
|
│ ├── slack.rs # Stub
|
||||||
│ ├── web/ # Web gateway (browser UI)
|
│ └── telegram.rs # Stub
|
||||||
│ │ ├── mod.rs # Gateway builder, startup
|
|
||||||
│ │ ├── server.rs # Axum router, 40+ API endpoints
|
|
||||||
│ │ ├── sse.rs # SSE broadcast manager
|
|
||||||
│ │ ├── ws.rs # WebSocket gateway + connection tracking
|
|
||||||
│ │ ├── types.rs # Request/response types, SseEvent enum
|
|
||||||
│ │ ├── auth.rs # Bearer token auth middleware
|
|
||||||
│ │ ├── log_layer.rs # Tracing layer for log streaming
|
|
||||||
│ │ └── static/ # HTML, CSS, JS (single-page app)
|
|
||||||
│ └── wasm/ # WASM channel runtime
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── bundled.rs # Bundled channel discovery
|
|
||||||
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
|
||||||
│
|
|
||||||
├── orchestrator/ # Internal HTTP API for sandbox containers
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
|
|
||||||
│ ├── auth.rs # Per-job bearer token store
|
|
||||||
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
|
|
||||||
│
|
|
||||||
├── worker/ # Runs inside Docker containers
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
|
|
||||||
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
|
|
||||||
│ ├── api.rs # HTTP client to orchestrator
|
|
||||||
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
|
|
||||||
│
|
│
|
||||||
├── safety/ # Prompt injection defense
|
├── safety/ # Prompt injection defense
|
||||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||||
@@ -128,9 +96,6 @@ src/
|
|||||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||||
│ │ ├── shell.rs # Shell command execution
|
│ │ ├── shell.rs # Shell command execution
|
||||||
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
||||||
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
|
|
||||||
│ │ ├── routine.rs # routine_create/list/update/delete/history
|
|
||||||
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
|
|
||||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||||
│ ├── builder/ # Dynamic tool building
|
│ ├── builder/ # Dynamic tool building
|
||||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||||
@@ -151,12 +116,6 @@ src/
|
|||||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||||
│ └── storage.rs # Linear memory persistence
|
│ └── 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)
|
├── workspace/ # Persistent memory system (OpenClaw-inspired)
|
||||||
│ ├── mod.rs # Workspace struct, memory operations
|
│ ├── mod.rs # Workspace struct, memory operations
|
||||||
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
||||||
@@ -198,9 +157,8 @@ When designing new features or systems, always prefer generic/extensible archite
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Use `thiserror` for error types in `error.rs`
|
- Use `thiserror` for error types in `error.rs`
|
||||||
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
|
- Never use `.unwrap()` in production code (tests are fine)
|
||||||
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
|
- 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
|
### Async
|
||||||
- All I/O is async with tokio
|
- All I/O is async with tokio
|
||||||
@@ -208,7 +166,6 @@ When designing new features or systems, always prefer generic/extensible archite
|
|||||||
- Use `RwLock` for concurrent read/write access
|
- Use `RwLock` for concurrent read/write access
|
||||||
|
|
||||||
### Traits for Extensibility
|
### Traits for Extensibility
|
||||||
- `Database` - Add new database backends (must implement all ~60 methods)
|
|
||||||
- `Channel` - Add new input sources
|
- `Channel` - Add new input sources
|
||||||
- `Tool` - Add new capabilities
|
- `Tool` - Add new capabilities
|
||||||
- `LlmProvider` - Add new LLM backends
|
- `LlmProvider` - Add new LLM backends
|
||||||
@@ -256,12 +213,7 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
|||||||
|
|
||||||
Environment variables (see `.env.example`):
|
Environment variables (see `.env.example`):
|
||||||
```bash
|
```bash
|
||||||
# Database backend (default: postgres)
|
|
||||||
DATABASE_BACKEND=postgres # or "libsql" / "turso"
|
|
||||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
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)
|
# NEAR AI (required)
|
||||||
NEARAI_SESSION_TOKEN=sess_...
|
NEARAI_SESSION_TOKEN=sess_...
|
||||||
@@ -284,30 +236,6 @@ HEARTBEAT_ENABLED=true
|
|||||||
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
||||||
HEARTBEAT_NOTIFY_CHANNEL=tui
|
HEARTBEAT_NOTIFY_CHANNEL=tui
|
||||||
HEARTBEAT_NOTIFY_USER=default
|
HEARTBEAT_NOTIFY_USER=default
|
||||||
|
|
||||||
# Web gateway
|
|
||||||
GATEWAY_ENABLED=true
|
|
||||||
GATEWAY_HOST=127.0.0.1
|
|
||||||
GATEWAY_PORT=3001
|
|
||||||
GATEWAY_AUTH_TOKEN=changeme # Required for API access
|
|
||||||
GATEWAY_USER_ID=default
|
|
||||||
|
|
||||||
# Docker sandbox
|
|
||||||
SANDBOX_ENABLED=true
|
|
||||||
SANDBOX_IMAGE=ironclaw-worker:latest
|
|
||||||
SANDBOX_MEMORY_LIMIT_MB=512
|
|
||||||
SANDBOX_TIMEOUT_SECS=1800
|
|
||||||
|
|
||||||
# Claude Code mode (runs inside sandbox containers)
|
|
||||||
CLAUDE_CODE_ENABLED=false
|
|
||||||
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
|
|
||||||
CLAUDE_CODE_MAX_TURNS=50
|
|
||||||
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
|
|
||||||
|
|
||||||
# Routines (scheduled/reactive execution)
|
|
||||||
ROUTINES_ENABLED=true
|
|
||||||
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
|
|
||||||
ROUTINES_MAX_CONCURRENT=3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### NEAR AI Provider
|
### NEAR AI Provider
|
||||||
@@ -321,51 +249,7 @@ Session tokens have the format `sess_xxx` (37 characters). They are authenticate
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
|
Single migration in `migrations/V1__initial.sql`. Tables:
|
||||||
|
|
||||||
**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:**
|
**Core:**
|
||||||
- `conversations` - Multi-channel conversation tracking
|
- `conversations` - Multi-channel conversation tracking
|
||||||
@@ -377,41 +261,12 @@ Both backends implement this trait. PostgreSQL delegates to the existing `Store`
|
|||||||
|
|
||||||
**Workspace/Memory:**
|
**Workspace/Memory:**
|
||||||
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
||||||
- `memory_chunks` - Chunked content with FTS and vector indexes
|
- `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) indexes
|
||||||
- `heartbeat_state` - Periodic execution tracking
|
- `heartbeat_state` - Periodic execution tracking
|
||||||
|
|
||||||
**Other:**
|
Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
|
||||||
- `routines`, `routine_runs` - Scheduled/reactive execution
|
|
||||||
- `settings` - Per-user key-value settings
|
|
||||||
- `tool_failures` - Self-repair tracking
|
|
||||||
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
|
|
||||||
|
|
||||||
### Configuration
|
Run migrations: `refinery migrate -c refinery.toml`
|
||||||
|
|
||||||
```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
|
## Safety Layer
|
||||||
|
|
||||||
@@ -442,14 +297,13 @@ Key test patterns:
|
|||||||
|
|
||||||
## Current Limitations / TODOs
|
## Current Limitations / TODOs
|
||||||
|
|
||||||
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
1. **Slack/Telegram channels** - Stubs only, need implementation
|
||||||
2. **Integration tests** - Need testcontainers setup for PostgreSQL
|
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||||
3. **MCP stdio transport** - Only HTTP transport implemented
|
3. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||||
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
4. **MCP stdio transport** - Only HTTP transport implemented
|
||||||
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||||
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||||
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
|
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||||
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
|
|
||||||
@@ -466,14 +320,6 @@ Key test patterns:
|
|||||||
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
||||||
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
||||||
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
||||||
- ✅ **Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket
|
|
||||||
- ✅ **Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines
|
|
||||||
- ✅ **Slack/Telegram channels** - Implemented as WASM tools
|
|
||||||
- ✅ **Docker sandbox** - Orchestrator/worker containers with per-job auth
|
|
||||||
- ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers
|
|
||||||
- ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails
|
|
||||||
- ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
|
|
||||||
- ✅ **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
|
## Adding a New Tool
|
||||||
|
|
||||||
@@ -630,22 +476,6 @@ RUST_LOG=ironclaw::agent=debug cargo run
|
|||||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module Specifications
|
|
||||||
|
|
||||||
Some modules have a `README.md` that serves as the authoritative specification
|
|
||||||
for that module's behavior. When modifying code in a module that has a spec:
|
|
||||||
|
|
||||||
1. **Read the spec first** before making changes
|
|
||||||
2. **Code follows spec**: if the spec says X, the code must do X
|
|
||||||
3. **Update both sides**: if you change behavior, update the spec to match;
|
|
||||||
if you're implementing a spec change, update the code to match
|
|
||||||
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
|
|
||||||
(unless the spec is clearly outdated, in which case fix the spec first)
|
|
||||||
|
|
||||||
| Module | Spec File |
|
|
||||||
|--------|-----------|
|
|
||||||
| `src/setup/` | `src/setup/README.md` |
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Use `crate::` imports, not `super::`
|
- Use `crate::` imports, not `super::`
|
||||||
@@ -654,37 +484,6 @@ for that module's behavior. When modifying code in a module that has a spec:
|
|||||||
- Keep functions focused, extract helpers when logic is reused
|
- Keep functions focused, extract helpers when logic is reused
|
||||||
- Comments for non-obvious logic only
|
- 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
|
## Workspace & Memory System
|
||||||
|
|
||||||
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
||||||
@@ -759,7 +558,7 @@ Four tools for LLM use:
|
|||||||
|
|
||||||
### Hybrid Search (RRF)
|
### Hybrid Search (RRF)
|
||||||
|
|
||||||
Combines full-text search and vector similarity using Reciprocal Rank Fusion:
|
Combines full-text search (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) using Reciprocal Rank Fusion:
|
||||||
|
|
||||||
```
|
```
|
||||||
score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
||||||
@@ -767,10 +566,6 @@ 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.
|
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
|
### Heartbeat System
|
||||||
|
|
||||||
Proactive periodic execution (default: 30 minutes):
|
Proactive periodic execution (default: 30 minutes):
|
||||||
|
|||||||
Generated
+233
-1019
File diff suppressed because it is too large
Load Diff
+18
-97
@@ -1,27 +1,10 @@
|
|||||||
[workspace]
|
|
||||||
exclude = [
|
|
||||||
"channels-src/telegram",
|
|
||||||
"channels-src/slack",
|
|
||||||
"channels-src/whatsapp",
|
|
||||||
"tools-src/gmail",
|
|
||||||
]
|
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.5.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.85"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
authors = ["NEAR AI <[email protected]>"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
homepage = "https://github.com/nearai/ironclaw"
|
|
||||||
repository = "https://github.com/nearai/ironclaw"
|
|
||||||
|
|
||||||
[package.metadata.wix]
|
|
||||||
upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F"
|
|
||||||
path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339"
|
|
||||||
license = false
|
|
||||||
eula = false
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Async runtime
|
# Async runtime
|
||||||
@@ -30,20 +13,17 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
# Database - PostgreSQL (default, feature-gated)
|
# Database
|
||||||
deadpool-postgres = { version = "0.14", optional = true }
|
deadpool-postgres = "0.14"
|
||||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
|
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"], optional = true }
|
postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
|
||||||
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
refinery = { version = "0.8", features = ["tokio-postgres"] }
|
||||||
|
|
||||||
# Database - libSQL/Turso (optional embedded database)
|
|
||||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
@@ -59,7 +39,7 @@ dotenvy = "0.15"
|
|||||||
# Core types
|
# Core types
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
|
||||||
rust_decimal_macros = "1"
|
rust_decimal_macros = "1"
|
||||||
|
|
||||||
# Async traits
|
# Async traits
|
||||||
@@ -78,22 +58,17 @@ axum = { version = "0.8", features = ["ws"] }
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||||
|
|
||||||
# Cron scheduling for routines
|
|
||||||
cron = "0.13"
|
|
||||||
|
|
||||||
# Safety/sanitization
|
# Safety/sanitization
|
||||||
regex = "1"
|
regex = "1"
|
||||||
aho-corasick = "1"
|
aho-corasick = "1"
|
||||||
|
|
||||||
# Filesystem paths
|
# Filesystem paths
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
fs4 = "0.6"
|
|
||||||
|
|
||||||
# Secrecy for sensitive values
|
# Secrecy for sensitive values
|
||||||
secrecy = { version = "0.10", features = ["serde"] }
|
secrecy = { version = "0.10", features = ["serde"] }
|
||||||
|
|
||||||
# URL parsing and encoding
|
# URL encoding for OAuth flow
|
||||||
url = "2"
|
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
# Open URLs in browser
|
# Open URLs in browser
|
||||||
@@ -101,7 +76,7 @@ open = "5"
|
|||||||
|
|
||||||
# Vector embeddings for semantic search
|
# Vector embeddings for semantic search
|
||||||
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
||||||
pgvector = { version = "0.4", features = ["postgres"], optional = true }
|
pgvector = { version = "0.4", features = ["postgres"] }
|
||||||
|
|
||||||
# WASM sandbox for untrusted tool execution
|
# WASM sandbox for untrusted tool execution
|
||||||
wasmtime = { version = "28", features = ["component-model"] }
|
wasmtime = { version = "28", features = ["component-model"] }
|
||||||
@@ -114,10 +89,13 @@ hkdf = "0.12"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
subtle = "2" # Constant-time comparisons for token validation
|
|
||||||
|
|
||||||
# Multi-provider LLM support
|
# NEAR key management (ed25519 signing, borsh serialization, base58 encoding)
|
||||||
rig-core = "0.30"
|
ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] }
|
||||||
|
borsh = { version = "1", features = ["derive"] }
|
||||||
|
bs58 = "0.5"
|
||||||
|
argon2 = "0.5"
|
||||||
|
zeroize = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
# Docker sandbox
|
# Docker sandbox
|
||||||
bollard = "0.18"
|
bollard = "0.18"
|
||||||
@@ -128,7 +106,6 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
|
|||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
mime_guess = "2.0.5"
|
|
||||||
|
|
||||||
# macOS keychain
|
# macOS keychain
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
@@ -147,61 +124,5 @@ pretty_assertions = "1"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["postgres", "libsql"]
|
default = []
|
||||||
postgres = [
|
|
||||||
"dep:deadpool-postgres",
|
|
||||||
"dep:tokio-postgres",
|
|
||||||
"dep:postgres-types",
|
|
||||||
"dep:refinery",
|
|
||||||
"dep:pgvector",
|
|
||||||
"rust_decimal/db-tokio-postgres",
|
|
||||||
]
|
|
||||||
libsql = ["dep:libsql"]
|
|
||||||
integration = []
|
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 = "skip"
|
|
||||||
# Path that installers should place binaries in
|
|
||||||
install-path = "CARGO_HOME"
|
|
||||||
# Whether to install an updater program
|
|
||||||
install-updater = true
|
|
||||||
# Cache intermediate build artifacts to speed up the release pipelines
|
|
||||||
cache-builds = 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
@@ -1,46 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
+51
-57
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
|
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
|
||||||
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE |
|
| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 |
|
||||||
| Single-user system | ✅ | ✅ | |
|
| Single-user system | ✅ | ✅ | |
|
||||||
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
||||||
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
||||||
@@ -31,19 +31,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints |
|
| Gateway control plane | ✅ | ❌ | Central WebSocket server |
|
||||||
| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions |
|
| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard |
|
||||||
| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker |
|
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
|
||||||
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
||||||
| Configuration hot-reload | ✅ | ❌ | |
|
| Configuration hot-reload | ✅ | ❌ | |
|
||||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions |
|
| OpenAI-compatible HTTP API | ✅ | ❌ | /v1/chat/completions |
|
||||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||||
| launchd/systemd integration | ✅ | ❌ | |
|
| launchd/systemd integration | ✅ | ❌ | |
|
||||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||||
| Tailscale integration | ✅ | ❌ | |
|
| Tailscale integration | ✅ | ❌ | |
|
||||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
| Health check endpoints | ✅ | ❌ | |
|
||||||
| `doctor` diagnostics | ✅ | ❌ | |
|
| `doctor` diagnostics | ✅ | ❌ | |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
| Telegram | ✅ | ❌ | P1 | grammY (Bot API) |
|
||||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||||
| Slack | ✅ | ✅ | - | WASM tool |
|
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
|
||||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||||
| LINE | ✅ | ❌ | P3 | |
|
| LINE | ✅ | ❌ | P3 | |
|
||||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
| WebChat | ✅ | ❌ | P2 | Browser-based chat |
|
||||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||||
| Mattermost | ✅ | ❌ | P3 | |
|
| Mattermost | ✅ | ❌ | P3 | |
|
||||||
| Google Chat | ✅ | ❌ | P3 | |
|
| Google Chat | ✅ | ❌ | P3 | |
|
||||||
@@ -79,13 +79,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
| DM pairing codes | ✅ | ❌ | Verification for unknown senders |
|
||||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
| Allowlist/blocklist | ✅ | ❌ | Per-channel access control |
|
||||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
| Mention-based activation | ✅ | ❌ | Configurable patterns |
|
||||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
| Per-channel media limits | ✅ | ❌ | |
|
||||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -99,20 +99,20 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `run` (agent) | ✅ | ✅ | - | Default command |
|
| `run` (agent) | ✅ | ✅ | - | Default command |
|
||||||
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
||||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup |
|
||||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||||
| `config` | ✅ | ✅ | - | Read/write config |
|
| `config` | ✅ | ❌ | P2 | Read/write config |
|
||||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||||
| `status` | ✅ | ✅ | - | System status |
|
| `status` | ✅ | ❌ | P2 | System status |
|
||||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||||
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
| `memory` | ✅ | ❌ | P2 | Memory search CLI |
|
||||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||||
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
| `pairing` | ✅ | ❌ | P3 | Node pairing |
|
||||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
||||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||||
@@ -132,8 +132,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
||||||
| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
|
| RPC-based execution | ✅ | 🚧 | Worker isolation |
|
||||||
| Multi-provider failover | ✅ | ✅ | `FailoverProvider` tries providers sequentially on retryable errors |
|
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
||||||
| Per-sender sessions | ✅ | ✅ | |
|
| Per-sender sessions | ✅ | ✅ | |
|
||||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||||
@@ -164,7 +164,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||||
| Google Gemini | ✅ | ❌ | P3 | |
|
| Google Gemini | ✅ | ❌ | P3 | |
|
||||||
| OpenRouter | ✅ | ❌ | P3 | |
|
| OpenRouter | ✅ | ❌ | P3 | |
|
||||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
| Ollama (local) | ✅ | ❌ | P2 | Local models |
|
||||||
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
||||||
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
||||||
|
|
||||||
@@ -173,8 +173,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Auto-discovery | ✅ | ❌ | |
|
| Auto-discovery | ✅ | ❌ | |
|
||||||
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
|
| Failover chains | ✅ | ❌ | Provider fallback |
|
||||||
| Cooldown management | ✅ | ✅ | Lock-free per-provider cooldown in `FailoverProvider` |
|
| Cooldown management | ✅ | ❌ | Skip failed providers |
|
||||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
| 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 |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions |
|
| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config |
|
||||||
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
|
| Channel status view | ✅ | ❌ | P2 | |
|
||||||
| Agent management | ✅ | ❌ | P3 | |
|
| Agent management | ✅ | ❌ | P3 | |
|
||||||
| Model selection | ✅ | ✅ | - | TUI only |
|
| Model selection | ✅ | ✅ | - | TUI only |
|
||||||
| Config editing | ✅ | ❌ | P3 | |
|
| Config editing | ✅ | ❌ | P3 | |
|
||||||
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
| Debug/logs viewer | ✅ | ❌ | P3 | |
|
||||||
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
| WebChat interface | ✅ | ❌ | P2 | Browser chat |
|
||||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
@@ -320,17 +320,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks |
|
||||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
| Timezone support | ✅ | ❌ | P2 | |
|
||||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
| One-shot/recurring jobs | ✅ | ❌ | P2 | |
|
||||||
| `beforeInbound` hook | ✅ | ✅ | P2 | |
|
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||||
| `beforeOutbound` hook | ✅ | ✅ | P2 | |
|
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||||
| `beforeToolCall` hook | ✅ | ✅ | P2 | |
|
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
| `onMessage` hook | ✅ | ❌ | P2 | |
|
||||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||||
| `transformResponse` hook | ✅ | ✅ | P2 | |
|
| `transformResponse` hook | ✅ | ❌ | P2 | |
|
||||||
| Bundled hooks | ✅ | ❌ | P2 | |
|
| Bundled hooks | ✅ | ❌ | P2 | |
|
||||||
| Plugin hooks | ✅ | ❌ | P3 | |
|
| Plugin hooks | ✅ | ❌ | P3 | |
|
||||||
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
||||||
@@ -346,18 +346,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
|
||||||
| Device pairing | ✅ | ❌ | |
|
| Device pairing | ✅ | ❌ | |
|
||||||
| Tailscale identity | ✅ | ❌ | |
|
| Tailscale identity | ✅ | ❌ | |
|
||||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
| DM pairing verification | ✅ | ❌ | |
|
||||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
| Allowlist/blocklist | ✅ | ❌ | |
|
||||||
| Per-group tool policies | ✅ | ❌ | |
|
| Per-group tool policies | ✅ | ❌ | |
|
||||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||||
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
| Docker sandbox | ✅ | ❌ | Uses WASM sandbox |
|
||||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||||
| Tool policies | ✅ | ✅ | |
|
| Tool policies | ✅ | ✅ | |
|
||||||
| Elevated mode | ✅ | ❌ | |
|
| Elevated mode | ✅ | ❌ | |
|
||||||
@@ -397,7 +397,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
### P0 - Core (Already Done)
|
### P0 - Core (Already Done)
|
||||||
- ✅ TUI channel with approval overlays
|
- ✅ TUI channel with approval overlays
|
||||||
- ✅ HTTP webhook channel
|
- ✅ HTTP webhook channel
|
||||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
|
||||||
- ✅ WASM tool sandbox
|
- ✅ WASM tool sandbox
|
||||||
- ✅ Workspace/memory with hybrid search
|
- ✅ Workspace/memory with hybrid search
|
||||||
- ✅ Prompt injection defense
|
- ✅ Prompt injection defense
|
||||||
@@ -405,28 +404,23 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- ✅ Session management
|
- ✅ Session management
|
||||||
- ✅ Context compaction
|
- ✅ Context compaction
|
||||||
- ✅ Model selection
|
- ✅ Model selection
|
||||||
- ✅ Gateway control plane + WebSocket
|
|
||||||
- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines)
|
|
||||||
- ✅ WebChat channel (web gateway)
|
|
||||||
- ✅ Slack channel (WASM tool)
|
|
||||||
- ✅ Telegram channel (WASM tool, MTProto)
|
|
||||||
- ✅ Docker sandbox (orchestrator/worker)
|
|
||||||
- ✅ Cron job scheduling (routines)
|
|
||||||
- ✅ CLI subcommands (onboard, config, status, memory)
|
|
||||||
- ✅ Gateway token auth
|
|
||||||
|
|
||||||
### P1 - High Priority
|
### P1 - High Priority
|
||||||
- ❌ Slack channel (real implementation)
|
- ❌ Slack channel (real implementation)
|
||||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
- ❌ Telegram channel
|
||||||
- ❌ WhatsApp channel
|
- ❌ WhatsApp channel
|
||||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
- ❌ Multi-provider failover
|
||||||
- ✅ Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse)
|
- ❌ Gateway control plane + WebSocket
|
||||||
|
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||||
|
|
||||||
### P2 - Medium Priority
|
### P2 - Medium Priority
|
||||||
|
- ❌ Cron job scheduling
|
||||||
|
- ❌ Web Control UI
|
||||||
|
- ❌ WebChat channel
|
||||||
- ❌ Media handling (images, PDFs)
|
- ❌ Media handling (images, PDFs)
|
||||||
|
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||||
- ❌ Ollama/local model support
|
- ❌ Ollama/local model support
|
||||||
- ❌ Configuration hot-reload
|
- ❌ Configuration hot-reload
|
||||||
- ❌ Webhook trigger endpoint in web gateway
|
|
||||||
|
|
||||||
### P3 - Lower Priority
|
### P3 - Lower Priority
|
||||||
- ❌ Discord channel
|
- ❌ Discord channel
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
|||||||
|
|
||||||
### Always Available
|
### Always Available
|
||||||
|
|
||||||
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway
|
- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more)
|
||||||
- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern
|
|
||||||
- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming
|
|
||||||
- **Routines** - Cron schedules, event triggers, webhook handlers for background automation
|
|
||||||
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
||||||
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
||||||
- **Self-repair** - Automatic detection and recovery of stuck operations
|
- **Self-repair** - Automatic detection and recovery of stuck operations
|
||||||
@@ -68,41 +65,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Rust 1.85+
|
- Rust 1.85+
|
||||||
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
|
- PostgreSQL 15+ with pgvector extension
|
||||||
- NEAR AI account (authentication handled via setup wizard)
|
- NEAR AI account (authentication handled via setup wizard)
|
||||||
|
|
||||||
## Download or Build
|
### 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
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
@@ -116,10 +82,6 @@ cargo build --release
|
|||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Database Setup
|
### Database Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -135,7 +97,7 @@ psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
|||||||
Run the setup wizard to configure IronClaw:
|
Run the setup wizard to configure IronClaw:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ironclaw onboard
|
ironclaw setup
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||||
@@ -181,42 +143,37 @@ External content passes through multiple security layers:
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ Channels │
|
│ Channels │
|
||||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
│ ┌──────┐ ┌──────┐ ┌──────────────┐ │
|
||||||
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │
|
│ │ REPL │ │ HTTP │ │ WASM Channels│ │
|
||||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
│ └──┬───┘ └──┬───┘ └──────┬───────┘ │
|
||||||
│ │ │ │ └──────┬──────┘ │
|
│ └─────────┴─────────────┘ │
|
||||||
│ └─────────┴──────────────┴────────────────┘ │
|
│ │ │
|
||||||
│ │ │
|
│ ┌────▼────┐ │
|
||||||
│ ┌─────────▼─────────┐ │
|
│ │ Router │ Intent classification │
|
||||||
│ │ Agent Loop │ Intent routing │
|
│ └────┬────┘ │
|
||||||
│ └────┬──────────┬───┘ │
|
│ │ │
|
||||||
│ │ │ │
|
│ ┌──────────▼──────────┐ │
|
||||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
│ │ Scheduler │ Parallel job management │
|
||||||
│ │ Scheduler │ │ Routines Engine │ │
|
│ └──────────┬──────────┘ │
|
||||||
│ │(parallel jobs)│ │(cron, event, wh) │ │
|
│ │ │
|
||||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
│ ┌───────────────┼───────────────┐ │
|
||||||
│ │ │ │
|
│ ▼ ▼ ▼ │
|
||||||
│ ┌─────────────┼────────────────────┘ │
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
│ │ │ │
|
│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │
|
||||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||||
│ │ Local │ │ Orchestrator │ │
|
│ └───────────────┼───────────────┘ │
|
||||||
│ │Workers │ │ ┌───────────────┐ │ │
|
│ │ │
|
||||||
│ │(in-proc)│ │ │ Docker Sandbox│ │ │
|
│ ┌──────────▼──────────┐ │
|
||||||
│ └───┬─────┘ │ │ Containers │ │ │
|
│ │ Tool Registry │ │
|
||||||
│ │ │ │ ┌───────────┐ │ │ │
|
│ │ ┌───────────────┐ │ │
|
||||||
│ │ │ │ │Worker / CC│ │ │ │
|
│ │ │ Built-in │ │ │
|
||||||
│ │ │ │ └───────────┘ │ │ │
|
│ │ │ MCP │ │ │
|
||||||
│ │ │ └───────────────┘ │ │
|
│ │ │ WASM Sandbox │ │ │
|
||||||
│ │ └─────────┬───────────┘ │
|
│ │ └───────────────┘ │ │
|
||||||
│ └──────────────────┤ │
|
│ └─────────────────────┘ │
|
||||||
│ │ │
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
│ ┌───────────▼──────────┐ │
|
|
||||||
│ │ Tool Registry │ │
|
|
||||||
│ │ Built-in, MCP, WASM │ │
|
|
||||||
│ └──────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Core Components
|
### Core Components
|
||||||
@@ -227,9 +184,6 @@ External content passes through multiple security layers:
|
|||||||
| **Router** | Classifies user intent (command, query, task) |
|
| **Router** | Classifies user intent (command, query, task) |
|
||||||
| **Scheduler** | Manages parallel job execution with priorities |
|
| **Scheduler** | Manages parallel job execution with priorities |
|
||||||
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
||||||
| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth |
|
|
||||||
| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines |
|
|
||||||
| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks |
|
|
||||||
| **Workspace** | Persistent memory with hybrid search |
|
| **Workspace** | Persistent memory with hybrid search |
|
||||||
| **Safety Layer** | Prompt injection defense and content sanitization |
|
| **Safety Layer** | Prompt injection defense and content sanitization |
|
||||||
|
|
||||||
@@ -237,7 +191,7 @@ External content passes through multiple security layers:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First-time setup (configures database, auth, etc.)
|
# First-time setup (configures database, auth, etc.)
|
||||||
ironclaw onboard
|
ironclaw setup
|
||||||
|
|
||||||
# Start interactive REPL
|
# Start interactive REPL
|
||||||
cargo run
|
cargo run
|
||||||
@@ -256,16 +210,12 @@ cargo fmt
|
|||||||
cargo clippy --all --benches --tests --examples --all-features
|
cargo clippy --all --benches --tests --examples --all-features
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
createdb ironclaw_test
|
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Run specific test
|
# Run specific test
|
||||||
cargo test test_name
|
cargo test test_name
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
|
|
||||||
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
|
|
||||||
|
|
||||||
## OpenClaw Heritage
|
## OpenClaw Heritage
|
||||||
|
|
||||||
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "discord-channel"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Discord channel for IronClaw"
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
wit-bindgen = "0.41.0"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = true
|
|
||||||
opt-level = "s"
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# Discord Channel for IronClaw
|
|
||||||
|
|
||||||
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Slash Commands** - Process Discord slash commands
|
|
||||||
- **Button Interactions** - Handle button clicks
|
|
||||||
- **Thread Support** - Respond in threads
|
|
||||||
- **DM Support** - Handle direct messages
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Create a Discord Application at <https://discord.com/developers/applications>
|
|
||||||
2. Create a Bot and get the token
|
|
||||||
3. Set up Interactions URL to point to your IronClaw instance
|
|
||||||
4. Copy the Application ID and Public Key
|
|
||||||
5. Store in IronClaw secrets:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
|
||||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
|
||||||
secrets are used by the IronClaw host (for example, to verify Discord
|
|
||||||
interaction signatures and manage slash command registration) and are not
|
|
||||||
accessed from the WASM module itself.
|
|
||||||
|
|
||||||
## Discord Configuration
|
|
||||||
|
|
||||||
### Register Slash Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: Bot YOUR_BOT_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
|
|
||||||
-d '{
|
|
||||||
"name": "ask",
|
|
||||||
"description": "Ask the AI agent",
|
|
||||||
"options": [{
|
|
||||||
"name": "question",
|
|
||||||
"description": "Your question",
|
|
||||||
"type": 3,
|
|
||||||
"required": true
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set Interactions Endpoint
|
|
||||||
|
|
||||||
In your Discord app settings, set:
|
|
||||||
|
|
||||||
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Slash Command
|
|
||||||
|
|
||||||
User types: `/ask question: What is the weather?`
|
|
||||||
|
|
||||||
The agent receives:
|
|
||||||
|
|
||||||
```text
|
|
||||||
User: @username
|
|
||||||
Content: /ask question: What is the weather?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Button Click
|
|
||||||
|
|
||||||
When a user clicks a button in a message, the agent receives:
|
|
||||||
|
|
||||||
```text
|
|
||||||
User: @username
|
|
||||||
Content: [Button clicked] Original message content
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
|
|
||||||
|
|
||||||
```text
|
|
||||||
❌ Internal Error: Failed to process command metadata.
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the host logs for detailed error information.
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Embeds
|
|
||||||
|
|
||||||
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Invalid Signature"
|
|
||||||
|
|
||||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
|
||||||
- This validation happens on the host before reaching the WASM.
|
|
||||||
|
|
||||||
### "401 Unauthorized"
|
|
||||||
|
|
||||||
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
|
|
||||||
- Ensure the bot is added to the server.
|
|
||||||
|
|
||||||
### "Interaction Failed"
|
|
||||||
|
|
||||||
- The interaction might have timed out (Discord requires a response within 3 seconds).
|
|
||||||
- The `interactions_endpoint_url` might be unreachable.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd channels-src/discord
|
|
||||||
cargo build --target wasm32-wasi --release
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT/Apache-2.0
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "channel",
|
|
||||||
"name": "discord",
|
|
||||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
|
||||||
"capabilities": {
|
|
||||||
"http": {
|
|
||||||
"allowlist": [
|
|
||||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"discord_bot_token": {
|
|
||||||
"secret_name": "discord_bot_token",
|
|
||||||
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
|
|
||||||
"host_patterns": ["discord.com"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rate_limit": {
|
|
||||||
"requests_per_minute": 60,
|
|
||||||
"requests_per_hour": 3600
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secrets": {
|
|
||||||
"allowed_names": ["discord_bot_token", "discord_*"]
|
|
||||||
},
|
|
||||||
"channel": {
|
|
||||||
"allowed_paths": ["/webhook/discord"],
|
|
||||||
"allow_polling": false,
|
|
||||||
"callback_timeout_secs": 45,
|
|
||||||
"workspace_prefix": "channels/discord/",
|
|
||||||
"emit_rate_limit": {
|
|
||||||
"messages_per_minute": 100,
|
|
||||||
"messages_per_hour": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"require_signature_verification": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
//! Discord Gateway/Webhook channel for IronClaw.
|
|
||||||
//!
|
|
||||||
//! This WASM component implements the channel interface for handling Discord
|
|
||||||
//! interactions via webhooks and sending messages back to Discord.
|
|
||||||
//!
|
|
||||||
//! # Features
|
|
||||||
//!
|
|
||||||
//! - URL verification for Discord interactions
|
|
||||||
//! - Slash command handling
|
|
||||||
//! - Message event parsing (@mentions, DMs)
|
|
||||||
//! - Thread support for conversations
|
|
||||||
//! - Response posting via Discord Web API
|
|
||||||
//! - Automatic message truncation (> 2000 chars)
|
|
||||||
//!
|
|
||||||
//! # Security
|
|
||||||
//!
|
|
||||||
//! - Signature validation is handled by the host (webhook secrets)
|
|
||||||
//! - Bot token is injected by host during HTTP requests
|
|
||||||
//! - WASM never sees raw credentials
|
|
||||||
|
|
||||||
wit_bindgen::generate!({
|
|
||||||
world: "sandboxed-channel",
|
|
||||||
path: "../../wit/channel.wit",
|
|
||||||
});
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use exports::near::agent::channel::{
|
|
||||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
|
||||||
OutgoingHttpResponse, StatusUpdate,
|
|
||||||
};
|
|
||||||
use near::agent::channel_host::{self, EmittedMessage};
|
|
||||||
|
|
||||||
/// Discord interaction wrapper.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct DiscordInteraction {
|
|
||||||
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
interaction_type: u8,
|
|
||||||
|
|
||||||
/// Interaction ID
|
|
||||||
id: String,
|
|
||||||
|
|
||||||
/// Application ID
|
|
||||||
application_id: String,
|
|
||||||
|
|
||||||
/// Guild ID (if in server)
|
|
||||||
#[allow(dead_code)] // Part of API payload, currently unused
|
|
||||||
guild_id: Option<String>,
|
|
||||||
|
|
||||||
/// Channel ID
|
|
||||||
channel_id: Option<String>,
|
|
||||||
|
|
||||||
/// Member info (if in server)
|
|
||||||
member: Option<DiscordMember>,
|
|
||||||
|
|
||||||
/// User info (if DM)
|
|
||||||
user: Option<DiscordUser>,
|
|
||||||
|
|
||||||
/// Command data (for slash commands)
|
|
||||||
data: Option<DiscordCommandData>,
|
|
||||||
|
|
||||||
/// Message (for component interactions)
|
|
||||||
message: Option<DiscordMessage>,
|
|
||||||
|
|
||||||
/// Token for responding
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct DiscordMember {
|
|
||||||
user: DiscordUser,
|
|
||||||
#[allow(dead_code)] // Part of API payload, currently unused
|
|
||||||
nick: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct DiscordUser {
|
|
||||||
id: String,
|
|
||||||
username: String,
|
|
||||||
global_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct DiscordCommandData {
|
|
||||||
#[allow(dead_code)] // Part of API payload, currently unused
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
options: Option<Vec<DiscordCommandOption>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct DiscordCommandOption {
|
|
||||||
name: String,
|
|
||||||
value: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
struct DiscordMessage {
|
|
||||||
#[allow(dead_code)] // Part of API payload, currently unused
|
|
||||||
id: String,
|
|
||||||
content: String,
|
|
||||||
channel_id: String,
|
|
||||||
#[allow(dead_code)] // Part of API payload, currently unused
|
|
||||||
author: DiscordUser,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata stored with emitted messages for response routing.
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct DiscordMessageMetadata {
|
|
||||||
/// Discord channel ID
|
|
||||||
channel_id: String,
|
|
||||||
|
|
||||||
/// Interaction ID for followups
|
|
||||||
interaction_id: String,
|
|
||||||
|
|
||||||
/// Interaction token for responding
|
|
||||||
token: String,
|
|
||||||
|
|
||||||
/// Application ID
|
|
||||||
application_id: String,
|
|
||||||
|
|
||||||
/// Thread ID (for forum threads)
|
|
||||||
thread_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscordChannel;
|
|
||||||
|
|
||||||
impl Guest for DiscordChannel {
|
|
||||||
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
|
|
||||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
|
||||||
|
|
||||||
Ok(ChannelConfig {
|
|
||||||
display_name: "Discord".to_string(),
|
|
||||||
http_endpoints: vec![HttpEndpointConfig {
|
|
||||||
path: "/webhook/discord".to_string(),
|
|
||||||
methods: vec!["POST".to_string()],
|
|
||||||
require_secret: true,
|
|
||||||
}],
|
|
||||||
poll: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
|
||||||
let body_str = match std::str::from_utf8(&req.body) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => {
|
|
||||||
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(e) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to parse Discord interaction: {}", e),
|
|
||||||
);
|
|
||||||
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match interaction.interaction_type {
|
|
||||||
// Ping - Discord verification
|
|
||||||
1 => {
|
|
||||||
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
|
|
||||||
json_response(200, serde_json::json!({"type": 1}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Application Command (slash command)
|
|
||||||
2 => {
|
|
||||||
handle_slash_command(&interaction);
|
|
||||||
json_response(
|
|
||||||
200,
|
|
||||||
serde_json::json!({
|
|
||||||
"type": 5,
|
|
||||||
"data": {
|
|
||||||
"content": "🤔 Thinking..."
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message Component (buttons, selects)
|
|
||||||
3 => {
|
|
||||||
if let Some(ref message) = interaction.message {
|
|
||||||
handle_message_component(&interaction, message);
|
|
||||||
}
|
|
||||||
json_response(200, serde_json::json!({"type": 6}))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!(
|
|
||||||
"Unknown Discord interaction type: {}",
|
|
||||||
interaction.interaction_type
|
|
||||||
),
|
|
||||||
);
|
|
||||||
json_response(200, serde_json::json!({"type": 6}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_poll() {}
|
|
||||||
|
|
||||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
||||||
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
|
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
|
||||||
|
|
||||||
// Use webhook endpoint for followup
|
|
||||||
let url = format!(
|
|
||||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
|
||||||
metadata.application_id, metadata.token
|
|
||||||
);
|
|
||||||
|
|
||||||
// Truncate content to 2000 characters to comply with Discord limits
|
|
||||||
let content = truncate_message(&response.content);
|
|
||||||
|
|
||||||
let mut payload = serde_json::json!({
|
|
||||||
"content": content,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for embeds in metadata
|
|
||||||
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
|
|
||||||
if let Some(embeds) = meta_json.get("embeds") {
|
|
||||||
payload["embeds"] = embeds.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload_bytes =
|
|
||||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
|
||||||
|
|
||||||
let headers = serde_json::json!({
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = channel_host::http_request(
|
|
||||||
"POST",
|
|
||||||
&url,
|
|
||||||
&headers.to_string(),
|
|
||||||
Some(&payload_bytes),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(http_response) => {
|
|
||||||
if http_response.status >= 200 && http_response.status < 300 {
|
|
||||||
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
||||||
Err(format!(
|
|
||||||
"Discord API error: {} - {}",
|
|
||||||
http_response.status, body_str
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_status(_update: StatusUpdate) {}
|
|
||||||
|
|
||||||
fn on_shutdown() {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Info,
|
|
||||||
"Discord channel shutting down",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_slash_command(interaction: &DiscordInteraction) {
|
|
||||||
let user = interaction
|
|
||||||
.member
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| &m.user)
|
|
||||||
.or(interaction.user.as_ref());
|
|
||||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
|
||||||
let user_name = user
|
|
||||||
.map(|u| {
|
|
||||||
u.global_name
|
|
||||||
.as_ref()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or(&u.username)
|
|
||||||
.clone()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let channel_id = interaction.channel_id.clone().unwrap_or_default();
|
|
||||||
|
|
||||||
let command_name = interaction
|
|
||||||
.data
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.name.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
|
|
||||||
|
|
||||||
let content = if let Some(opts) = options {
|
|
||||||
let opt_str = opts
|
|
||||||
.iter()
|
|
||||||
.map(|o| format!("{}: {}", o.name, o.value))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
format!("/{} {}", command_name, opt_str)
|
|
||||||
} else {
|
|
||||||
format!("/{}", command_name)
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata = DiscordMessageMetadata {
|
|
||||||
channel_id: channel_id.clone(),
|
|
||||||
interaction_id: interaction.id.clone(),
|
|
||||||
token: interaction.token.clone(),
|
|
||||||
application_id: interaction.application_id.clone(),
|
|
||||||
thread_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata_json = match serde_json::to_string(&metadata) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(e) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to serialize metadata: {}", e),
|
|
||||||
);
|
|
||||||
// Attempt to notify user of internal error
|
|
||||||
let url = format!(
|
|
||||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
|
||||||
interaction.application_id, interaction.token
|
|
||||||
);
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"content": "❌ Internal Error: Failed to process command metadata.",
|
|
||||||
"flags": 64 // Ephemeral
|
|
||||||
});
|
|
||||||
let _ = channel_host::http_request(
|
|
||||||
"POST",
|
|
||||||
&url,
|
|
||||||
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
|
|
||||||
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
channel_host::emit_message(&EmittedMessage {
|
|
||||||
user_id,
|
|
||||||
user_name: Some(user_name),
|
|
||||||
content,
|
|
||||||
thread_id: None,
|
|
||||||
metadata_json,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
|
||||||
// Check member first (for server contexts), then user (for DMs)
|
|
||||||
let user = interaction
|
|
||||||
.member
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| &m.user)
|
|
||||||
.or(interaction.user.as_ref());
|
|
||||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
|
||||||
let user_name = user
|
|
||||||
.map(|u| {
|
|
||||||
u.global_name
|
|
||||||
.as_ref()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or(&u.username)
|
|
||||||
.clone()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let channel_id = message.channel_id.clone();
|
|
||||||
|
|
||||||
let metadata = DiscordMessageMetadata {
|
|
||||||
channel_id: channel_id.clone(),
|
|
||||||
interaction_id: interaction.id.clone(),
|
|
||||||
token: interaction.token.clone(),
|
|
||||||
application_id: interaction.application_id.clone(),
|
|
||||||
thread_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata_json = match serde_json::to_string(&metadata) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(e) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to serialize metadata: {}", e),
|
|
||||||
);
|
|
||||||
return; // Don't emit message if metadata can't be serialized
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
channel_host::emit_message(&EmittedMessage {
|
|
||||||
user_id,
|
|
||||||
user_name: Some(user_name),
|
|
||||||
content: format!("[Button clicked] {}", message.content),
|
|
||||||
thread_id: None,
|
|
||||||
metadata_json,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
|
||||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
|
||||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
|
||||||
|
|
||||||
OutgoingHttpResponse {
|
|
||||||
status,
|
|
||||||
headers_json: headers.to_string(),
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export!(DiscordChannel);
|
|
||||||
|
|
||||||
fn truncate_message(content: &str) -> String {
|
|
||||||
if content.len() <= 2000 {
|
|
||||||
content.to_string()
|
|
||||||
} else {
|
|
||||||
let max_bytes = 1990;
|
|
||||||
let cutoff = content
|
|
||||||
.char_indices()
|
|
||||||
.map(|(i, c)| i + c.len_utf8())
|
|
||||||
.take_while(|&end| end <= max_bytes)
|
|
||||||
.last()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let mut truncated = content[..cutoff].to_string();
|
|
||||||
truncated.push_str("\n... (truncated)");
|
|
||||||
truncated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncate_message() {
|
|
||||||
let short = "Hello world";
|
|
||||||
assert_eq!(truncate_message(short), short);
|
|
||||||
|
|
||||||
let long = "a".repeat(2005);
|
|
||||||
let truncated = truncate_message(&long);
|
|
||||||
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
|
|
||||||
assert!(truncated.ends_with("\n... (truncated)"));
|
|
||||||
|
|
||||||
// Test with multibyte characters (Euro sign is 3 bytes)
|
|
||||||
// 1000 chars * 3 bytes = 3000 bytes
|
|
||||||
let multi = "€".repeat(1000);
|
|
||||||
let truncated_multi = truncate_message(&multi);
|
|
||||||
|
|
||||||
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
|
|
||||||
// Should truncate at 663 chars (1989 bytes).
|
|
||||||
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
|
|
||||||
assert!(truncated_multi.len() <= 2006);
|
|
||||||
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
|
|
||||||
assert!(truncated_multi.ends_with("\n... (truncated)"));
|
|
||||||
|
|
||||||
let content_part = &truncated_multi[..truncated_multi.len() - 16];
|
|
||||||
assert!(content_part.chars().all(|c| c == '€'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_metadata_serialization() {
|
|
||||||
let metadata = DiscordMessageMetadata {
|
|
||||||
channel_id: "123".into(),
|
|
||||||
interaction_id: "456".into(),
|
|
||||||
token: "abc".into(),
|
|
||||||
application_id: "789".into(),
|
|
||||||
thread_id: None,
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string(&metadata).unwrap();
|
|
||||||
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(parsed.channel_id, "123");
|
|
||||||
assert_eq!(parsed.interaction_id, "456");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
|
|||||||
wasm-tools strip slack.wasm -o slack.wasm
|
wasm-tools strip slack.wasm -o slack.wasm
|
||||||
|
|
||||||
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
||||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/"
|
echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/"
|
||||||
else
|
else
|
||||||
echo "Error: WASM output not found at $WASM_PATH"
|
echo "Error: WASM output not found at $WASM_PATH"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -108,10 +108,7 @@ struct SlackPostMessageResponse {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SlackConfig {
|
struct SlackConfig {
|
||||||
/// Name of secret containing signing secret (for verification by host).
|
/// Name of secret containing signing secret (for verification by host).
|
||||||
/// Parsed from config for forward compatibility; not yet used in WASM
|
|
||||||
/// (host handles signature verification).
|
|
||||||
#[serde(default = "default_signing_secret_name")]
|
#[serde(default = "default_signing_secret_name")]
|
||||||
#[allow(dead_code)]
|
|
||||||
signing_secret_name: String,
|
signing_secret_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +175,11 @@ impl Guest for SlackChannel {
|
|||||||
// Actual event callback
|
// Actual event callback
|
||||||
"event_callback" => {
|
"event_callback" => {
|
||||||
if let Some(event) = event_wrapper.event {
|
if let Some(event) = event_wrapper.event {
|
||||||
handle_slack_event(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)
|
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
||||||
json_response(200, serde_json::json!({"ok": true}))
|
json_response(200, serde_json::json!({"ok": true}))
|
||||||
@@ -229,7 +230,6 @@ impl Guest for SlackChannel {
|
|||||||
"https://slack.com/api/chat.postMessage",
|
"https://slack.com/api/chat.postMessage",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -243,15 +243,14 @@ impl Guest for SlackChannel {
|
|||||||
|
|
||||||
// Parse Slack response
|
// Parse Slack response
|
||||||
let slack_response: SlackPostMessageResponse =
|
let slack_response: SlackPostMessageResponse =
|
||||||
serde_json::from_slice(&http_response.body)
|
serde_json::from_slice(&http_response.body).map_err(|e| {
|
||||||
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
format!("Failed to parse Slack response: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
if !slack_response.ok {
|
if !slack_response.ok {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Slack API error: {}",
|
"Slack API error: {}",
|
||||||
slack_response
|
slack_response.error.unwrap_or_else(|| "unknown".to_string())
|
||||||
.error
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,16 +277,17 @@ impl Guest for SlackChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a Slack event and emit message if applicable.
|
/// 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() {
|
match event.event_type.as_str() {
|
||||||
// Direct mention of the bot
|
// Direct mention of the bot
|
||||||
"app_mention" => {
|
"app_mention" => {
|
||||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||||
event.user,
|
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||||
event.channel.clone(),
|
{
|
||||||
event.text,
|
|
||||||
event.ts.clone(),
|
|
||||||
) {
|
|
||||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,12 +299,9 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||||
event.user,
|
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||||
event.channel.clone(),
|
{
|
||||||
event.text,
|
|
||||||
event.ts.clone(),
|
|
||||||
) {
|
|
||||||
// Only process DMs (channel IDs starting with D)
|
// Only process DMs (channel IDs starting with D)
|
||||||
if channel.starts_with('D') {
|
if channel.starts_with('D') {
|
||||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||||
@@ -338,13 +335,8 @@ fn emit_message(
|
|||||||
team_id,
|
team_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
let metadata_json =
|
||||||
channel_host::log(
|
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to serialize Slack metadata: {}", e),
|
|
||||||
);
|
|
||||||
"{}".to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strip @ mentions of the bot from the text for cleaner messages
|
// Strip @ mentions of the bot from the text for cleaner messages
|
||||||
let cleaned_text = strip_bot_mention(&text);
|
let cleaned_text = strip_bot_mention(&text);
|
||||||
@@ -372,13 +364,7 @@ fn strip_bot_mention(text: &str) -> String {
|
|||||||
|
|
||||||
/// Create a JSON HTTP response.
|
/// Create a JSON HTTP response.
|
||||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||||
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"});
|
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||||
|
|
||||||
OutgoingHttpResponse {
|
OutgoingHttpResponse {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
|
|||||||
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To install:"
|
echo "To install:"
|
||||||
echo " mkdir -p ~/.ironclaw/channels"
|
echo " mkdir -p ~/.near-agent/channels"
|
||||||
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
|
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Then add your bot token to secrets:"
|
echo "Then add your bot token to secrets:"
|
||||||
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
||||||
|
|||||||
+100
-392
@@ -72,10 +72,6 @@ struct TelegramMessage {
|
|||||||
/// Message text.
|
/// Message text.
|
||||||
text: Option<String>,
|
text: Option<String>,
|
||||||
|
|
||||||
/// Caption for media (photo, video, document, etc.).
|
|
||||||
#[serde(default)]
|
|
||||||
caption: Option<String>,
|
|
||||||
|
|
||||||
/// Original message if this is a reply.
|
/// Original message if this is a reply.
|
||||||
reply_to_message: Option<Box<TelegramMessage>>,
|
reply_to_message: Option<Box<TelegramMessage>>,
|
||||||
|
|
||||||
@@ -164,21 +160,6 @@ const POLLING_STATE_PATH: &str = "state/last_update_id";
|
|||||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||||
|
|
||||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
|
||||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
|
||||||
|
|
||||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
|
||||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
|
||||||
|
|
||||||
/// Channel name for pairing store (used by pairing host APIs).
|
|
||||||
const CHANNEL_NAME: &str = "telegram";
|
|
||||||
|
|
||||||
/// Workspace path for persisting bot_username for mention detection in groups.
|
|
||||||
const BOT_USERNAME_PATH: &str = "state/bot_username";
|
|
||||||
|
|
||||||
/// Workspace path for persisting respond_to_all_group_messages flag.
|
|
||||||
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Channel Metadata
|
// Channel Metadata
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -215,14 +196,6 @@ struct TelegramConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
owner_id: Option<i64>,
|
owner_id: Option<i64>,
|
||||||
|
|
||||||
/// DM policy: "pairing" (default), "allowlist", or "open".
|
|
||||||
#[serde(default)]
|
|
||||||
dm_policy: Option<String>,
|
|
||||||
|
|
||||||
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
|
|
||||||
#[serde(default)]
|
|
||||||
allow_from: Option<Vec<String>>,
|
|
||||||
|
|
||||||
/// Whether to respond to all group messages (not just mentions).
|
/// Whether to respond to all group messages (not just mentions).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
respond_to_all_group_messages: bool,
|
respond_to_all_group_messages: bool,
|
||||||
@@ -284,24 +257,6 @@ impl Guest for TelegramChannel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist dm_policy and allow_from for DM pairing in handle_message
|
|
||||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
|
||||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
|
||||||
|
|
||||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
|
||||||
.unwrap_or_else(|_| "[]".to_string());
|
|
||||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
|
||||||
|
|
||||||
// Persist bot_username and respond_to_all_group_messages for group handling
|
|
||||||
let _ = channel_host::workspace_write(
|
|
||||||
BOT_USERNAME_PATH,
|
|
||||||
&config.bot_username.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
let _ = channel_host::workspace_write(
|
|
||||||
RESPOND_TO_ALL_GROUP_PATH,
|
|
||||||
&config.respond_to_all_group_messages.to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mode is determined by whether the host injected a tunnel_url
|
// Mode is determined by whether the host injected a tunnel_url
|
||||||
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
||||||
let webhook_mode = config.tunnel_url.is_some();
|
let webhook_mode = config.tunnel_url.is_some();
|
||||||
@@ -433,9 +388,7 @@ impl Guest for TelegramChannel {
|
|||||||
|
|
||||||
let headers = serde_json::json!({});
|
let headers = serde_json::json!({});
|
||||||
|
|
||||||
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll
|
let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
|
||||||
let result =
|
|
||||||
channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000));
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
@@ -508,52 +461,72 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||||
|
// Parse metadata to get chat info
|
||||||
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||||
|
|
||||||
// Try sending with Markdown first; fall back to plain text if Telegram
|
// Build sendMessage payload
|
||||||
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
|
let mut payload = serde_json::json!({
|
||||||
let result = send_message(
|
"chat_id": metadata.chat_id,
|
||||||
metadata.chat_id,
|
"text": response.content,
|
||||||
&response.content,
|
"parse_mode": "Markdown",
|
||||||
metadata.message_id,
|
});
|
||||||
Some("Markdown"),
|
|
||||||
|
// Reply to the original message for context
|
||||||
|
payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into());
|
||||||
|
|
||||||
|
let payload_bytes = serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||||
|
|
||||||
|
// Make HTTP request to Telegram API
|
||||||
|
// The bot token is injected into the URL by the host
|
||||||
|
let headers = serde_json::json!({
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = channel_host::http_request(
|
||||||
|
"POST",
|
||||||
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
|
&headers.to_string(),
|
||||||
|
Some(&payload_bytes),
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(msg_id) => {
|
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())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Debug,
|
channel_host::LogLevel::Debug,
|
||||||
&format!(
|
&format!(
|
||||||
"Sent message to chat {}: message_id={}",
|
"Sent message to chat {}: message_id={}",
|
||||||
metadata.chat_id, msg_id
|
metadata.chat_id,
|
||||||
|
api_response.result.map(|r| r.message_id).unwrap_or(0)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +568,6 @@ impl Guest for TelegramChannel {
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -614,101 +586,6 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Send Message Helper
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Errors from send_message, split so callers can match on parse-entity failures.
|
|
||||||
enum SendError {
|
|
||||||
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
|
|
||||||
ParseEntities(String),
|
|
||||||
/// Any other failure.
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for SendError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
|
|
||||||
SendError::Other(msg) => write!(f, "{}", msg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message via the Telegram Bot API.
|
|
||||||
///
|
|
||||||
/// Returns the sent message_id on success. When `parse_mode` is set and
|
|
||||||
/// Telegram returns a 400 "can't parse entities" error, returns
|
|
||||||
/// `SendError::ParseEntities` so the caller can retry without formatting.
|
|
||||||
fn send_message(
|
|
||||||
chat_id: i64,
|
|
||||||
text: &str,
|
|
||||||
reply_to_message_id: i64,
|
|
||||||
parse_mode: Option<&str>,
|
|
||||||
) -> Result<i64, SendError> {
|
|
||||||
let mut payload = serde_json::json!({
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": text,
|
|
||||||
"reply_to_message_id": reply_to_message_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(mode) = parse_mode {
|
|
||||||
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload_bytes = serde_json::to_vec(&payload)
|
|
||||||
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
|
|
||||||
|
|
||||||
let headers = serde_json::json!({ "Content-Type": "application/json" });
|
|
||||||
|
|
||||||
let result = channel_host::http_request(
|
|
||||||
"POST",
|
|
||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
|
||||||
&headers.to_string(),
|
|
||||||
Some(&payload_bytes),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(http_response) => {
|
|
||||||
if http_response.status == 400 {
|
|
||||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
||||||
if body_str.contains("can't parse entities") {
|
|
||||||
return Err(SendError::ParseEntities(body_str.to_string()));
|
|
||||||
}
|
|
||||||
return Err(SendError::Other(format!(
|
|
||||||
"Telegram API returned 400: {}",
|
|
||||||
body_str
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if http_response.status != 200 {
|
|
||||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
|
||||||
return Err(SendError::Other(format!(
|
|
||||||
"Telegram API returned status {}: {}",
|
|
||||||
http_response.status, body_str
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let api_response: TelegramApiResponse<SentMessage> =
|
|
||||||
serde_json::from_slice(&http_response.body)
|
|
||||||
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
|
|
||||||
|
|
||||||
if !api_response.ok {
|
|
||||||
return Err(SendError::Other(format!(
|
|
||||||
"Telegram API error: {}",
|
|
||||||
api_response
|
|
||||||
.description
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
|
|
||||||
}
|
|
||||||
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Webhook Management
|
// Webhook Management
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -727,7 +604,6 @@ fn delete_webhook() -> Result<(), String> {
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -790,7 +666,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
|||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&body_bytes),
|
Some(&body_bytes),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -825,48 +700,6 @@ 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
|
// Update Handling
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -886,16 +719,11 @@ fn handle_update(update: TelegramUpdate) {
|
|||||||
|
|
||||||
/// Process a single message.
|
/// Process a single message.
|
||||||
fn handle_message(message: TelegramMessage) {
|
fn handle_message(message: TelegramMessage) {
|
||||||
// Use text or caption (for media messages)
|
// Skip messages without text
|
||||||
let content = message
|
let text = match message.text {
|
||||||
.text
|
Some(t) if !t.is_empty() => t,
|
||||||
.filter(|t| !t.is_empty())
|
_ => return,
|
||||||
.or_else(|| message.caption.filter(|c| !c.is_empty()))
|
};
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if content.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip messages without a sender (channel posts)
|
// Skip messages without a sender (channel posts)
|
||||||
let from = match message.from {
|
let from = match message.from {
|
||||||
@@ -908,105 +736,41 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_private = message.chat.chat_type == "private";
|
// Owner validation: silently drop messages from non-owner users
|
||||||
|
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
|
||||||
// Owner validation: when owner_id is set, only that user can message
|
if !owner_id_str.is_empty() {
|
||||||
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
|
||||||
|
if from.id != owner_id {
|
||||||
if let Some(ref id_str) = owner_id_str {
|
channel_host::log(
|
||||||
if let Ok(owner_id) = id_str.parse::<i64>() {
|
channel_host::LogLevel::Debug,
|
||||||
if from.id != owner_id {
|
&format!(
|
||||||
channel_host::log(
|
"Dropping message from non-owner user {} (owner: {})",
|
||||||
channel_host::LogLevel::Debug,
|
from.id, owner_id
|
||||||
&format!(
|
),
|
||||||
"Dropping message from non-owner user {} (owner: {})",
|
);
|
||||||
from.id, owner_id
|
return;
|
||||||
),
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
|
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
|
||||||
if !is_private {
|
if !is_private {
|
||||||
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
|
// In groups, only respond if there's a bot mention or command
|
||||||
.as_deref()
|
// This is a simplified check - proper implementation would use entities
|
||||||
.unwrap_or("false")
|
let has_command = text.starts_with('/');
|
||||||
== "true";
|
let has_mention = text.contains('@');
|
||||||
|
|
||||||
if !respond_to_all {
|
if !has_command && !has_mention {
|
||||||
let has_command = content.starts_with('/');
|
channel_host::log(
|
||||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
channel_host::LogLevel::Debug,
|
||||||
let has_bot_mention = if bot_username.is_empty() {
|
&format!("Ignoring group message without mention: {}", text),
|
||||||
content.contains('@')
|
);
|
||||||
} else {
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,30 +792,17 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
|
||||||
// Clean the message text (strip bot mentions and commands)
|
// Clean the message text (strip bot mentions and commands)
|
||||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
let cleaned_text = clean_message_text(&text);
|
||||||
let cleaned_text = clean_message_text(
|
|
||||||
&content,
|
|
||||||
if bot_username.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(bot_username.as_str())
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// For /start with no args, emit placeholder so agent can respond with welcome
|
if cleaned_text.is_empty() {
|
||||||
let content_to_emit = if cleaned_text.is_empty() && content.trim().starts_with('/') {
|
|
||||||
"[User started the bot]".to_string()
|
|
||||||
} else if cleaned_text.is_empty() {
|
|
||||||
return;
|
return;
|
||||||
} else {
|
}
|
||||||
cleaned_text
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit the message to the agent
|
// Emit the message to the agent
|
||||||
channel_host::emit_message(&EmittedMessage {
|
channel_host::emit_message(&EmittedMessage {
|
||||||
user_id: from.id.to_string(),
|
user_id: from.id.to_string(),
|
||||||
user_name: Some(user_name),
|
user_name: Some(user_name),
|
||||||
content: content_to_emit,
|
content: cleaned_text,
|
||||||
thread_id: None, // Telegram doesn't have threads in the same way
|
thread_id: None, // Telegram doesn't have threads in the same way
|
||||||
metadata_json,
|
metadata_json,
|
||||||
});
|
});
|
||||||
@@ -1066,8 +817,7 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean message text by removing bot commands and @mentions at the start.
|
/// Clean message text by removing bot commands and @mentions at the start.
|
||||||
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
|
fn clean_message_text(text: &str) -> String {
|
||||||
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
|
||||||
let mut result = text.trim().to_string();
|
let mut result = text.trim().to_string();
|
||||||
|
|
||||||
// Remove leading /command
|
// Remove leading /command
|
||||||
@@ -1082,30 +832,11 @@ fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
|||||||
|
|
||||||
// Remove leading @mention
|
// Remove leading @mention
|
||||||
if result.starts_with('@') {
|
if result.starts_with('@') {
|
||||||
if let Some(bot) = bot_username {
|
if let Some(space_idx) = result.find(' ') {
|
||||||
let mention = format!("@{}", bot);
|
result = result[space_idx..].trim_start().to_string();
|
||||||
let mention_lower = mention.to_lowercase();
|
|
||||||
let result_lower = result.to_lowercase();
|
|
||||||
if result_lower.starts_with(&mention_lower) {
|
|
||||||
let rest = result[mention.len()..].trim_start();
|
|
||||||
if rest.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
result = rest.to_string();
|
|
||||||
} else if let Some(space_idx) = result.find(' ') {
|
|
||||||
// Different leading @mention - only strip if it's the bot
|
|
||||||
let first_word = &result[..space_idx];
|
|
||||||
if first_word.eq_ignore_ascii_case(&mention) {
|
|
||||||
result = result[space_idx..].trim_start().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No bot_username: strip any leading @mention
|
// Just a mention with no text
|
||||||
if let Some(space_idx) = result.find(' ') {
|
return String::new();
|
||||||
result = result[space_idx..].trim_start().to_string();
|
|
||||||
} else {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,22 +872,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clean_message_text() {
|
fn test_clean_message_text() {
|
||||||
// Without bot_username: strips any leading @mention
|
assert_eq!(clean_message_text("/start hello"), "hello");
|
||||||
assert_eq!(clean_message_text("/start hello", None), "hello");
|
assert_eq!(clean_message_text("@bot hello world"), "hello world");
|
||||||
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
|
assert_eq!(clean_message_text("/start"), "");
|
||||||
assert_eq!(clean_message_text("/start", None), "");
|
assert_eq!(clean_message_text("@botname"), "");
|
||||||
assert_eq!(clean_message_text("@botname", None), "");
|
assert_eq!(clean_message_text("just text"), "just text");
|
||||||
assert_eq!(clean_message_text("just text", None), "just text");
|
assert_eq!(clean_message_text(" spaced "), "spaced");
|
||||||
assert_eq!(clean_message_text(" spaced ", None), "spaced");
|
|
||||||
|
|
||||||
// With bot_username: only strips @MyBot, not @alice
|
|
||||||
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
|
|
||||||
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
|
|
||||||
assert_eq!(
|
|
||||||
clean_message_text("@alice hello", Some("MyBot")),
|
|
||||||
"@alice hello"
|
|
||||||
);
|
|
||||||
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1224,17 +945,4 @@ mod tests {
|
|||||||
assert_eq!(from.id, 789);
|
assert_eq!(from.id, 789);
|
||||||
assert_eq!(from.first_name, "John");
|
assert_eq!(from.first_name, "John");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_message_with_caption() {
|
|
||||||
let json = r#"{
|
|
||||||
"message_id": 1,
|
|
||||||
"from": {"id": 1, "is_bot": false, "first_name": "A"},
|
|
||||||
"chat": {"id": 1, "type": "private"},
|
|
||||||
"caption": "What's in this image?"
|
|
||||||
}"#;
|
|
||||||
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(msg.text, None);
|
|
||||||
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,43 @@
|
|||||||
{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}}
|
{
|
||||||
|
"type": "channel",
|
||||||
|
"name": "telegram",
|
||||||
|
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||||
|
"capabilities": {
|
||||||
|
"http": {
|
||||||
|
"allowlist": [
|
||||||
|
{ "host": "api.telegram.org", "path_prefix": "/bot" }
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"telegram_bot": {
|
||||||
|
"secret_name": "telegram_bot_token",
|
||||||
|
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
|
||||||
|
"host_patterns": ["api.telegram.org"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rate_limit": {
|
||||||
|
"requests_per_minute": 30,
|
||||||
|
"requests_per_hour": 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"allowed_names": ["telegram_*"]
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"allowed_paths": ["/webhook/telegram"],
|
||||||
|
"allow_polling": true,
|
||||||
|
"min_poll_interval_ms": 30000,
|
||||||
|
"workspace_prefix": "channels/telegram/",
|
||||||
|
"emit_rate_limit": {
|
||||||
|
"messages_per_minute": 100,
|
||||||
|
"messages_per_hour": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"bot_username": null,
|
||||||
|
"owner_id": null,
|
||||||
|
"respond_to_all_group_messages": false,
|
||||||
|
"polling_enabled": false,
|
||||||
|
"poll_interval_ms": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -254,19 +254,10 @@ struct WhatsAppChannel;
|
|||||||
|
|
||||||
impl Guest for WhatsAppChannel {
|
impl Guest for WhatsAppChannel {
|
||||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||||
Ok(c) => c,
|
api_version: default_api_version(),
|
||||||
Err(e) => {
|
reply_to_message: default_reply_to_message(),
|
||||||
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::log(
|
||||||
channel_host::LogLevel::Info,
|
channel_host::LogLevel::Info,
|
||||||
@@ -276,9 +267,6 @@ 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
|
// WhatsApp Cloud API is webhook-only, no polling available
|
||||||
Ok(ChannelConfig {
|
Ok(ChannelConfig {
|
||||||
display_name: "WhatsApp".to_string(),
|
display_name: "WhatsApp".to_string(),
|
||||||
@@ -339,16 +327,11 @@ impl Guest for WhatsAppChannel {
|
|||||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||||
|
|
||||||
// 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
|
// Build WhatsApp API URL with token placeholder
|
||||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||||
let api_url = format!(
|
let api_url = format!(
|
||||||
"https://graph.facebook.com/{}/{}/messages",
|
"https://graph.facebook.com/v18.0/{}/messages",
|
||||||
api_version, metadata.phone_number_id
|
metadata.phone_number_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build sendMessage payload
|
// Build sendMessage payload
|
||||||
@@ -378,7 +361,6 @@ impl Guest for WhatsAppChannel {
|
|||||||
&api_url,
|
&api_url,
|
||||||
&headers.to_string(),
|
&headers.to_string(),
|
||||||
Some(&payload_bytes),
|
Some(&payload_bytes),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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,46 +246,13 @@ Create `my-channel.capabilities.json`:
|
|||||||
|
|
||||||
## Building and Deploying
|
## Building and Deploying
|
||||||
|
|
||||||
### Supply Chain Security: No Committed Binaries
|
|
||||||
|
|
||||||
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
|
|
||||||
|
|
||||||
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
|
|
||||||
- The built binary is in `.gitignore` and is not committed
|
|
||||||
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
|
|
||||||
|
|
||||||
**Reproducible build:**
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
|
|
||||||
|
|
||||||
### Telegram Channel (Manual Build)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add WASM target if needed
|
|
||||||
rustup target add wasm32-wasip2
|
|
||||||
|
|
||||||
# Build Telegram channel
|
|
||||||
./channels-src/telegram/build.sh
|
|
||||||
|
|
||||||
# Install (or use ironclaw onboard to install bundled channel)
|
|
||||||
mkdir -p ~/.ironclaw/channels
|
|
||||||
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
|
|
||||||
|
|
||||||
### Other Channels
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the WASM component
|
# Build the WASM component
|
||||||
cd channels-src/my-channel
|
cd channels/my-channel
|
||||||
cargo build --release --target wasm32-wasip2
|
cargo component build --release
|
||||||
|
|
||||||
# Deploy to ~/.ironclaw/channels/
|
# Deploy to ~/.ironclaw/channels/
|
||||||
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||||
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! 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,10 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[workspace]
|
|
||||||
git_release_enable = false
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
+243
-982
File diff suppressed because it is too large
Load Diff
+3
-34
@@ -29,7 +29,7 @@ use std::time::Duration;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::channels::OutgoingResponse;
|
use crate::channels::OutgoingResponse;
|
||||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
/// Configuration for the heartbeat runner.
|
/// Configuration for the heartbeat runner.
|
||||||
@@ -217,26 +217,9 @@ impl HeartbeatRunner {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the model's context_length to set max_tokens. The API returns
|
|
||||||
// the total context window; we cap output at half of that (the rest is
|
|
||||||
// the prompt) with a floor of 4096.
|
|
||||||
let max_tokens = match self.llm.model_metadata().await {
|
|
||||||
Ok(meta) => {
|
|
||||||
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
|
|
||||||
from_api.max(4096)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Could not fetch model metadata, using default max_tokens: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
4096
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let request = CompletionRequest::new(messages)
|
let request = CompletionRequest::new(messages)
|
||||||
.with_max_tokens(max_tokens)
|
.with_max_tokens(1024)
|
||||||
.with_temperature(0.3);
|
.with_temperature(0.3); // Lower temperature for more focused responses
|
||||||
|
|
||||||
let response = match self.llm.complete(request).await {
|
let response = match self.llm.complete(request).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@@ -245,20 +228,6 @@ impl HeartbeatRunner {
|
|||||||
|
|
||||||
let content = response.content.trim();
|
let content = response.content.trim();
|
||||||
|
|
||||||
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
|
|
||||||
// burn all output tokens on chain-of-thought and return content: null.
|
|
||||||
if content.is_empty() {
|
|
||||||
return if response.finish_reason == FinishReason::Length {
|
|
||||||
HeartbeatResult::Failed(
|
|
||||||
"LLM response was truncated (finish_reason=length) with no content. \
|
|
||||||
The model may have exhausted its token budget on reasoning."
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
HeartbeatResult::Failed("LLM returned empty content.".to_string())
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if nothing needs attention
|
// Check if nothing needs attention
|
||||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
||||||
return HeartbeatResult::Ok;
|
return HeartbeatResult::Ok;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//! - Tool invocation with safety
|
//! - Tool invocation with safety
|
||||||
//! - Self-repair for stuck jobs
|
//! - Self-repair for stuck jobs
|
||||||
//! - Proactive heartbeat execution
|
//! - Proactive heartbeat execution
|
||||||
//! - Routine-based scheduled and reactive jobs
|
|
||||||
//! - Turn-based session management with undo
|
//! - Turn-based session management with undo
|
||||||
//! - Context compaction for long conversations
|
//! - Context compaction for long conversations
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ pub mod compaction;
|
|||||||
pub mod context_monitor;
|
pub mod context_monitor;
|
||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
mod router;
|
mod router;
|
||||||
pub mod routine;
|
|
||||||
pub mod routine_engine;
|
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod self_repair;
|
mod self_repair;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -26,14 +23,11 @@ pub mod task;
|
|||||||
pub mod undo;
|
pub mod undo;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
pub(crate) use agent_loop::truncate_for_preview;
|
|
||||||
pub use agent_loop::{Agent, AgentDeps};
|
pub use agent_loop::{Agent, AgentDeps};
|
||||||
pub use compaction::{CompactionResult, ContextCompactor};
|
pub use compaction::{CompactionResult, ContextCompactor};
|
||||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||||
pub use router::{MessageIntent, Router};
|
pub use router::{MessageIntent, Router};
|
||||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
|
||||||
pub use routine_engine::RoutineEngine;
|
|
||||||
pub use scheduler::Scheduler;
|
pub use scheduler::Scheduler;
|
||||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||||
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||||
|
|||||||
@@ -1,509 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,601 +0,0 @@
|
|||||||
//! 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+76
-81
@@ -12,9 +12,8 @@ use crate::agent::task::{Task, TaskContext, TaskOutput};
|
|||||||
use crate::agent::worker::{Worker, WorkerDeps};
|
use crate::agent::worker::{Worker, WorkerDeps};
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::context::{ContextManager, JobContext, JobState};
|
use crate::context::{ContextManager, JobContext, JobState};
|
||||||
use crate::db::Database;
|
|
||||||
use crate::error::{Error, JobError};
|
use crate::error::{Error, JobError};
|
||||||
use crate::hooks::HookRegistry;
|
use crate::history::Store;
|
||||||
use crate::llm::LlmProvider;
|
use crate::llm::LlmProvider;
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
@@ -49,8 +48,7 @@ pub struct Scheduler {
|
|||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
safety: Arc<SafetyLayer>,
|
safety: Arc<SafetyLayer>,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<Store>>,
|
||||||
hooks: Arc<HookRegistry>,
|
|
||||||
/// Running jobs (main LLM-driven jobs).
|
/// Running jobs (main LLM-driven jobs).
|
||||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||||
/// Running sub-tasks (tool executions, background tasks).
|
/// Running sub-tasks (tool executions, background tasks).
|
||||||
@@ -65,8 +63,7 @@ impl Scheduler {
|
|||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
safety: Arc<SafetyLayer>,
|
safety: Arc<SafetyLayer>,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<Store>>,
|
||||||
hooks: Arc<HookRegistry>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -75,7 +72,6 @@ impl Scheduler {
|
|||||||
safety,
|
safety,
|
||||||
tools,
|
tools,
|
||||||
store,
|
store,
|
||||||
hooks,
|
|
||||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||||
}
|
}
|
||||||
@@ -83,65 +79,64 @@ impl Scheduler {
|
|||||||
|
|
||||||
/// Schedule a job for execution.
|
/// Schedule a job for execution.
|
||||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||||
// Hold write lock for the entire check-insert sequence to prevent
|
// Check if already scheduled
|
||||||
// TOCTOU races where two concurrent calls both pass the checks.
|
if self.jobs.read().await.contains_key(&job_id) {
|
||||||
{
|
return Ok(());
|
||||||
let mut jobs = self.jobs.write().await;
|
|
||||||
|
|
||||||
if jobs.contains_key(&job_id) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if jobs.len() >= 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(),
|
|
||||||
hooks: self.hooks.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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the worker
|
||||||
|
let _ = tx.send(WorkerMessage::Start).await;
|
||||||
|
|
||||||
|
// Store the scheduled job
|
||||||
|
self.jobs
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(job_id, ScheduledJob { handle, tx });
|
||||||
|
|
||||||
// Cleanup task for this job to avoid capacity leaks
|
// Cleanup task for this job to avoid capacity leaks
|
||||||
let jobs = Arc::clone(&self.jobs);
|
let jobs = Arc::clone(&self.jobs);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -378,23 +373,23 @@ impl Scheduler {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute with per-tool timeout
|
// Execute with timeout
|
||||||
let tool_timeout = tool.execution_timeout();
|
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||||
let result =
|
tool.execute(params, &job_ctx).await
|
||||||
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
Error::Tool(crate::error::ToolError::Timeout {
|
Error::Tool(crate::error::ToolError::Timeout {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
timeout: tool_timeout,
|
timeout: Duration::from_secs(60),
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
reason: e.to_string(),
|
reason: e.to_string(),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(TaskOutput::new(result.result, start.elapsed()))
|
Ok(TaskOutput::new(result.result, start.elapsed()))
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-21
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::context::{ContextManager, JobState};
|
use crate::context::{ContextManager, JobState};
|
||||||
use crate::db::Database;
|
|
||||||
use crate::error::RepairError;
|
use crate::error::RepairError;
|
||||||
|
use crate::history::Store;
|
||||||
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
||||||
|
|
||||||
/// A job that has been detected as stuck.
|
/// 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
|
#[allow(dead_code)] // Will be used for time-based stuck detection
|
||||||
stuck_threshold: Duration,
|
stuck_threshold: Duration,
|
||||||
max_repair_attempts: u32,
|
max_repair_attempts: u32,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<Store>>,
|
||||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||||
#[allow(dead_code)] // Will be used for tool hot-reload after repair
|
#[allow(dead_code)] // Will be used for tool hot-reload after repair
|
||||||
tools: Option<Arc<ToolRegistry>>,
|
tools: Option<Arc<ToolRegistry>>,
|
||||||
@@ -94,7 +94,7 @@ impl DefaultSelfRepair {
|
|||||||
|
|
||||||
/// Add a Store for tool failure tracking.
|
/// Add a Store for tool failure tracking.
|
||||||
#[allow(dead_code)] // Public API for configuring repair with persistence
|
#[allow(dead_code)] // Public API for configuring repair with persistence
|
||||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
pub fn with_store(mut self, store: Arc<Store>) -> Self {
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
|
|||||||
let mut stuck_jobs = Vec::new();
|
let mut stuck_jobs = Vec::new();
|
||||||
|
|
||||||
for job_id in stuck_ids {
|
for job_id in stuck_ids {
|
||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||||
&& ctx.state == JobState::Stuck
|
if ctx.state == JobState::Stuck {
|
||||||
{
|
let stuck_duration = ctx
|
||||||
let stuck_duration = ctx
|
.started_at
|
||||||
.started_at
|
.map(|start| {
|
||||||
.map(|start| {
|
let now = Utc::now();
|
||||||
let now = Utc::now();
|
let duration = now.signed_duration_since(start);
|
||||||
let duration = now.signed_duration_since(start);
|
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
})
|
||||||
})
|
.unwrap_or_default();
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
stuck_jobs.push(StuckJob {
|
stuck_jobs.push(StuckJob {
|
||||||
job_id,
|
job_id,
|
||||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||||
stuck_duration,
|
stuck_duration,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
repair_attempts: ctx.repair_attempts,
|
repair_attempts: ctx.repair_attempts,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-409
@@ -173,10 +173,6 @@ pub struct Thread {
|
|||||||
/// Pending auth token request (thread is in auth mode).
|
/// Pending auth token request (thread is in auth mode).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pending_auth: Option<PendingAuth>,
|
pub pending_auth: Option<PendingAuth>,
|
||||||
/// Last NEAR AI response ID for response chaining. Persisted to DB
|
|
||||||
/// metadata so we can resume chaining across restarts.
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_response_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Thread {
|
impl Thread {
|
||||||
@@ -193,24 +189,6 @@ impl Thread {
|
|||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
pending_approval: None,
|
pending_approval: None,
|
||||||
pending_auth: 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,11 +324,11 @@ impl Thread {
|
|||||||
let mut turn = Turn::new(turn_number, &msg.content);
|
let mut turn = Turn::new(turn_number, &msg.content);
|
||||||
|
|
||||||
// Check if next is assistant response
|
// Check if next is assistant response
|
||||||
if let Some(next) = iter.peek()
|
if let Some(next) = iter.peek() {
|
||||||
&& next.role == crate::llm::Role::Assistant
|
if next.role == crate::llm::Role::Assistant {
|
||||||
{
|
let response = iter.next().expect("peeked");
|
||||||
let response = iter.next().expect("peeked");
|
turn.complete(&response.content);
|
||||||
turn.complete(&response.content);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.turns.push(turn);
|
self.turns.push(turn);
|
||||||
@@ -615,386 +593,4 @@ mod tests {
|
|||||||
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
||||||
assert!(restored.pending_auth.is_none());
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agent::session::Session;
|
use crate::agent::session::Session;
|
||||||
use crate::agent::undo::UndoManager;
|
use crate::agent::undo::UndoManager;
|
||||||
use crate::hooks::HookRegistry;
|
|
||||||
|
|
||||||
/// Key for mapping external thread IDs to internal ones.
|
/// Key for mapping external thread IDs to internal ones.
|
||||||
#[derive(Clone, Hash, Eq, PartialEq)]
|
#[derive(Clone, Hash, Eq, PartialEq)]
|
||||||
@@ -26,7 +25,6 @@ pub struct SessionManager {
|
|||||||
sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
|
sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
|
||||||
thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
|
thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
|
||||||
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
|
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
|
||||||
hooks: Option<Arc<HookRegistry>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
@@ -36,16 +34,9 @@ impl SessionManager {
|
|||||||
sessions: RwLock::new(HashMap::new()),
|
sessions: RwLock::new(HashMap::new()),
|
||||||
thread_map: RwLock::new(HashMap::new()),
|
thread_map: RwLock::new(HashMap::new()),
|
||||||
undo_managers: RwLock::new(HashMap::new()),
|
undo_managers: RwLock::new(HashMap::new()),
|
||||||
hooks: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a hook registry for session lifecycle events.
|
|
||||||
pub fn with_hooks(mut self, hooks: Arc<HookRegistry>) -> Self {
|
|
||||||
self.hooks = Some(hooks);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create a session for a user.
|
/// Get or create a session for a user.
|
||||||
pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> {
|
pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> {
|
||||||
// Fast path: check if session exists
|
// Fast path: check if session exists
|
||||||
@@ -63,28 +54,8 @@ impl SessionManager {
|
|||||||
return Arc::clone(session);
|
return Arc::clone(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_session = Session::new(user_id);
|
let session = Arc::new(Mutex::new(Session::new(user_id)));
|
||||||
let session_id = new_session.id.to_string();
|
|
||||||
let session = Arc::new(Mutex::new(new_session));
|
|
||||||
sessions.insert(user_id.to_string(), Arc::clone(&session));
|
sessions.insert(user_id.to_string(), Arc::clone(&session));
|
||||||
|
|
||||||
// Fire OnSessionStart hook (fire-and-forget)
|
|
||||||
if let Some(ref hooks) = self.hooks {
|
|
||||||
let hooks = hooks.clone();
|
|
||||||
let uid = user_id.to_string();
|
|
||||||
let sid = session_id;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use crate::hooks::HookEvent;
|
|
||||||
let event = HookEvent::SessionStart {
|
|
||||||
user_id: uid,
|
|
||||||
session_id: sid,
|
|
||||||
};
|
|
||||||
if let Err(e) = hooks.run(&event).await {
|
|
||||||
tracing::warn!("OnSessionStart hook error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
session
|
session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,41 +110,6 @@ impl SessionManager {
|
|||||||
(session, thread_id)
|
(session, thread_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a hydrated thread so subsequent `resolve_thread` calls find it.
|
|
||||||
///
|
|
||||||
/// Inserts into the thread_map and creates an undo manager for the thread.
|
|
||||||
pub async fn register_thread(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
channel: &str,
|
|
||||||
thread_id: Uuid,
|
|
||||||
session: Arc<Mutex<Session>>,
|
|
||||||
) {
|
|
||||||
let key = ThreadKey {
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
channel: channel.to_string(),
|
|
||||||
external_thread_id: Some(thread_id.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut thread_map = self.thread_map.write().await;
|
|
||||||
thread_map.insert(key, thread_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut undo_managers = self.undo_managers.write().await;
|
|
||||||
undo_managers
|
|
||||||
.entry(thread_id)
|
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the session is tracked
|
|
||||||
{
|
|
||||||
let mut sessions = self.sessions.write().await;
|
|
||||||
sessions.entry(user_id.to_string()).or_insert(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get undo manager for a thread.
|
/// Get undo manager for a thread.
|
||||||
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
||||||
// Fast path
|
// Fast path
|
||||||
@@ -202,8 +138,8 @@ impl SessionManager {
|
|||||||
pub async fn prune_stale_sessions(&self, max_idle: std::time::Duration) -> usize {
|
pub async fn prune_stale_sessions(&self, max_idle: std::time::Duration) -> usize {
|
||||||
let cutoff = chrono::Utc::now() - chrono::TimeDelta::seconds(max_idle.as_secs() as i64);
|
let cutoff = chrono::Utc::now() - chrono::TimeDelta::seconds(max_idle.as_secs() as i64);
|
||||||
|
|
||||||
// Find stale sessions (user_id + session_id)
|
// Find stale session user_ids
|
||||||
let stale_sessions: Vec<(String, String)> = {
|
let stale_users: Vec<String> = {
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
sessions
|
sessions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -211,7 +147,7 @@ impl SessionManager {
|
|||||||
// Try to lock; skip if contended (someone is actively using it)
|
// Try to lock; skip if contended (someone is actively using it)
|
||||||
let sess = session.try_lock().ok()?;
|
let sess = session.try_lock().ok()?;
|
||||||
if sess.last_active_at < cutoff {
|
if sess.last_active_at < cutoff {
|
||||||
Some((user_id.clone(), sess.id.to_string()))
|
Some(user_id.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -219,11 +155,6 @@ impl SessionManager {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let stale_users: Vec<String> = stale_sessions
|
|
||||||
.iter()
|
|
||||||
.map(|(user_id, _)| user_id.clone())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if stale_users.is_empty() {
|
if stale_users.is_empty() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -233,30 +164,11 @@ impl SessionManager {
|
|||||||
{
|
{
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
for user_id in &stale_users {
|
for user_id in &stale_users {
|
||||||
if let Some(session) = sessions.get(user_id)
|
if let Some(session) = sessions.get(user_id) {
|
||||||
&& let Ok(sess) = session.try_lock()
|
if let Ok(sess) = session.try_lock() {
|
||||||
{
|
stale_thread_ids.extend(sess.threads.keys());
|
||||||
stale_thread_ids.extend(sess.threads.keys());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire OnSessionEnd hooks for stale sessions (fire-and-forget)
|
|
||||||
if let Some(ref hooks) = self.hooks {
|
|
||||||
for (user_id, session_id) in &stale_sessions {
|
|
||||||
let hooks = hooks.clone();
|
|
||||||
let uid = user_id.clone();
|
|
||||||
let sid = session_id.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use crate::hooks::HookEvent;
|
|
||||||
let event = HookEvent::SessionEnd {
|
|
||||||
user_id: uid,
|
|
||||||
session_id: sid,
|
|
||||||
};
|
|
||||||
if let Err(e) = hooks.run(&event).await {
|
|
||||||
tracing::warn!("OnSessionEnd hook error: {}", e);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,344 +296,4 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(pruned, 0);
|
assert_eq!(pruned, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_thread() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let thread_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
// Create a session with a hydrated thread
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(thread_id, sess.id);
|
|
||||||
sess.threads.insert(thread_id, thread);
|
|
||||||
sess.active_thread = Some(thread_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the thread
|
|
||||||
manager
|
|
||||||
.register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// resolve_thread should find it (using the UUID as external_thread_id)
|
|
||||||
let (resolved_session, resolved_tid) = manager
|
|
||||||
.resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string()))
|
|
||||||
.await;
|
|
||||||
assert_eq!(resolved_tid, thread_id);
|
|
||||||
|
|
||||||
// Should be the same session object
|
|
||||||
let sess = resolved_session.lock().await;
|
|
||||||
assert!(sess.threads.contains_key(&thread_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_with_explicit_external_id() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
// Two calls with the same explicit external thread ID should resolve
|
|
||||||
// to the same internal thread.
|
|
||||||
let (_, t1) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
|
||||||
.await;
|
|
||||||
let (_, t2) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
|
||||||
.await;
|
|
||||||
assert_eq!(t1, t2);
|
|
||||||
|
|
||||||
// A different external ID on the same channel/user gets a new thread.
|
|
||||||
let (_, t3) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-xyz"))
|
|
||||||
.await;
|
|
||||||
assert_ne!(t1, t3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_none_vs_some_external_id() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
// None external_thread_id is a distinct key from Some("ext-1").
|
|
||||||
let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await;
|
|
||||||
let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await;
|
|
||||||
assert_ne!(t_none, t_some);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_different_users_isolated() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
let (_, t1) = manager
|
|
||||||
.resolve_thread("user-a", "gateway", Some("same-ext"))
|
|
||||||
.await;
|
|
||||||
let (_, t2) = manager
|
|
||||||
.resolve_thread("user-b", "gateway", Some("same-ext"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Same channel + same external ID but different users = different threads
|
|
||||||
assert_ne!(t1, t2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_different_channels_isolated() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
let (_, t1) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("thread-x"))
|
|
||||||
.await;
|
|
||||||
let (_, t2) = manager
|
|
||||||
.resolve_thread("user-1", "telegram", Some("thread-x"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Same user + same external ID but different channels = different threads
|
|
||||||
assert_ne!(t1, t2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_stale_mapping_creates_new_thread() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
// Create a thread normally
|
|
||||||
let (session, original_tid) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Simulate the thread being removed from the session (e.g. pruned)
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
sess.threads.remove(&original_tid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next resolve should detect the stale mapping and create a fresh thread
|
|
||||||
let (_, new_tid) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
|
||||||
.await;
|
|
||||||
assert_ne!(original_tid, new_tid);
|
|
||||||
|
|
||||||
// The new thread should actually exist in the session
|
|
||||||
let sess = session.lock().await;
|
|
||||||
assert!(sess.threads.contains_key(&new_tid));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_thread_preserves_uuid_on_resolve() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let known_uuid = Uuid::new_v4();
|
|
||||||
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-web")));
|
|
||||||
let session_id = {
|
|
||||||
let sess = session.lock().await;
|
|
||||||
sess.id
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simulate hydration: create thread with a known UUID
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(known_uuid, session_id);
|
|
||||||
sess.threads.insert(known_uuid, thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register it
|
|
||||||
manager
|
|
||||||
.register_thread("user-web", "gateway", known_uuid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// resolve_thread with UUID as external_thread_id MUST return the same UUID,
|
|
||||||
// not mint a new one (this was the root cause of the "wrong conversation" bug)
|
|
||||||
let (_, resolved) = manager
|
|
||||||
.resolve_thread("user-web", "gateway", Some(&known_uuid.to_string()))
|
|
||||||
.await;
|
|
||||||
assert_eq!(resolved, known_uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_thread_idempotent() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let tid = Uuid::new_v4();
|
|
||||||
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-idem")));
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(tid, sess.id);
|
|
||||||
sess.threads.insert(tid, thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register twice
|
|
||||||
manager
|
|
||||||
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
manager
|
|
||||||
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Should still resolve to the same thread
|
|
||||||
let (_, resolved) = manager
|
|
||||||
.resolve_thread("user-idem", "gateway", Some(&tid.to_string()))
|
|
||||||
.await;
|
|
||||||
assert_eq!(resolved, tid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_thread_creates_undo_manager() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let tid = Uuid::new_v4();
|
|
||||||
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-undo")));
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(tid, sess.id);
|
|
||||||
sess.threads.insert(tid, thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
manager
|
|
||||||
.register_thread("user-undo", "gateway", tid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Undo manager should exist for the registered thread
|
|
||||||
let undo = manager.get_undo_manager(tid).await;
|
|
||||||
let undo2 = manager.get_undo_manager(tid).await;
|
|
||||||
assert!(Arc::ptr_eq(&undo, &undo2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_thread_stores_session() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let tid = Uuid::new_v4();
|
|
||||||
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-new")));
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(tid, sess.id);
|
|
||||||
sess.threads.insert(tid, thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user has no session yet in the manager
|
|
||||||
{
|
|
||||||
let sessions = manager.sessions.read().await;
|
|
||||||
assert!(!sessions.contains_key("user-new"));
|
|
||||||
}
|
|
||||||
|
|
||||||
manager
|
|
||||||
.register_thread("user-new", "gateway", tid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Now the session should be tracked
|
|
||||||
{
|
|
||||||
let sessions = manager.sessions.read().await;
|
|
||||||
assert!(sessions.contains_key("user-new"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_multiple_threads_per_user() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
let (_, t1) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("thread-a"))
|
|
||||||
.await;
|
|
||||||
let (_, t2) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("thread-b"))
|
|
||||||
.await;
|
|
||||||
let (session, t3) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("thread-c"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// All three should be distinct
|
|
||||||
assert_ne!(t1, t2);
|
|
||||||
assert_ne!(t2, t3);
|
|
||||||
assert_ne!(t1, t3);
|
|
||||||
|
|
||||||
// All three should exist in the same session
|
|
||||||
let sess = session.lock().await;
|
|
||||||
assert!(sess.threads.contains_key(&t1));
|
|
||||||
assert!(sess.threads.contains_key(&t2));
|
|
||||||
assert!(sess.threads.contains_key(&t3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_prune_cleans_thread_map_and_undo_managers() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await;
|
|
||||||
|
|
||||||
// Backdate the session
|
|
||||||
{
|
|
||||||
let mut sess = stale_session.lock().await;
|
|
||||||
sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify thread_map and undo_managers have entries
|
|
||||||
{
|
|
||||||
let tm = manager.thread_map.read().await;
|
|
||||||
assert!(!tm.is_empty());
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let um = manager.undo_managers.read().await;
|
|
||||||
assert!(um.contains_key(&stale_tid));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pruned = manager
|
|
||||||
.prune_stale_sessions(std::time::Duration::from_secs(86400 * 7))
|
|
||||||
.await;
|
|
||||||
assert_eq!(pruned, 1);
|
|
||||||
|
|
||||||
// Thread map and undo managers should be cleaned up
|
|
||||||
{
|
|
||||||
let tm = manager.thread_map.read().await;
|
|
||||||
assert!(tm.is_empty());
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let um = manager.undo_managers.read().await;
|
|
||||||
assert!(!um.contains_key(&stale_tid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_resolve_thread_active_thread_set() {
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
|
|
||||||
let (session, thread_id) = manager
|
|
||||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// The resolved thread should be set as the active thread
|
|
||||||
let sess = session.lock().await;
|
|
||||||
assert_eq!(sess.active_thread, Some(thread_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_register_then_resolve_different_channel_creates_new() {
|
|
||||||
use crate::agent::session::{Session, Thread};
|
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
|
||||||
let tid = Uuid::new_v4();
|
|
||||||
|
|
||||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
|
||||||
{
|
|
||||||
let mut sess = session.lock().await;
|
|
||||||
let thread = Thread::with_id(tid, sess.id);
|
|
||||||
sess.threads.insert(tid, thread);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register on "gateway" channel
|
|
||||||
manager
|
|
||||||
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Resolve on a different channel with the same UUID string should NOT
|
|
||||||
// find the registered thread (channel is part of the key)
|
|
||||||
let (_, resolved) = manager
|
|
||||||
.resolve_thread("user-cross", "telegram", Some(&tid.to_string()))
|
|
||||||
.await;
|
|
||||||
assert_ne!(resolved, tid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-144
@@ -43,49 +43,6 @@ impl SubmissionParser {
|
|||||||
if lower == "/thread new" || lower == "/new" {
|
if lower == "/thread new" || lower == "/new" {
|
||||||
return Submission::NewThread;
|
return Submission::NewThread;
|
||||||
}
|
}
|
||||||
// System commands (bypass thread-state checks)
|
|
||||||
if lower == "/help" || lower == "/?" {
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "help".to_string(),
|
|
||||||
args: vec![],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if lower == "/version" {
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "version".to_string(),
|
|
||||||
args: vec![],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if lower == "/tools" {
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "tools".to_string(),
|
|
||||||
args: vec![],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if lower == "/ping" {
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "ping".to_string(),
|
|
||||||
args: vec![],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if lower == "/debug" {
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "debug".to_string(),
|
|
||||||
args: vec![],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if lower.starts_with("/model") {
|
|
||||||
let args: Vec<String> = trimmed
|
|
||||||
.split_whitespace()
|
|
||||||
.skip(1)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
return Submission::SystemCommand {
|
|
||||||
command: "model".to_string(),
|
|
||||||
args,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if lower == "/quit" || lower == "/exit" || lower == "/shutdown" {
|
if lower == "/quit" || lower == "/exit" || lower == "/shutdown" {
|
||||||
return Submission::Quit;
|
return Submission::Quit;
|
||||||
}
|
}
|
||||||
@@ -93,26 +50,27 @@ impl SubmissionParser {
|
|||||||
// /thread <uuid> - switch thread
|
// /thread <uuid> - switch thread
|
||||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
if rest != "new"
|
if rest != "new" {
|
||||||
&& let Ok(id) = Uuid::parse_str(rest)
|
if let Ok(id) = Uuid::parse_str(rest) {
|
||||||
{
|
return Submission::SwitchThread { thread_id: id };
|
||||||
return Submission::SwitchThread { thread_id: id };
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /resume <uuid> - resume from checkpoint
|
// /resume <uuid> - resume from checkpoint
|
||||||
if let Some(rest) = lower.strip_prefix("/resume ")
|
if let Some(rest) = lower.strip_prefix("/resume ") {
|
||||||
&& let Ok(id) = Uuid::parse_str(rest.trim())
|
if let Ok(id) = Uuid::parse_str(rest.trim()) {
|
||||||
{
|
return Submission::Resume { checkpoint_id: id };
|
||||||
return Submission::Resume { checkpoint_id: id };
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||||
if trimmed.starts_with('{')
|
if trimmed.starts_with('{') {
|
||||||
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
|
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
|
||||||
&& matches!(submission, Submission::ExecApproval { .. })
|
if matches!(submission, Submission::ExecApproval { .. }) {
|
||||||
{
|
return submission;
|
||||||
return submission;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approval responses (simple yes/no/always for pending approvals)
|
// Approval responses (simple yes/no/always for pending approvals)
|
||||||
@@ -214,15 +172,6 @@ pub enum Submission {
|
|||||||
|
|
||||||
/// Quit the agent. Bypasses thread-state checks.
|
/// Quit the agent. Bypasses thread-state checks.
|
||||||
Quit,
|
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 {
|
impl Submission {
|
||||||
@@ -289,7 +238,6 @@ impl Submission {
|
|||||||
| Self::Heartbeat
|
| Self::Heartbeat
|
||||||
| Self::Summarize
|
| Self::Summarize
|
||||||
| Self::Suggest
|
| Self::Suggest
|
||||||
| Self::SystemCommand { .. }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,84 +504,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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]
|
#[test]
|
||||||
fn test_parser_quit() {
|
fn test_parser_quit() {
|
||||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||||
|
|||||||
+16
-136
@@ -43,10 +43,6 @@ impl Checkpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Manager for undo/redo functionality.
|
/// Manager for undo/redo functionality.
|
||||||
///
|
|
||||||
/// Each undo/redo operation pops from one stack and pushes the current state
|
|
||||||
/// onto the other, so `undo_count() + redo_count()` stays constant across
|
|
||||||
/// undo/redo cycles (only `checkpoint()` and `clear()` change the total).
|
|
||||||
pub struct UndoManager {
|
pub struct UndoManager {
|
||||||
/// Stack of past checkpoints (for undo).
|
/// Stack of past checkpoints (for undo).
|
||||||
undo_stack: VecDeque<Checkpoint>,
|
undo_stack: VecDeque<Checkpoint>,
|
||||||
@@ -72,14 +68,6 @@ impl UndoManager {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a checkpoint onto the undo stack, trimming oldest entries if over limit.
|
|
||||||
fn push_undo(&mut self, checkpoint: Checkpoint) {
|
|
||||||
self.undo_stack.push_back(checkpoint);
|
|
||||||
while self.undo_stack.len() > self.max_checkpoints {
|
|
||||||
self.undo_stack.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a checkpoint at the current state.
|
/// Create a checkpoint at the current state.
|
||||||
///
|
///
|
||||||
/// This clears the redo stack since we're creating a new history branch.
|
/// This clears the redo stack since we're creating a new history branch.
|
||||||
@@ -92,23 +80,24 @@ impl UndoManager {
|
|||||||
// Clear redo stack (new branch of history)
|
// Clear redo stack (new branch of history)
|
||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
|
|
||||||
|
// Create and push checkpoint
|
||||||
let checkpoint = Checkpoint::new(turn_number, messages, description);
|
let checkpoint = Checkpoint::new(turn_number, messages, description);
|
||||||
self.push_undo(checkpoint);
|
self.undo_stack.push_back(checkpoint);
|
||||||
|
|
||||||
|
// Trim if over limit
|
||||||
|
while self.undo_stack.len() > self.max_checkpoints {
|
||||||
|
self.undo_stack.pop_front();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Undo: pop the last checkpoint and return it.
|
/// Undo: pop the last checkpoint and return it.
|
||||||
///
|
///
|
||||||
/// Saves the current state to the redo stack and pops the most recent
|
/// The current state should be saved to redo stack before calling this.
|
||||||
/// checkpoint from the undo stack so that repeated undos walk backwards
|
|
||||||
/// through history.
|
|
||||||
///
|
|
||||||
/// Takes ownership of `current_messages`; callers must clone first if
|
|
||||||
/// they need to retain a copy.
|
|
||||||
pub fn undo(
|
pub fn undo(
|
||||||
&mut self,
|
&mut self,
|
||||||
current_turn: usize,
|
current_turn: usize,
|
||||||
current_messages: Vec<ChatMessage>,
|
current_messages: Vec<ChatMessage>,
|
||||||
) -> Option<Checkpoint> {
|
) -> Option<&Checkpoint> {
|
||||||
if self.undo_stack.is_empty() {
|
if self.undo_stack.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -121,8 +110,9 @@ impl UndoManager {
|
|||||||
);
|
);
|
||||||
self.redo_stack.push(current);
|
self.redo_stack.push(current);
|
||||||
|
|
||||||
// Pop and return the most recent checkpoint
|
// Return the most recent checkpoint without removing it
|
||||||
self.undo_stack.pop_back()
|
// (we keep it so multiple undos can work)
|
||||||
|
self.undo_stack.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop the last checkpoint from the undo stack.
|
/// Pop the last checkpoint from the undo stack.
|
||||||
@@ -131,29 +121,7 @@ impl UndoManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Redo: restore a previously undone state.
|
/// Redo: restore a previously undone state.
|
||||||
///
|
pub fn redo(&mut self) -> Option<Checkpoint> {
|
||||||
/// Saves the current state to the undo stack and pops the most recent
|
|
||||||
/// checkpoint from the redo stack.
|
|
||||||
///
|
|
||||||
/// Takes ownership of `current_messages`; callers must clone first if
|
|
||||||
/// they need to retain a copy.
|
|
||||||
pub fn redo(
|
|
||||||
&mut self,
|
|
||||||
current_turn: usize,
|
|
||||||
current_messages: Vec<ChatMessage>,
|
|
||||||
) -> Option<Checkpoint> {
|
|
||||||
if self.redo_stack.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current state to undo stack
|
|
||||||
let current = Checkpoint::new(
|
|
||||||
current_turn,
|
|
||||||
current_messages,
|
|
||||||
format!("Turn {}", current_turn),
|
|
||||||
);
|
|
||||||
self.push_undo(current);
|
|
||||||
|
|
||||||
self.redo_stack.pop()
|
self.redo_stack.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,16 +214,14 @@ mod tests {
|
|||||||
assert!(manager.can_undo());
|
assert!(manager.can_undo());
|
||||||
assert!(!manager.can_redo());
|
assert!(!manager.can_redo());
|
||||||
|
|
||||||
// Undo - returns owned Checkpoint now
|
// Undo
|
||||||
let current = vec![ChatMessage::user("Hello"), ChatMessage::assistant("Hi")];
|
let current = vec![ChatMessage::user("Hello"), ChatMessage::assistant("Hi")];
|
||||||
let checkpoint = manager.undo(2, current);
|
let checkpoint = manager.undo(2, current);
|
||||||
assert!(checkpoint.is_some());
|
assert!(checkpoint.is_some());
|
||||||
let checkpoint = checkpoint.unwrap();
|
|
||||||
assert_eq!(checkpoint.turn_number, 1);
|
|
||||||
assert!(manager.can_redo());
|
assert!(manager.can_redo());
|
||||||
|
|
||||||
// Redo - now requires current state parameters
|
// Redo
|
||||||
let restored = manager.redo(checkpoint.turn_number, checkpoint.messages);
|
let restored = manager.redo();
|
||||||
assert!(restored.is_some());
|
assert!(restored.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,90 +249,4 @@ mod tests {
|
|||||||
assert!(restored.is_some());
|
assert!(restored.is_some());
|
||||||
assert_eq!(manager.undo_count(), 0);
|
assert_eq!(manager.undo_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_repeated_undo_advances_through_stack() {
|
|
||||||
let mut manager = UndoManager::new();
|
|
||||||
|
|
||||||
// Create 3 checkpoints at turns 0, 1, 2
|
|
||||||
manager.checkpoint(0, vec![], "Turn 0");
|
|
||||||
manager.checkpoint(1, vec![ChatMessage::user("msg1")], "Turn 1");
|
|
||||||
manager.checkpoint(2, vec![ChatMessage::user("msg2")], "Turn 2");
|
|
||||||
assert_eq!(manager.undo_count(), 3);
|
|
||||||
|
|
||||||
// First undo: should return turn 2 checkpoint, stack shrinks to 2
|
|
||||||
let cp1 = manager
|
|
||||||
.undo(3, vec![ChatMessage::user("msg3")])
|
|
||||||
.expect("first undo should succeed");
|
|
||||||
assert_eq!(cp1.turn_number, 2);
|
|
||||||
assert_eq!(manager.undo_count(), 2);
|
|
||||||
|
|
||||||
// Second undo: should return turn 1 checkpoint (different!), stack shrinks to 1
|
|
||||||
let cp2 = manager
|
|
||||||
.undo(cp1.turn_number, cp1.messages)
|
|
||||||
.expect("second undo should succeed");
|
|
||||||
assert_eq!(cp2.turn_number, 1);
|
|
||||||
assert_eq!(manager.undo_count(), 1);
|
|
||||||
|
|
||||||
// Verify we walked backwards through distinct checkpoints
|
|
||||||
assert_ne!(cp1.turn_number, cp2.turn_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_undo_redo_cycle_preserves_state() {
|
|
||||||
let mut manager = UndoManager::new();
|
|
||||||
|
|
||||||
let msgs_t0: Vec<ChatMessage> = vec![];
|
|
||||||
let msgs_t1 = vec![ChatMessage::user("hello")];
|
|
||||||
let msgs_t2 = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
|
|
||||||
|
|
||||||
manager.checkpoint(0, msgs_t0, "Turn 0");
|
|
||||||
manager.checkpoint(1, msgs_t1, "Turn 1");
|
|
||||||
|
|
||||||
// Undo from turn 2 -> get turn 1 checkpoint
|
|
||||||
let cp_undo1 = manager
|
|
||||||
.undo(2, msgs_t2.clone())
|
|
||||||
.expect("undo should succeed");
|
|
||||||
assert_eq!(cp_undo1.turn_number, 1);
|
|
||||||
|
|
||||||
// Redo from turn 1 -> get turn 2 state back
|
|
||||||
let cp_redo = manager
|
|
||||||
.redo(cp_undo1.turn_number, cp_undo1.messages)
|
|
||||||
.expect("redo should succeed");
|
|
||||||
assert_eq!(cp_redo.turn_number, 2);
|
|
||||||
assert_eq!(cp_redo.messages.len(), 2);
|
|
||||||
|
|
||||||
// Undo again from turn 2 -> should go back to turn 1 again
|
|
||||||
let cp_undo2 = manager
|
|
||||||
.undo(cp_redo.turn_number, cp_redo.messages)
|
|
||||||
.expect("second undo should succeed");
|
|
||||||
assert_eq!(cp_undo2.turn_number, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_undo_redo_stack_sizes_consistent() {
|
|
||||||
let mut manager = UndoManager::new();
|
|
||||||
|
|
||||||
manager.checkpoint(0, vec![], "Turn 0");
|
|
||||||
manager.checkpoint(1, vec![ChatMessage::user("a")], "Turn 1");
|
|
||||||
manager.checkpoint(2, vec![ChatMessage::user("b")], "Turn 2");
|
|
||||||
|
|
||||||
// Start: undo=3, redo=0, total=3
|
|
||||||
let total = manager.undo_count() + manager.redo_count();
|
|
||||||
assert_eq!(total, 3);
|
|
||||||
|
|
||||||
// After undo: total should still be 3 (one moved from undo to redo,
|
|
||||||
// plus the current state pushed to redo)
|
|
||||||
// Actually: undo pops one (3->2), pushes current to redo (0->1), total=3
|
|
||||||
let cp = manager.undo(3, vec![]).unwrap();
|
|
||||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
|
||||||
|
|
||||||
// After redo: redo pops one (1->0), pushes current to undo (2->3), total=3
|
|
||||||
let cp2 = manager.redo(cp.turn_number, cp.messages).unwrap();
|
|
||||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
|
||||||
|
|
||||||
// After another undo: same invariant
|
|
||||||
let _cp3 = manager.undo(cp2.turn_number, cp2.messages).unwrap();
|
|
||||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-222
@@ -10,9 +10,8 @@ use uuid::Uuid;
|
|||||||
use crate::agent::scheduler::WorkerMessage;
|
use crate::agent::scheduler::WorkerMessage;
|
||||||
use crate::agent::task::TaskOutput;
|
use crate::agent::task::TaskOutput;
|
||||||
use crate::context::{ContextManager, JobState};
|
use crate::context::{ContextManager, JobState};
|
||||||
use crate::db::Database;
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::hooks::HookRegistry;
|
use crate::history::Store;
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||||
};
|
};
|
||||||
@@ -29,8 +28,7 @@ pub struct WorkerDeps {
|
|||||||
pub llm: Arc<dyn LlmProvider>,
|
pub llm: Arc<dyn LlmProvider>,
|
||||||
pub safety: Arc<SafetyLayer>,
|
pub safety: Arc<SafetyLayer>,
|
||||||
pub tools: Arc<ToolRegistry>,
|
pub tools: Arc<ToolRegistry>,
|
||||||
pub store: Option<Arc<dyn Database>>,
|
pub store: Option<Arc<Store>>,
|
||||||
pub hooks: Arc<HookRegistry>,
|
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
pub use_planning: bool,
|
pub use_planning: bool,
|
||||||
}
|
}
|
||||||
@@ -69,7 +67,7 @@ impl Worker {
|
|||||||
&self.deps.tools
|
&self.deps.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store(&self) -> Option<&Arc<dyn Database>> {
|
fn store(&self) -> Option<&Arc<Store>> {
|
||||||
self.deps.store.as_ref()
|
self.deps.store.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
|
||||||
&& ctx.state == JobState::Cancelled
|
if ctx.state == JobState::Cancelled {
|
||||||
{
|
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
return Ok(());
|
||||||
return Ok(());
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
@@ -250,15 +248,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
|
|
||||||
if selections.is_empty() {
|
if selections.is_empty() {
|
||||||
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
||||||
let respond_output = reasoning.respond_with_tools(reason_ctx).await?;
|
let respond_result = reasoning.respond_with_tools(reason_ctx).await?;
|
||||||
|
|
||||||
match respond_output.result {
|
match respond_result {
|
||||||
RespondResult::Text(response) => {
|
RespondResult::Text(response) => {
|
||||||
// Check for explicit completion phrases. Use word-boundary
|
// Check for completion keywords
|
||||||
// aware checks to avoid false positives like "incomplete",
|
let response_lower = response.to_lowercase();
|
||||||
// "not done", or "unfinished". Only the LLM's own response
|
if response_lower.contains("complete")
|
||||||
// (not tool output) can trigger this.
|
|| response_lower.contains("finished")
|
||||||
if crate::util::llm_signals_completion(&response) {
|
|| response_lower.contains("done")
|
||||||
|
{
|
||||||
self.mark_completed().await?;
|
self.mark_completed().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -273,10 +272,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RespondResult::ToolCalls {
|
RespondResult::ToolCalls(tool_calls) => {
|
||||||
tool_calls,
|
|
||||||
content,
|
|
||||||
} => {
|
|
||||||
// Model returned tool calls - execute them
|
// Model returned tool calls - execute them
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Job {} respond_with_tools returned {} tool calls",
|
"Job {} respond_with_tools returned {} tool calls",
|
||||||
@@ -284,14 +280,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
tool_calls.len()
|
tool_calls.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add assistant message with tool_calls (OpenAI protocol)
|
|
||||||
reason_ctx
|
|
||||||
.messages
|
|
||||||
.push(ChatMessage::assistant_with_tool_calls(
|
|
||||||
content,
|
|
||||||
tool_calls.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
||||||
|
|
||||||
@@ -301,7 +289,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
parameters: tc.arguments.clone(),
|
parameters: tc.arguments.clone(),
|
||||||
reasoning: String::new(),
|
reasoning: String::new(),
|
||||||
alternatives: vec![],
|
alternatives: vec![],
|
||||||
tool_call_id: tc.id.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
self.process_tool_result(reason_ctx, &selection, result)
|
self.process_tool_result(reason_ctx, &selection, result)
|
||||||
@@ -354,11 +341,23 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.map(|selection| {
|
.map(|selection| {
|
||||||
let tool_name = selection.tool_name.clone();
|
let tool_name = selection.tool_name.clone();
|
||||||
let params = selection.parameters.clone();
|
let params = selection.parameters.clone();
|
||||||
let deps = self.deps.clone();
|
let tools = self.tools().clone();
|
||||||
|
let context_manager = self.context_manager().clone();
|
||||||
|
let safety = self.safety().clone();
|
||||||
let job_id = self.job_id;
|
let job_id = self.job_id;
|
||||||
|
let store = self.deps.store.clone();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, ¶ms).await;
|
let result = Self::execute_tool_inner(
|
||||||
|
tools,
|
||||||
|
context_manager,
|
||||||
|
safety,
|
||||||
|
store,
|
||||||
|
job_id,
|
||||||
|
&tool_name,
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
ToolExecResult { result }
|
ToolExecResult { result }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -369,18 +368,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
|
|
||||||
/// Inner tool execution logic that can be called from both single and parallel paths.
|
/// Inner tool execution logic that can be called from both single and parallel paths.
|
||||||
async fn execute_tool_inner(
|
async fn execute_tool_inner(
|
||||||
deps: &WorkerDeps,
|
tools: Arc<ToolRegistry>,
|
||||||
|
context_manager: Arc<ContextManager>,
|
||||||
|
safety: Arc<SafetyLayer>,
|
||||||
|
store: Option<Arc<Store>>,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let tool =
|
let tool = tools
|
||||||
deps.tools
|
.get(tool_name)
|
||||||
.get(tool_name)
|
.await
|
||||||
.await
|
.ok_or_else(|| crate::error::ToolError::NotFound {
|
||||||
.ok_or_else(|| crate::error::ToolError::NotFound {
|
name: tool_name.to_string(),
|
||||||
name: tool_name.to_string(),
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Tools requiring approval are blocked in autonomous jobs
|
// Tools requiring approval are blocked in autonomous jobs
|
||||||
if tool.requires_approval() {
|
if tool.requires_approval() {
|
||||||
@@ -390,46 +391,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch job context early so we have the real user_id for hooks
|
// Get job context for the tool
|
||||||
let job_ctx = deps.context_manager.get_context(job_id).await?;
|
let job_ctx = context_manager.get_context(job_id).await?;
|
||||||
|
|
||||||
// Run BeforeToolCall hook
|
|
||||||
let params = {
|
|
||||||
use crate::hooks::{HookError, HookEvent, HookOutcome};
|
|
||||||
let event = HookEvent::ToolCall {
|
|
||||||
tool_name: tool_name.to_string(),
|
|
||||||
parameters: params.clone(),
|
|
||||||
user_id: job_ctx.user_id.clone(),
|
|
||||||
context: format!("job:{}", job_id),
|
|
||||||
};
|
|
||||||
match deps.hooks.run(&event).await {
|
|
||||||
Err(HookError::Rejected { reason }) => {
|
|
||||||
return Err(crate::error::ToolError::ExecutionFailed {
|
|
||||||
name: tool_name.to_string(),
|
|
||||||
reason: format!("Blocked by hook: {}", reason),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
return Err(crate::error::ToolError::ExecutionFailed {
|
|
||||||
name: tool_name.to_string(),
|
|
||||||
reason: format!("Blocked by hook failure mode: {}", err),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Ok(HookOutcome::Continue {
|
|
||||||
modified: Some(new_params),
|
|
||||||
}) => serde_json::from_str(&new_params).unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
tool = %tool_name,
|
|
||||||
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
params.clone()
|
|
||||||
}),
|
|
||||||
_ => params.clone(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if job_ctx.state == JobState::Cancelled {
|
if job_ctx.state == JobState::Cancelled {
|
||||||
return Err(crate::error::ToolError::ExecutionFailed {
|
return Err(crate::error::ToolError::ExecutionFailed {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
@@ -439,7 +402,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate tool parameters
|
// Validate tool parameters
|
||||||
let validation = deps.safety.validator().validate_tool_params(¶ms);
|
let validation = safety.validator().validate_tool_params(params);
|
||||||
if !validation.is_valid {
|
if !validation.is_valid {
|
||||||
let details = validation
|
let details = validation
|
||||||
.errors
|
.errors
|
||||||
@@ -454,58 +417,21 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
// Execute with timeout and timing
|
||||||
tool = %tool_name,
|
|
||||||
params = %params,
|
|
||||||
job = %job_id,
|
|
||||||
"Tool call started"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute with per-tool timeout and timing
|
|
||||||
let tool_timeout = tool.execution_timeout();
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let result = tokio::time::timeout(tool_timeout, async {
|
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||||
tool.execute(params.clone(), &job_ctx).await
|
tool.execute(params.clone(), &job_ctx).await
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
match &result {
|
|
||||||
Ok(Ok(output)) => {
|
|
||||||
let result_str = serde_json::to_string(&output.result)
|
|
||||||
.unwrap_or_else(|_| "<serialize error>".to_string());
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tool_name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
result = %result_str,
|
|
||||||
"Tool call succeeded"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tool_name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
error = %e,
|
|
||||||
"Tool call failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tool_name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
timeout_secs = tool_timeout.as_secs(),
|
|
||||||
"Tool call timed out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record action in memory and get the ActionRecord for persistence
|
// Record action in memory and get the ActionRecord for persistence
|
||||||
let action = match &result {
|
let action = match &result {
|
||||||
Ok(Ok(output)) => {
|
Ok(Ok(output)) => {
|
||||||
let output_str = serde_json::to_string_pretty(&output.result)
|
let output_str = serde_json::to_string_pretty(&output.result)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| deps.safety.sanitize_tool_output(tool_name, &s).content);
|
.map(|s| safety.sanitize_tool_output(tool_name, &s).content);
|
||||||
deps.context_manager
|
context_manager
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
||||||
output_str.clone(),
|
output_str.clone(),
|
||||||
@@ -518,8 +444,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => deps
|
Ok(Err(e)) => context_manager
|
||||||
.context_manager
|
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem
|
let rec = mem
|
||||||
.create_action(tool_name, params.clone())
|
.create_action(tool_name, params.clone())
|
||||||
@@ -529,8 +454,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.ok(),
|
.ok(),
|
||||||
Err(_) => deps
|
Err(_) => context_manager
|
||||||
.context_manager
|
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem
|
let rec = mem
|
||||||
.create_action(tool_name, params.clone())
|
.create_action(tool_name, params.clone())
|
||||||
@@ -543,7 +467,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Persist action to database (fire-and-forget)
|
// Persist action to database (fire-and-forget)
|
||||||
if let (Some(action), Some(store)) = (action, deps.store.clone()) {
|
if let (Some(action), Some(store)) = (action, store) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = store.save_action(job_id, &action).await {
|
if let Err(e) = store.save_action(job_id, &action).await {
|
||||||
tracing::warn!("Failed to persist action for job {}: {}", job_id, e);
|
tracing::warn!("Failed to persist action for job {}: {}", job_id, e);
|
||||||
@@ -555,7 +479,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
let output = result
|
let output = result
|
||||||
.map_err(|_| crate::error::ToolError::Timeout {
|
.map_err(|_| crate::error::ToolError::Timeout {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
timeout: tool_timeout,
|
timeout: Duration::from_secs(60),
|
||||||
})?
|
})?
|
||||||
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
||||||
name: tool_name.to_string(),
|
name: tool_name.to_string(),
|
||||||
@@ -594,14 +518,17 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
);
|
);
|
||||||
|
|
||||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||||
&selection.tool_call_id,
|
"tool_call_id",
|
||||||
&selection.tool_name,
|
&selection.tool_name,
|
||||||
wrapped,
|
wrapped,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Tool output never drives job completion. A malicious tool could
|
// Check if job is complete
|
||||||
// emit "TASK_COMPLETE" to force premature completion. Only the LLM's
|
if output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") {
|
||||||
// own structured response (in execution_loop) can mark a job done.
|
self.mark_completed().await?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -626,7 +553,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||||
&selection.tool_call_id,
|
"tool_call_id",
|
||||||
&selection.tool_name,
|
&selection.tool_name,
|
||||||
format!("Error: {}", e),
|
format!("Error: {}", e),
|
||||||
));
|
));
|
||||||
@@ -676,15 +603,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.execute_tool(&action.tool_name, &action.parameters)
|
.execute_tool(&action.tool_name, &action.parameters)
|
||||||
.await;
|
.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 {
|
let selection = ToolSelection {
|
||||||
tool_name: action.tool_name.clone(),
|
tool_name: action.tool_name.clone(),
|
||||||
parameters: action.parameters.clone(),
|
parameters: action.parameters.clone(),
|
||||||
reasoning: action.reasoning.clone(),
|
reasoning: action.reasoning.clone(),
|
||||||
alternatives: vec![],
|
alternatives: vec![],
|
||||||
tool_call_id: format!("plan_{}_{}", self.job_id, i),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process the result
|
// Process the result
|
||||||
@@ -708,7 +632,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
let response = reasoning.respond(reason_ctx).await?;
|
let response = reasoning.respond(reason_ctx).await?;
|
||||||
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
||||||
|
|
||||||
if crate::util::llm_signals_completion(&response) {
|
let response_lower = response.to_lowercase();
|
||||||
|
if response_lower.contains("complete")
|
||||||
|
|| response_lower.contains("finished")
|
||||||
|
|| response_lower.contains("done")
|
||||||
|
{
|
||||||
self.mark_completed().await?;
|
self.mark_completed().await?;
|
||||||
} else {
|
} else {
|
||||||
// Job not complete, could re-plan or fall back to direct selection
|
// Job not complete, could re-plan or fall back to direct selection
|
||||||
@@ -729,7 +657,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
Self::execute_tool_inner(&self.deps, self.job_id, tool_name, params).await
|
Self::execute_tool_inner(
|
||||||
|
self.tools().clone(),
|
||||||
|
self.context_manager().clone(),
|
||||||
|
self.safety().clone(),
|
||||||
|
self.deps.store.clone(),
|
||||||
|
self.job_id,
|
||||||
|
tool_name,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_completed(&self) -> Result<(), Error> {
|
async fn mark_completed(&self) -> Result<(), Error> {
|
||||||
@@ -794,86 +731,3 @@ 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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
//! Boot screen displayed after all initialization completes.
|
|
||||||
//!
|
|
||||||
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
|
|
||||||
//! state: model, database, tool count, enabled features, active channels,
|
|
||||||
//! and the gateway URL.
|
|
||||||
|
|
||||||
/// All displayable fields for the boot screen.
|
|
||||||
pub struct BootInfo {
|
|
||||||
pub version: String,
|
|
||||||
pub agent_name: String,
|
|
||||||
pub llm_backend: String,
|
|
||||||
pub llm_model: String,
|
|
||||||
pub cheap_model: Option<String>,
|
|
||||||
pub db_backend: String,
|
|
||||||
pub db_connected: bool,
|
|
||||||
pub tool_count: usize,
|
|
||||||
pub gateway_url: Option<String>,
|
|
||||||
pub embeddings_enabled: bool,
|
|
||||||
pub embeddings_provider: Option<String>,
|
|
||||||
pub heartbeat_enabled: bool,
|
|
||||||
pub heartbeat_interval_secs: u64,
|
|
||||||
pub sandbox_enabled: bool,
|
|
||||||
pub claude_code_enabled: bool,
|
|
||||||
pub routines_enabled: bool,
|
|
||||||
pub channels: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the boot screen to stdout.
|
|
||||||
pub fn print_boot_screen(info: &BootInfo) {
|
|
||||||
// ANSI codes matching existing REPL palette
|
|
||||||
let bold = "\x1b[1m";
|
|
||||||
let cyan = "\x1b[36m";
|
|
||||||
let dim = "\x1b[90m";
|
|
||||||
let yellow_underline = "\x1b[33;4m";
|
|
||||||
let reset = "\x1b[0m";
|
|
||||||
|
|
||||||
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("{border}");
|
|
||||||
println!();
|
|
||||||
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Model line
|
|
||||||
let model_display = if let Some(ref cheap) = info.cheap_model {
|
|
||||||
format!(
|
|
||||||
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
|
|
||||||
info.llm_model, cheap
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("{cyan}{}{reset}", info.llm_model)
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
" {dim}model{reset} {model_display} {dim}via {}{reset}",
|
|
||||||
info.llm_backend
|
|
||||||
);
|
|
||||||
|
|
||||||
// Database line
|
|
||||||
let db_status = if info.db_connected {
|
|
||||||
"connected"
|
|
||||||
} else {
|
|
||||||
"none"
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
|
|
||||||
info.db_backend
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tools line
|
|
||||||
println!(
|
|
||||||
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
|
|
||||||
info.tool_count
|
|
||||||
);
|
|
||||||
|
|
||||||
// Features line
|
|
||||||
let mut features = Vec::new();
|
|
||||||
if info.embeddings_enabled {
|
|
||||||
if let Some(ref provider) = info.embeddings_provider {
|
|
||||||
features.push(format!("embeddings ({provider})"));
|
|
||||||
} else {
|
|
||||||
features.push("embeddings".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info.heartbeat_enabled {
|
|
||||||
let mins = info.heartbeat_interval_secs / 60;
|
|
||||||
features.push(format!("heartbeat ({mins}m)"));
|
|
||||||
}
|
|
||||||
if info.sandbox_enabled {
|
|
||||||
features.push("sandbox".to_string());
|
|
||||||
}
|
|
||||||
if info.claude_code_enabled {
|
|
||||||
features.push("claude-code".to_string());
|
|
||||||
}
|
|
||||||
if info.routines_enabled {
|
|
||||||
features.push("routines".to_string());
|
|
||||||
}
|
|
||||||
if !features.is_empty() {
|
|
||||||
println!(
|
|
||||||
" {dim}features{reset} {cyan}{}{reset}",
|
|
||||||
features.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channels line
|
|
||||||
if !info.channels.is_empty() {
|
|
||||||
println!(
|
|
||||||
" {dim}channels{reset} {cyan}{}{reset}",
|
|
||||||
info.channels.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gateway URL (highlighted)
|
|
||||||
if let Some(ref url) = info.gateway_url {
|
|
||||||
println!();
|
|
||||||
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("{border}");
|
|
||||||
println!();
|
|
||||||
println!(" /help for commands, /quit to exit");
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_print_boot_screen_full() {
|
|
||||||
let info = BootInfo {
|
|
||||||
version: "0.2.0".to_string(),
|
|
||||||
agent_name: "ironclaw".to_string(),
|
|
||||||
llm_backend: "nearai".to_string(),
|
|
||||||
llm_model: "claude-3-5-sonnet-20241022".to_string(),
|
|
||||||
cheap_model: Some("gpt-4o-mini".to_string()),
|
|
||||||
db_backend: "libsql".to_string(),
|
|
||||||
db_connected: true,
|
|
||||||
tool_count: 24,
|
|
||||||
gateway_url: Some("http://127.0.0.1:3001/?token=abc123".to_string()),
|
|
||||||
embeddings_enabled: true,
|
|
||||||
embeddings_provider: Some("openai".to_string()),
|
|
||||||
heartbeat_enabled: true,
|
|
||||||
heartbeat_interval_secs: 1800,
|
|
||||||
sandbox_enabled: true,
|
|
||||||
claude_code_enabled: false,
|
|
||||||
routines_enabled: true,
|
|
||||||
channels: vec![
|
|
||||||
"repl".to_string(),
|
|
||||||
"gateway".to_string(),
|
|
||||||
"telegram".to_string(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
// Should not panic
|
|
||||||
print_boot_screen(&info);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_print_boot_screen_minimal() {
|
|
||||||
let info = BootInfo {
|
|
||||||
version: "0.2.0".to_string(),
|
|
||||||
agent_name: "ironclaw".to_string(),
|
|
||||||
llm_backend: "nearai".to_string(),
|
|
||||||
llm_model: "gpt-4o".to_string(),
|
|
||||||
cheap_model: None,
|
|
||||||
db_backend: "none".to_string(),
|
|
||||||
db_connected: false,
|
|
||||||
tool_count: 5,
|
|
||||||
gateway_url: None,
|
|
||||||
embeddings_enabled: false,
|
|
||||||
embeddings_provider: None,
|
|
||||||
heartbeat_enabled: false,
|
|
||||||
heartbeat_interval_secs: 0,
|
|
||||||
sandbox_enabled: false,
|
|
||||||
claude_code_enabled: false,
|
|
||||||
routines_enabled: false,
|
|
||||||
channels: vec![],
|
|
||||||
};
|
|
||||||
// Should not panic
|
|
||||||
print_boot_screen(&info);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_print_boot_screen_no_features() {
|
|
||||||
let info = BootInfo {
|
|
||||||
version: "0.1.0".to_string(),
|
|
||||||
agent_name: "test".to_string(),
|
|
||||||
llm_backend: "openai".to_string(),
|
|
||||||
llm_model: "gpt-4o".to_string(),
|
|
||||||
cheap_model: None,
|
|
||||||
db_backend: "postgres".to_string(),
|
|
||||||
db_connected: true,
|
|
||||||
tool_count: 10,
|
|
||||||
gateway_url: None,
|
|
||||||
embeddings_enabled: false,
|
|
||||||
embeddings_provider: None,
|
|
||||||
heartbeat_enabled: false,
|
|
||||||
heartbeat_interval_secs: 0,
|
|
||||||
sandbox_enabled: false,
|
|
||||||
claude_code_enabled: false,
|
|
||||||
routines_enabled: false,
|
|
||||||
channels: vec!["repl".to_string()],
|
|
||||||
};
|
|
||||||
// Should not panic
|
|
||||||
print_boot_screen(&info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
//! 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -114,12 +114,6 @@ pub enum StatusUpdate {
|
|||||||
StreamChunk(String),
|
StreamChunk(String),
|
||||||
/// General status message.
|
/// General status message.
|
||||||
Status(String),
|
Status(String),
|
||||||
/// A sandbox job has started (shown as a clickable card in the UI).
|
|
||||||
JobStarted {
|
|
||||||
job_id: String,
|
|
||||||
title: String,
|
|
||||||
browse_url: String,
|
|
||||||
},
|
|
||||||
/// Tool requires user approval before execution.
|
/// Tool requires user approval before execution.
|
||||||
ApprovalNeeded {
|
ApprovalNeeded {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -127,19 +121,6 @@ pub enum StatusUpdate {
|
|||||||
description: String,
|
description: String,
|
||||||
parameters: serde_json::Value,
|
parameters: serde_json::Value,
|
||||||
},
|
},
|
||||||
/// Extension needs user authentication (token or OAuth).
|
|
||||||
AuthRequired {
|
|
||||||
extension_name: String,
|
|
||||||
instructions: Option<String>,
|
|
||||||
auth_url: Option<String>,
|
|
||||||
setup_url: Option<String>,
|
|
||||||
},
|
|
||||||
/// Extension authentication completed.
|
|
||||||
AuthCompleted {
|
|
||||||
extension_name: String,
|
|
||||||
success: bool,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for message channels.
|
/// Trait for message channels.
|
||||||
|
|||||||
+11
-83
@@ -33,41 +33,21 @@ use termimad::MadSkin;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
use crate::agent::truncate_for_preview;
|
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
/// 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.
|
/// Slash commands available in the REPL.
|
||||||
const SLASH_COMMANDS: &[&str] = &[
|
const SLASH_COMMANDS: &[&str] = &[
|
||||||
"/help",
|
"/help",
|
||||||
"/quit",
|
"/quit",
|
||||||
"/exit",
|
"/exit",
|
||||||
"/debug",
|
"/debug",
|
||||||
"/model",
|
|
||||||
"/undo",
|
"/undo",
|
||||||
"/redo",
|
"/redo",
|
||||||
"/clear",
|
"/clear",
|
||||||
"/compact",
|
"/compact",
|
||||||
"/new",
|
"/new",
|
||||||
"/interrupt",
|
"/interrupt",
|
||||||
"/version",
|
|
||||||
"/tools",
|
|
||||||
"/ping",
|
|
||||||
"/job",
|
|
||||||
"/status",
|
|
||||||
"/cancel",
|
|
||||||
"/list",
|
|
||||||
"/heartbeat",
|
|
||||||
"/summarize",
|
|
||||||
"/suggest",
|
|
||||||
"/thread",
|
|
||||||
"/resume",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Rustyline helper for slash-command tab completion.
|
/// Rustyline helper for slash-command tab completion.
|
||||||
@@ -184,8 +164,6 @@ pub struct ReplChannel {
|
|||||||
debug_mode: Arc<AtomicBool>,
|
debug_mode: Arc<AtomicBool>,
|
||||||
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||||
is_streaming: Arc<AtomicBool>,
|
is_streaming: Arc<AtomicBool>,
|
||||||
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
|
||||||
suppress_banner: Arc<AtomicBool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplChannel {
|
impl ReplChannel {
|
||||||
@@ -195,7 +173,6 @@ impl ReplChannel {
|
|||||||
single_message: None,
|
single_message: None,
|
||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,15 +182,9 @@ impl ReplChannel {
|
|||||||
single_message: Some(message),
|
single_message: Some(message),
|
||||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suppress the one-liner startup banner (boot screen will be shown instead).
|
|
||||||
pub fn suppress_banner(&self) {
|
|
||||||
self.suppress_banner.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_debug(&self) -> bool {
|
fn is_debug(&self) -> bool {
|
||||||
self.debug_mode.load(Ordering::Relaxed)
|
self.debug_mode.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
@@ -273,12 +244,11 @@ impl Channel for ReplChannel {
|
|||||||
let (tx, rx) = mpsc::channel(32);
|
let (tx, rx) = mpsc::channel(32);
|
||||||
let single_message = self.single_message.clone();
|
let single_message = self.single_message.clone();
|
||||||
let debug_mode = Arc::clone(&self.debug_mode);
|
let debug_mode = Arc::clone(&self.debug_mode);
|
||||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// Single message mode: send it and return
|
// Single message mode: send it and return
|
||||||
if let Some(msg) = single_message {
|
if let Some(msg) = single_message {
|
||||||
let incoming = IncomingMessage::new("repl", "default", &msg);
|
let incoming = IncomingMessage::new("repl", "user", &msg);
|
||||||
let _ = tx.blocking_send(incoming);
|
let _ = tx.blocking_send(incoming);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -308,10 +278,8 @@ impl Channel for ReplChannel {
|
|||||||
}
|
}
|
||||||
let _ = rl.load_history(&hist_path);
|
let _ = rl.load_history(&hist_path);
|
||||||
|
|
||||||
if !suppress_banner.load(Ordering::Relaxed) {
|
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
println!();
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||||
@@ -327,11 +295,10 @@ impl Channel for ReplChannel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle local REPL commands (only commands that need
|
// Handle local REPL commands
|
||||||
// immediate local handling stay here)
|
|
||||||
match line.to_lowercase().as_str() {
|
match line.to_lowercase().as_str() {
|
||||||
"/quit" | "/exit" => break,
|
"/quit" | "/exit" => break,
|
||||||
"/help" => {
|
"/help" | "/?" => {
|
||||||
print_help();
|
print_help();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -348,21 +315,21 @@ impl Channel for ReplChannel {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = IncomingMessage::new("repl", "default", line);
|
let msg = IncomingMessage::new("repl", "user", line);
|
||||||
if tx.blocking_send(msg).is_err() {
|
if tx.blocking_send(msg).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(ReadlineError::Interrupted) => {
|
Err(ReadlineError::Interrupted) => {
|
||||||
// Ctrl+C: send /interrupt
|
// Ctrl+C: send /interrupt
|
||||||
let msg = IncomingMessage::new("repl", "default", "/interrupt");
|
let msg = IncomingMessage::new("repl", "user", "/interrupt");
|
||||||
if tx.blocking_send(msg).is_err() {
|
if tx.blocking_send(msg).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(ReadlineError::Eof) => {
|
Err(ReadlineError::Eof) => {
|
||||||
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
||||||
let msg = IncomingMessage::new("repl", "default", "/quit");
|
let msg = IncomingMessage::new("repl", "user", "/quit");
|
||||||
let _ = tx.blocking_send(msg);
|
let _ = tx.blocking_send(msg);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -419,8 +386,7 @@ impl Channel for ReplChannel {
|
|||||||
|
|
||||||
match status {
|
match status {
|
||||||
StatusUpdate::Thinking(msg) => {
|
StatusUpdate::Thinking(msg) => {
|
||||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
eprintln!(" \x1b[90m\u{25CB} {msg}\x1b[0m");
|
||||||
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolStarted { name } => {
|
StatusUpdate::ToolStarted { name } => {
|
||||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||||
@@ -433,8 +399,7 @@ impl Channel for ReplChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolResult { name: _, preview } => {
|
StatusUpdate::ToolResult { name: _, preview } => {
|
||||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
eprintln!(" \x1b[90m{preview}\x1b[0m");
|
||||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
|
||||||
}
|
}
|
||||||
StatusUpdate::StreamChunk(chunk) => {
|
StatusUpdate::StreamChunk(chunk) => {
|
||||||
// Print separator on the false-to-true transition
|
// Print separator on the false-to-true transition
|
||||||
@@ -448,19 +413,9 @@ impl Channel for ReplChannel {
|
|||||||
print!("{chunk}");
|
print!("{chunk}");
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
}
|
}
|
||||||
StatusUpdate::JobStarted {
|
|
||||||
job_id,
|
|
||||||
title,
|
|
||||||
browse_url,
|
|
||||||
} => {
|
|
||||||
eprintln!(
|
|
||||||
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
StatusUpdate::Status(msg) => {
|
StatusUpdate::Status(msg) => {
|
||||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
eprintln!(" \x1b[90m{msg}\x1b[0m");
|
||||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StatusUpdate::ApprovalNeeded {
|
StatusUpdate::ApprovalNeeded {
|
||||||
@@ -517,33 +472,6 @@ impl Channel for ReplChannel {
|
|||||||
eprintln!(" {bot_border}");
|
eprintln!(" {bot_border}");
|
||||||
eprintln!();
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-109
@@ -1,125 +1,68 @@
|
|||||||
//! Known WASM channels that can be installed from build artifacts.
|
//! Bundled WASM channels that can be installed locally.
|
||||||
//!
|
|
||||||
//! 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 std::path::Path;
|
||||||
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
/// Compile-time project root, used to locate channels-src/ in dev builds.
|
#[derive(Clone, Copy)]
|
||||||
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
struct BundledChannel {
|
||||||
|
name: &'static str,
|
||||||
/// Known channel names and their crate names (for locating build artifacts).
|
wasm: &'static [u8],
|
||||||
const KNOWN_CHANNELS: &[(&str, &str)] = &[
|
capabilities: &'static [u8],
|
||||||
("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.
|
/// Names of bundled channels shipped with IronClaw.
|
||||||
///
|
pub fn bundled_channel_names() -> &'static [&'static str] {
|
||||||
/// Checks (in order):
|
&["telegram"]
|
||||||
/// 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.
|
/// Install a bundled channel into a channels directory.
|
||||||
///
|
|
||||||
/// 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(
|
pub async fn install_bundled_channel(
|
||||||
name: &str,
|
name: &str,
|
||||||
target_dir: &Path,
|
target_dir: &Path,
|
||||||
force: bool,
|
force: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (wasm_src, caps_src) = locate_channel_artifacts(name)?;
|
let channel = bundled_channel(name)
|
||||||
|
.ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?;
|
||||||
|
|
||||||
fs::create_dir_all(target_dir)
|
fs::create_dir_all(target_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
|
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
|
||||||
|
|
||||||
let wasm_dst = target_dir.join(format!("{}.wasm", name));
|
let wasm_path = target_dir.join(format!("{}.wasm", channel.name));
|
||||||
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
|
let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name));
|
||||||
|
|
||||||
let has_existing = wasm_dst.exists() || caps_dst.exists();
|
let has_existing = wasm_path.exists() || caps_path.exists();
|
||||||
if has_existing && !force {
|
if has_existing && !force {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Channel '{}' already exists at {}",
|
"Channel '{}' already exists at {}",
|
||||||
name,
|
channel.name,
|
||||||
target_dir.display()
|
target_dir.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::copy(&wasm_src, &wasm_dst)
|
fs::write(&wasm_path, channel.wasm)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?;
|
.map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?;
|
||||||
fs::copy(&caps_src, &caps_dst)
|
fs::write(&caps_path, channel.capabilities)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?;
|
.map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check which known channels have build artifacts available.
|
fn bundled_channel(name: &str) -> Option<BundledChannel> {
|
||||||
pub fn available_channel_names() -> Vec<&'static str> {
|
if name.eq_ignore_ascii_case("telegram") {
|
||||||
KNOWN_CHANNELS
|
Some(BundledChannel {
|
||||||
.iter()
|
name: "telegram",
|
||||||
.filter(|(name, _)| locate_channel_artifacts(name).is_ok())
|
wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"),
|
||||||
.map(|(name, _)| *name)
|
capabilities: include_bytes!(
|
||||||
.collect()
|
"../../../channels-src/telegram/telegram.capabilities.json"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -130,35 +73,31 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_known_channels_includes_all_three() {
|
fn test_bundled_channel_names_contains_telegram() {
|
||||||
let names = bundled_channel_names();
|
assert!(bundled_channel_names().contains(&"telegram"));
|
||||||
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]
|
#[tokio::test]
|
||||||
async fn test_install_refuses_overwrite_without_force() {
|
async fn test_install_bundled_channel_writes_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
|
||||||
|
install_bundled_channel("telegram", dir.path(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(dir.path().join("telegram.wasm").exists());
|
||||||
|
assert!(dir.path().join("telegram.capabilities.json").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_install_bundled_channel_refuses_overwrite_without_force() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let wasm_path = dir.path().join("telegram.wasm");
|
let wasm_path = dir.path().join("telegram.wasm");
|
||||||
fs::write(&wasm_path, b"custom").await.unwrap();
|
fs::write(&wasm_path, b"custom").await.unwrap();
|
||||||
|
|
||||||
let result = install_bundled_channel("telegram", dir.path(), false).await;
|
let result = install_bundled_channel("telegram", dir.path(), false).await;
|
||||||
// Either fails because artifacts missing OR because file exists
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
||||||
// Original file should be untouched
|
|
||||||
let existing = fs::read(&wasm_path).await.unwrap();
|
let existing = fs::read(&wasm_path).await.unwrap();
|
||||||
assert_eq!(existing, b"custom");
|
assert_eq!(existing, b"custom");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,16 @@ use crate::channels::wasm::error::WasmChannelError;
|
|||||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||||
use crate::channels::wasm::wrapper::WasmChannel;
|
use crate::channels::wasm::wrapper::WasmChannel;
|
||||||
use crate::pairing::PairingStore;
|
|
||||||
|
|
||||||
/// Loads WASM channels from the filesystem.
|
/// Loads WASM channels from the filesystem.
|
||||||
pub struct WasmChannelLoader {
|
pub struct WasmChannelLoader {
|
||||||
runtime: Arc<WasmChannelRuntime>,
|
runtime: Arc<WasmChannelRuntime>,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmChannelLoader {
|
impl WasmChannelLoader {
|
||||||
/// Create a new loader with the given runtime and pairing store.
|
/// Create a new loader with the given runtime.
|
||||||
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
pub fn new(runtime: Arc<WasmChannelRuntime>) -> Self {
|
||||||
Self {
|
Self { runtime }
|
||||||
runtime,
|
|
||||||
pairing_store,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a single WASM channel from a file pair.
|
/// Load a single WASM channel from a file pair.
|
||||||
@@ -119,13 +114,7 @@ impl WasmChannelLoader {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Create the channel
|
// Create the channel
|
||||||
let channel = WasmChannel::new(
|
let channel = WasmChannel::new(self.runtime.clone(), prepared, capabilities, config_json);
|
||||||
self.runtime.clone(),
|
|
||||||
prepared,
|
|
||||||
capabilities,
|
|
||||||
config_json,
|
|
||||||
self.pairing_store.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
name = name,
|
name = name,
|
||||||
@@ -363,7 +352,6 @@ mod tests {
|
|||||||
|
|
||||||
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels};
|
||||||
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig};
|
||||||
use crate::pairing::PairingStore;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -420,7 +408,7 @@ mod tests {
|
|||||||
async fn test_loader_invalid_name() {
|
async fn test_loader_invalid_name() {
|
||||||
let config = WasmChannelRuntimeConfig::for_testing();
|
let config = WasmChannelRuntimeConfig::for_testing();
|
||||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
|
let loader = WasmChannelLoader::new(runtime);
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let wasm_path = dir.path().join("test.wasm");
|
let wasm_path = dir.path().join("test.wasm");
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ mod schema;
|
|||||||
mod wrapper;
|
mod wrapper;
|
||||||
|
|
||||||
// Core types
|
// Core types
|
||||||
pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel};
|
pub use bundled::{bundled_channel_names, install_bundled_channel};
|
||||||
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig};
|
||||||
pub use error::WasmChannelError;
|
pub use error::WasmChannelError;
|
||||||
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
||||||
|
|||||||
@@ -478,7 +478,6 @@ mod tests {
|
|||||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||||
};
|
};
|
||||||
use crate::channels::wasm::wrapper::WasmChannel;
|
use crate::channels::wasm::wrapper::WasmChannel;
|
||||||
use crate::pairing::PairingStore;
|
|
||||||
use crate::tools::wasm::ResourceLimits;
|
use crate::tools::wasm::ResourceLimits;
|
||||||
|
|
||||||
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
||||||
@@ -500,7 +499,6 @@ mod tests {
|
|||||||
prepared,
|
prepared,
|
||||||
capabilities,
|
capabilities,
|
||||||
"{}".to_string(),
|
"{}".to_string(),
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-399
@@ -48,7 +48,6 @@ use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
|
|||||||
use crate::channels::wasm::schema::ChannelConfig;
|
use crate::channels::wasm::schema::ChannelConfig;
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
use crate::pairing::PairingStore;
|
|
||||||
use crate::safety::LeakDetector;
|
use crate::safety::LeakDetector;
|
||||||
use crate::tools::wasm::LogLevel;
|
use crate::tools::wasm::LogLevel;
|
||||||
use crate::tools::wasm::WasmResourceLimiter;
|
use crate::tools::wasm::WasmResourceLimiter;
|
||||||
@@ -74,11 +73,6 @@ struct ChannelStoreData {
|
|||||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
/// Pairing store for DM pairing (guest access control).
|
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
/// 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 {
|
impl ChannelStoreData {
|
||||||
@@ -87,7 +81,6 @@ impl ChannelStoreData {
|
|||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||||
let wasi = WasiCtxBuilder::new().build();
|
let wasi = WasiCtxBuilder::new().build();
|
||||||
@@ -98,8 +91,6 @@ impl ChannelStoreData {
|
|||||||
wasi,
|
wasi,
|
||||||
table: ResourceTable::new(),
|
table: ResourceTable::new(),
|
||||||
credentials,
|
credentials,
|
||||||
pairing_store,
|
|
||||||
http_runtime: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,34 +129,18 @@ impl ChannelStoreData {
|
|||||||
if result.contains('{') && result.contains('}') {
|
if result.contains('{') && result.contains('}') {
|
||||||
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
||||||
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
||||||
if let Some(re) = brace_pattern
|
if let Some(re) = brace_pattern {
|
||||||
&& re.is_match(&result)
|
if re.is_match(&result) {
|
||||||
{
|
tracing::warn!(
|
||||||
tracing::warn!(
|
context = %context,
|
||||||
context = %context,
|
"String may contain unresolved credential placeholders"
|
||||||
"String may contain unresolved credential placeholders"
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace injected credential values with `[REDACTED]` in text.
|
|
||||||
///
|
|
||||||
/// Prevents credentials from leaking through error messages, logs, or
|
|
||||||
/// return values to WASM. reqwest::Error includes the full URL in its
|
|
||||||
/// Display output, so any error from an injected-URL request will
|
|
||||||
/// contain the raw credential unless we scrub it.
|
|
||||||
fn redact_credentials(&self, text: &str) -> String {
|
|
||||||
let mut result = text.to_string();
|
|
||||||
for (name, value) in &self.credentials {
|
|
||||||
if !value.is_empty() {
|
|
||||||
result = result.replace(value, &format!("[REDACTED:{}]", name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement WasiView to provide WASI context and resource table
|
// Implement WasiView to provide WASI context and resource table
|
||||||
@@ -212,7 +187,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
url: String,
|
url: String,
|
||||||
headers_json: String,
|
headers_json: String,
|
||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
timeout_ms: Option<u32>,
|
|
||||||
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
method = %method,
|
method = %method,
|
||||||
@@ -277,35 +251,10 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
.scan_http_request(&url, &header_vec, body.as_deref())
|
.scan_http_request(&url, &header_vec, body.as_deref())
|
||||||
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
||||||
|
|
||||||
// Get the max response size from capabilities (default 10MB).
|
// Make the HTTP request using blocking I/O
|
||||||
let max_response_bytes = self
|
// We're already in a spawn_blocking context, so we can use block_on
|
||||||
.host_state
|
let result = tokio::runtime::Handle::current().block_on(async {
|
||||||
.capabilities()
|
let client = reqwest::Client::new();
|
||||||
.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() {
|
let mut request = match method.to_uppercase().as_str() {
|
||||||
"GET" => client.get(&url),
|
"GET" => client.get(&url),
|
||||||
@@ -327,21 +276,12 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
request = request.body(body_bytes);
|
request = request.body(body_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send request with caller-specified timeout (default 30s, max 5min).
|
// Send request with timeout
|
||||||
let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64;
|
let response = request
|
||||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
let response = request.timeout(timeout).send().await.map_err(|e| {
|
.send()
|
||||||
// Walk the full error chain so we get the actual root cause
|
.await
|
||||||
// (DNS, TLS, connection refused, etc.) instead of just
|
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||||
// "error sending request for url (...)".
|
|
||||||
let mut chain = format!("HTTP request failed: {}", e);
|
|
||||||
let mut source = std::error::Error::source(&e);
|
|
||||||
while let Some(cause) = source {
|
|
||||||
chain.push_str(&format!(" -> {}", cause));
|
|
||||||
source = cause.source();
|
|
||||||
}
|
|
||||||
chain
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let response_headers: std::collections::HashMap<String, String> = response
|
let response_headers: std::collections::HashMap<String, String> = response
|
||||||
@@ -354,29 +294,11 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
|
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
|
let body = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
.map_err(|e| format!("Failed to read response body: {}", e))?
|
||||||
if body.len() > max_response {
|
.to_vec();
|
||||||
return Err(format!(
|
|
||||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
|
||||||
body.len(),
|
|
||||||
max_response
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let body = body.to_vec();
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
status = status,
|
status = status,
|
||||||
@@ -408,11 +330,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scrub credential values from error messages before logging or returning
|
|
||||||
// to WASM. reqwest::Error includes the full URL (with injected credentials)
|
|
||||||
// in its Display output.
|
|
||||||
let result = result.map_err(|e| self.redact_credentials(&e));
|
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
tracing::info!(status = resp.status, "http_request completed successfully");
|
tracing::info!(status = resp.status, "http_request completed successfully");
|
||||||
@@ -455,43 +372,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pairing_upsert_request(
|
|
||||||
&mut self,
|
|
||||||
channel: String,
|
|
||||||
id: String,
|
|
||||||
meta_json: String,
|
|
||||||
) -> Result<near::agent::channel_host::PairingUpsertResult, String> {
|
|
||||||
let meta = if meta_json.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
serde_json::from_str(&meta_json).ok()
|
|
||||||
};
|
|
||||||
match self.pairing_store.upsert_request(&channel, &id, meta) {
|
|
||||||
Ok(r) => Ok(near::agent::channel_host::PairingUpsertResult {
|
|
||||||
code: r.code,
|
|
||||||
created: r.created,
|
|
||||||
}),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pairing_is_allowed(
|
|
||||||
&mut self,
|
|
||||||
channel: String,
|
|
||||||
id: String,
|
|
||||||
username: Option<String>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
self.pairing_store
|
|
||||||
.is_sender_allowed(&channel, &id, username.as_deref())
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pairing_read_allow_from(&mut self, channel: String) -> Result<Vec<String>, String> {
|
|
||||||
self.pairing_store
|
|
||||||
.read_allow_from(&channel)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A WASM-based channel implementing the Channel trait.
|
/// A WASM-based channel implementing the Channel trait.
|
||||||
@@ -544,9 +424,6 @@ pub struct WasmChannel {
|
|||||||
/// Background task that repeats typing indicators every 4 seconds.
|
/// Background task that repeats typing indicators every 4 seconds.
|
||||||
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
||||||
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
|
||||||
/// Pairing store for DM pairing (guest access control).
|
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmChannel {
|
impl WasmChannel {
|
||||||
@@ -556,7 +433,6 @@ impl WasmChannel {
|
|||||||
prepared: Arc<PreparedChannelModule>,
|
prepared: Arc<PreparedChannelModule>,
|
||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
config_json: String,
|
config_json: String,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let name = prepared.name.clone();
|
let name = prepared.name.clone();
|
||||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||||
@@ -576,7 +452,6 @@ impl WasmChannel {
|
|||||||
endpoints: RwLock::new(Vec::new()),
|
endpoints: RwLock::new(Vec::new()),
|
||||||
credentials: Arc::new(RwLock::new(HashMap::new())),
|
credentials: Arc::new(RwLock::new(HashMap::new())),
|
||||||
typing_task: RwLock::new(None),
|
typing_task: RwLock::new(None),
|
||||||
pairing_store,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +533,6 @@ impl WasmChannel {
|
|||||||
prepared: &PreparedChannelModule,
|
prepared: &PreparedChannelModule,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||||
let engine = runtime.engine();
|
let engine = runtime.engine();
|
||||||
let limits = &prepared.limits;
|
let limits = &prepared.limits;
|
||||||
@@ -669,7 +543,6 @@ impl WasmChannel {
|
|||||||
&prepared.name,
|
&prepared.name,
|
||||||
capabilities.clone(),
|
capabilities.clone(),
|
||||||
credentials,
|
credentials,
|
||||||
pairing_store,
|
|
||||||
);
|
);
|
||||||
let mut store = Store::new(engine, store_data);
|
let mut store = Store::new(engine, store_data);
|
||||||
|
|
||||||
@@ -770,18 +643,12 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
|
|
||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_start using the generated typed interface
|
// Call on_start using the generated typed interface
|
||||||
@@ -814,21 +681,7 @@ impl WasmChannel {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok((config, mut host_state))) => {
|
Ok(Ok((config, _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!(
|
tracing::info!(
|
||||||
channel = %self.name,
|
channel = %self.name,
|
||||||
display_name = %config.display_name,
|
display_name = %config.display_name,
|
||||||
@@ -900,7 +753,6 @@ impl WasmChannel {
|
|||||||
let capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
|
|
||||||
// Prepare request data
|
// Prepare request data
|
||||||
let method = method.to_string();
|
let method = method.to_string();
|
||||||
@@ -914,13 +766,8 @@ impl WasmChannel {
|
|||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Build the WIT request type
|
// Build the WIT request type
|
||||||
@@ -993,18 +840,12 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
|
|
||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
@@ -1088,7 +929,6 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
|
|
||||||
// Prepare response data
|
// Prepare response data
|
||||||
let message_id_str = message_id.to_string();
|
let message_id_str = message_id.to_string();
|
||||||
@@ -1102,13 +942,8 @@ impl WasmChannel {
|
|||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
tracing::info!("Creating WASM store for on_respond");
|
tracing::info!("Creating WASM store for on_respond");
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
tracing::info!("Instantiating WASM component for on_respond");
|
tracing::info!("Instantiating WASM component for on_respond");
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1201,19 +1036,13 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
let credentials = self.get_credentials().await;
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
|
|
||||||
let wit_update = status_to_wit(status, metadata);
|
let wit_update = status_to_wit(status, metadata);
|
||||||
|
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
@@ -1251,14 +1080,12 @@ impl WasmChannel {
|
|||||||
///
|
///
|
||||||
/// Static method for use by the background typing repeat task (which
|
/// Static method for use by the background typing repeat task (which
|
||||||
/// doesn't have access to `&self`).
|
/// doesn't have access to `&self`).
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn execute_status(
|
async fn execute_status(
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
runtime: &Arc<WasmChannelRuntime>,
|
runtime: &Arc<WasmChannelRuntime>,
|
||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
wit_update: wit_channel::StatusUpdate,
|
wit_update: wit_channel::StatusUpdate,
|
||||||
) -> Result<(), WasmChannelError> {
|
) -> Result<(), WasmChannelError> {
|
||||||
@@ -1274,13 +1101,8 @@ impl WasmChannel {
|
|||||||
|
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials_snapshot,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
@@ -1348,7 +1170,6 @@ impl WasmChannel {
|
|||||||
let prepared = Arc::clone(&self.prepared);
|
let prepared = Arc::clone(&self.prepared);
|
||||||
let capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let credentials = self.credentials.clone();
|
let credentials = self.credentials.clone();
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
let wit_update = status_to_wit(&status, metadata);
|
let wit_update = status_to_wit(&status, metadata);
|
||||||
|
|
||||||
@@ -1368,7 +1189,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
pairing_store.clone(),
|
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
wit_update_clone,
|
wit_update_clone,
|
||||||
)
|
)
|
||||||
@@ -1499,7 +1319,6 @@ impl WasmChannel {
|
|||||||
let message_tx = self.message_tx.clone();
|
let message_tx = self.message_tx.clone();
|
||||||
let rate_limiter = self.rate_limiter.clone();
|
let rate_limiter = self.rate_limiter.clone();
|
||||||
let credentials = self.credentials.clone();
|
let credentials = self.credentials.clone();
|
||||||
let pairing_store = self.pairing_store.clone();
|
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1521,15 +1340,14 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
pairing_store.clone(),
|
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(emitted_messages) => {
|
Ok(emitted_messages) => {
|
||||||
// Process any emitted messages
|
// Process any emitted messages
|
||||||
if !emitted_messages.is_empty()
|
if !emitted_messages.is_empty() {
|
||||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
if let Err(e) = Self::dispatch_emitted_messages(
|
||||||
&channel_name,
|
&channel_name,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
&message_tx,
|
&message_tx,
|
||||||
@@ -1541,6 +1359,7 @@ impl WasmChannel {
|
|||||||
"Failed to dispatch emitted messages from poll"
|
"Failed to dispatch emitted messages from poll"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1572,7 +1391,6 @@ impl WasmChannel {
|
|||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
||||||
// Skip if no WASM bytes (testing mode)
|
// Skip if no WASM bytes (testing mode)
|
||||||
@@ -1593,13 +1411,8 @@ impl WasmChannel {
|
|||||||
// Execute in blocking task with timeout
|
// Execute in blocking task with timeout
|
||||||
let result = tokio::time::timeout(timeout, async move {
|
let result = tokio::time::timeout(timeout, async move {
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut store = Self::create_store(
|
let mut store =
|
||||||
&runtime,
|
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
||||||
&prepared,
|
|
||||||
&capabilities,
|
|
||||||
credentials_snapshot,
|
|
||||||
pairing_store,
|
|
||||||
)?;
|
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
@@ -1770,22 +1583,22 @@ impl Channel for WasmChannel {
|
|||||||
*self.endpoints.write().await = endpoints;
|
*self.endpoints.write().await = endpoints;
|
||||||
|
|
||||||
// Start polling if configured
|
// Start polling if configured
|
||||||
if let Some(poll_config) = &config.poll
|
if let Some(poll_config) = &config.poll {
|
||||||
&& poll_config.enabled
|
if poll_config.enabled {
|
||||||
{
|
let interval = self
|
||||||
let interval = self
|
.capabilities
|
||||||
.capabilities
|
.validate_poll_interval(poll_config.interval_ms)
|
||||||
.validate_poll_interval(poll_config.interval_ms)
|
.map_err(|e| ChannelError::StartupFailed {
|
||||||
.map_err(|e| ChannelError::StartupFailed {
|
name: self.name.clone(),
|
||||||
name: self.name.clone(),
|
reason: e,
|
||||||
reason: e,
|
})?;
|
||||||
})?;
|
|
||||||
|
|
||||||
// Create shutdown channel for polling and store the sender to keep it alive
|
// Create shutdown channel for polling and store the sender to keep it alive
|
||||||
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
||||||
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
|
*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!(
|
tracing::info!(
|
||||||
@@ -2045,29 +1858,6 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
|||||||
message: format!("Approval needed: {} - {}", tool_name, description),
|
message: format!("Approval needed: {} - {}", tool_name, description),
|
||||||
metadata_json,
|
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2139,7 +1929,6 @@ mod tests {
|
|||||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||||
};
|
};
|
||||||
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
||||||
use crate::pairing::PairingStore;
|
|
||||||
use crate::tools::wasm::ResourceLimits;
|
use crate::tools::wasm::ResourceLimits;
|
||||||
|
|
||||||
fn create_test_channel() -> WasmChannel {
|
fn create_test_channel() -> WasmChannel {
|
||||||
@@ -2155,13 +1944,7 @@ mod tests {
|
|||||||
|
|
||||||
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
||||||
|
|
||||||
WasmChannel::new(
|
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
|
||||||
runtime,
|
|
||||||
prepared,
|
|
||||||
capabilities,
|
|
||||||
"{}".to_string(),
|
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2236,7 +2019,6 @@ mod tests {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
timeout,
|
timeout,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -2330,13 +2112,7 @@ mod tests {
|
|||||||
.with_path("/webhook/poll")
|
.with_path("/webhook/poll")
|
||||||
.with_polling(1000);
|
.with_polling(1000);
|
||||||
|
|
||||||
let channel = WasmChannel::new(
|
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
|
||||||
runtime,
|
|
||||||
prepared,
|
|
||||||
capabilities,
|
|
||||||
"{}".to_string(),
|
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start the channel
|
// Start the channel
|
||||||
let _stream = channel.start().await.expect("Channel should start");
|
let _stream = channel.start().await.expect("Channel should start");
|
||||||
@@ -2574,126 +2350,4 @@ mod tests {
|
|||||||
assert_eq!(cloned.message, "hello");
|
assert_eq!(cloned.message, "hello");
|
||||||
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_redact_credentials_replaces_values() {
|
|
||||||
use super::ChannelStoreData;
|
|
||||||
|
|
||||||
let mut creds = std::collections::HashMap::new();
|
|
||||||
creds.insert(
|
|
||||||
"TELEGRAM_BOT_TOKEN".to_string(),
|
|
||||||
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
|
|
||||||
);
|
|
||||||
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
|
||||||
|
|
||||||
let store = ChannelStoreData::new(
|
|
||||||
1024 * 1024,
|
|
||||||
"test",
|
|
||||||
ChannelCapabilities::default(),
|
|
||||||
creds,
|
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = "HTTP request failed: error sending request for url \
|
|
||||||
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
|
|
||||||
|
|
||||||
let redacted = store.redact_credentials(error);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
|
|
||||||
"credential value should be redacted"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"),
|
|
||||||
"redacted text should contain placeholder name"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!redacted.contains("s3cret"),
|
|
||||||
"other credentials should also be redacted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_redact_credentials_no_op_without_credentials() {
|
|
||||||
use super::ChannelStoreData;
|
|
||||||
|
|
||||||
let store = ChannelStoreData::new(
|
|
||||||
1024 * 1024,
|
|
||||||
"test",
|
|
||||||
ChannelCapabilities::default(),
|
|
||||||
std::collections::HashMap::new(),
|
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "some error message";
|
|
||||||
assert_eq!(store.redact_credentials(input), input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_redact_credentials_skips_empty_values() {
|
|
||||||
use super::ChannelStoreData;
|
|
||||||
|
|
||||||
let mut creds = std::collections::HashMap::new();
|
|
||||||
creds.insert("EMPTY_TOKEN".to_string(), String::new());
|
|
||||||
|
|
||||||
let store = ChannelStoreData::new(
|
|
||||||
1024 * 1024,
|
|
||||||
"test",
|
|
||||||
ChannelCapabilities::default(),
|
|
||||||
creds,
|
|
||||||
Arc::new(PairingStore::new()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let input = "should not match anything";
|
|
||||||
assert_eq!(store.redact_credentials(input), input);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-13
@@ -6,7 +6,6 @@ use axum::{
|
|||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use subtle::ConstantTimeEq;
|
|
||||||
|
|
||||||
/// Shared auth state injected via axum middleware state.
|
/// Shared auth state injected via axum middleware state.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -24,22 +23,24 @@ pub async fn auth_middleware(
|
|||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Try Authorization header first (constant-time comparison)
|
// Try Authorization header first
|
||||||
if let Some(auth_header) = headers.get("authorization")
|
if let Some(auth_header) = headers.get("authorization") {
|
||||||
&& let Ok(value) = auth_header.to_str()
|
if let Ok(value) = auth_header.to_str() {
|
||||||
&& let Some(token) = value.strip_prefix("Bearer ")
|
if let Some(token) = value.strip_prefix("Bearer ") {
|
||||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
if token == auth.token {
|
||||||
{
|
return next.run(request).await;
|
||||||
return next.run(request).await;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
// Fall back to query parameter (for SSE EventSource)
|
||||||
if let Some(query) = request.uri().query() {
|
if let Some(query) = request.uri().query() {
|
||||||
for pair in query.split('&') {
|
for pair in query.split('&') {
|
||||||
if let Some(token) = pair.strip_prefix("token=")
|
if let Some(token) = pair.strip_prefix("token=") {
|
||||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
if token == auth.token {
|
||||||
{
|
return next.run(request).await;
|
||||||
return next.run(request).await;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ use tokio::sync::broadcast;
|
|||||||
use tracing::field::{Field, Visit};
|
use tracing::field::{Field, Visit};
|
||||||
use tracing_subscriber::Layer;
|
use tracing_subscriber::Layer;
|
||||||
|
|
||||||
use crate::safety::LeakDetector;
|
|
||||||
|
|
||||||
/// Maximum number of recent log entries kept for late-joining SSE subscribers.
|
/// Maximum number of recent log entries kept for late-joining SSE subscribers.
|
||||||
const HISTORY_CAP: usize = 500;
|
const HISTORY_CAP: usize = 500;
|
||||||
|
|
||||||
@@ -48,8 +46,6 @@ pub struct LogEntry {
|
|||||||
pub struct LogBroadcaster {
|
pub struct LogBroadcaster {
|
||||||
tx: broadcast::Sender<LogEntry>,
|
tx: broadcast::Sender<LogEntry>,
|
||||||
recent: Mutex<VecDeque<LogEntry>>,
|
recent: Mutex<VecDeque<LogEntry>>,
|
||||||
/// Scrubs secrets from log messages before broadcasting to SSE clients.
|
|
||||||
leak_detector: LeakDetector,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogBroadcaster {
|
impl LogBroadcaster {
|
||||||
@@ -58,19 +54,10 @@ impl LogBroadcaster {
|
|||||||
Self {
|
Self {
|
||||||
tx,
|
tx,
|
||||||
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
|
recent: Mutex::new(VecDeque::with_capacity(HISTORY_CAP)),
|
||||||
leak_detector: LeakDetector::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, mut entry: LogEntry) {
|
pub fn send(&self, 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)
|
// Stash in ring buffer (for late joiners)
|
||||||
if let Ok(mut buf) = self.recent.lock() {
|
if let Ok(mut buf) = self.recent.lock() {
|
||||||
if buf.len() >= HISTORY_CAP {
|
if buf.len() >= HISTORY_CAP {
|
||||||
@@ -158,9 +145,6 @@ impl Visit for MessageVisitor {
|
|||||||
///
|
///
|
||||||
/// Only forwards DEBUG and above. Attach to the tracing subscriber
|
/// Only forwards DEBUG and above. Attach to the tracing subscriber
|
||||||
/// alongside the existing fmt layer.
|
/// 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 {
|
pub struct WebLogLayer {
|
||||||
broadcaster: Arc<LogBroadcaster>,
|
broadcaster: Arc<LogBroadcaster>,
|
||||||
}
|
}
|
||||||
@@ -194,7 +178,6 @@ impl<S: tracing::Subscriber> Layer<S> for WebLogLayer {
|
|||||||
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
// LeakDetector scrubbing happens inside broadcaster.send()
|
|
||||||
self.broadcaster.send(entry);
|
self.broadcaster.send(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,29 +313,4 @@ mod tests {
|
|||||||
let v = MessageVisitor::new();
|
let v = MessageVisitor::new();
|
||||||
assert_eq!(v.finish(), "");
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-109
@@ -10,13 +10,12 @@
|
|||||||
//! ◄── GET /api/chat/events ── SSE stream
|
//! ◄── GET /api/chat/events ── SSE stream
|
||||||
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
|
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
|
||||||
//! ─── GET /api/memory/* ────► Workspace
|
//! ─── GET /api/memory/* ────► Workspace
|
||||||
//! ─── GET /api/jobs/* ──────► Database
|
//! ─── GET /api/jobs/* ──────► ContextManager
|
||||||
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod log_layer;
|
pub mod log_layer;
|
||||||
pub mod openai_compat;
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
@@ -32,10 +31,9 @@ use tokio_stream::wrappers::ReceiverStream;
|
|||||||
use crate::agent::SessionManager;
|
use crate::agent::SessionManager;
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::config::GatewayConfig;
|
use crate::config::GatewayConfig;
|
||||||
use crate::db::Database;
|
use crate::context::ContextManager;
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::ExtensionManager;
|
||||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
@@ -72,18 +70,14 @@ impl GatewayChannel {
|
|||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
sse: SseManager::new(),
|
sse: SseManager::new(),
|
||||||
workspace: None,
|
workspace: None,
|
||||||
|
context_manager: None,
|
||||||
session_manager: None,
|
session_manager: None,
|
||||||
log_broadcaster: None,
|
log_broadcaster: None,
|
||||||
extension_manager: None,
|
extension_manager: None,
|
||||||
tool_registry: None,
|
tool_registry: None,
|
||||||
store: None,
|
|
||||||
job_manager: None,
|
|
||||||
prompt_queue: None,
|
|
||||||
user_id: config.user_id.clone(),
|
user_id: config.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
|
||||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -99,18 +93,14 @@ impl GatewayChannel {
|
|||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
sse: SseManager::new(),
|
sse: SseManager::new(),
|
||||||
workspace: self.state.workspace.clone(),
|
workspace: self.state.workspace.clone(),
|
||||||
|
context_manager: self.state.context_manager.clone(),
|
||||||
session_manager: self.state.session_manager.clone(),
|
session_manager: self.state.session_manager.clone(),
|
||||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||||
extension_manager: self.state.extension_manager.clone(),
|
extension_manager: self.state.extension_manager.clone(),
|
||||||
tool_registry: self.state.tool_registry.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(),
|
user_id: self.state.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: self.state.ws_tracker.clone(),
|
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);
|
mutate(&mut new_state);
|
||||||
self.state = Arc::new(new_state);
|
self.state = Arc::new(new_state);
|
||||||
@@ -122,6 +112,12 @@ impl GatewayChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inject the context manager for the jobs API.
|
||||||
|
pub fn with_context_manager(mut self, cm: Arc<ContextManager>) -> Self {
|
||||||
|
self.rebuild_state(|s| s.context_manager = Some(cm));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Inject the session manager for thread/session info.
|
/// Inject the session manager for thread/session info.
|
||||||
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
||||||
self.rebuild_state(|s| s.session_manager = Some(sm));
|
self.rebuild_state(|s| s.session_manager = Some(sm));
|
||||||
@@ -146,40 +142,6 @@ impl GatewayChannel {
|
|||||||
self
|
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).
|
/// Get the auth token (for printing to console on startup).
|
||||||
pub fn auth_token(&self) -> &str {
|
pub fn auth_token(&self) -> &str {
|
||||||
&self.auth_token
|
&self.auth_token
|
||||||
@@ -211,7 +173,11 @@ impl Channel for GatewayChannel {
|
|||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
|
let bound_addr =
|
||||||
|
server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
|
||||||
|
|
||||||
|
tracing::info!("Web gateway listening on http://{}", bound_addr);
|
||||||
|
tracing::info!("Auth token: {}", self.auth_token);
|
||||||
|
|
||||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||||
}
|
}
|
||||||
@@ -234,48 +200,17 @@ impl Channel for GatewayChannel {
|
|||||||
async fn send_status(
|
async fn send_status(
|
||||||
&self,
|
&self,
|
||||||
status: StatusUpdate,
|
status: StatusUpdate,
|
||||||
metadata: &serde_json::Value,
|
_metadata: &serde_json::Value,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
let thread_id = metadata
|
|
||||||
.get("thread_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
let event = match status {
|
let event = match status {
|
||||||
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
|
StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg },
|
||||||
message: msg,
|
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name },
|
||||||
thread_id: thread_id.clone(),
|
StatusUpdate::ToolCompleted { name, success } => {
|
||||||
},
|
SseEvent::ToolCompleted { name, success }
|
||||||
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
|
}
|
||||||
name,
|
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview },
|
||||||
thread_id: thread_id.clone(),
|
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content },
|
||||||
},
|
StatusUpdate::Status(msg) => SseEvent::Status { message: msg },
|
||||||
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 {
|
StatusUpdate::ApprovalNeeded {
|
||||||
request_id,
|
request_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
@@ -288,26 +223,6 @@ impl Channel for GatewayChannel {
|
|||||||
parameters: serde_json::to_string_pretty(¶meters)
|
parameters: serde_json::to_string_pretty(¶meters)
|
||||||
.unwrap_or_else(|_| parameters.to_string()),
|
.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);
|
self.state.sse.broadcast(event);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+102
-1553
File diff suppressed because it is too large
Load Diff
+14
-71
@@ -13,15 +13,10 @@ use tokio_stream::wrappers::BroadcastStream;
|
|||||||
|
|
||||||
use crate::channels::web::types::SseEvent;
|
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.
|
/// Manages SSE broadcast to all connected browser tabs.
|
||||||
pub struct SseManager {
|
pub struct SseManager {
|
||||||
tx: broadcast::Sender<SseEvent>,
|
tx: broadcast::Sender<SseEvent>,
|
||||||
connection_count: Arc<AtomicU64>,
|
connection_count: Arc<AtomicU64>,
|
||||||
max_connections: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SseManager {
|
impl SseManager {
|
||||||
@@ -32,7 +27,6 @@ impl SseManager {
|
|||||||
Self {
|
Self {
|
||||||
tx,
|
tx,
|
||||||
connection_count: Arc::new(AtomicU64::new(0)),
|
connection_count: Arc::new(AtomicU64::new(0)),
|
||||||
max_connections: MAX_CONNECTIONS,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,50 +45,25 @@ impl SseManager {
|
|||||||
///
|
///
|
||||||
/// Returns a stream of `SseEvent` values and increments/decrements the
|
/// Returns a stream of `SseEvent` values and increments/decrements the
|
||||||
/// connection counter on creation/drop, just like `subscribe()` does for SSE.
|
/// connection counter on creation/drop, just like `subscribe()` does for SSE.
|
||||||
///
|
pub fn subscribe_raw(&self) -> impl Stream<Item = SseEvent> + Send + 'static + use<> {
|
||||||
/// 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 counter = Arc::clone(&self.connection_count);
|
||||||
let max = self.max_connections;
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
counter
|
|
||||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
|
||||||
if current < max {
|
|
||||||
Some(current + 1)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok()?;
|
|
||||||
let rx = self.tx.subscribe();
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
|
let stream = BroadcastStream::new(rx).filter_map(|result| result.ok());
|
||||||
|
|
||||||
Some(CountedStream {
|
CountedStream {
|
||||||
inner: stream,
|
inner: stream,
|
||||||
counter,
|
counter,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new SSE stream for a client connection.
|
/// Create a new SSE stream for a client connection.
|
||||||
///
|
|
||||||
/// Returns `None` if the maximum connection limit has been reached.
|
|
||||||
pub fn subscribe(
|
pub fn subscribe(
|
||||||
&self,
|
&self,
|
||||||
) -> Option<Sse<impl Stream<Item = Result<Event, Infallible>> + Send + 'static + use<>>> {
|
) -> 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 counter = Arc::clone(&self.connection_count);
|
||||||
let max = self.max_connections;
|
counter.fetch_add(1, Ordering::Relaxed);
|
||||||
counter
|
|
||||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
|
|
||||||
if current < max {
|
|
||||||
Some(current + 1)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok()?;
|
|
||||||
let rx = self.tx.subscribe();
|
let rx = self.tx.subscribe();
|
||||||
|
|
||||||
let stream = BroadcastStream::new(rx)
|
let stream = BroadcastStream::new(rx)
|
||||||
@@ -110,15 +79,7 @@ impl SseManager {
|
|||||||
SseEvent::StreamChunk { .. } => "stream_chunk",
|
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||||
SseEvent::Status { .. } => "status",
|
SseEvent::Status { .. } => "status",
|
||||||
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||||
SseEvent::AuthRequired { .. } => "auth_required",
|
|
||||||
SseEvent::AuthCompleted { .. } => "auth_completed",
|
|
||||||
SseEvent::Error { .. } => "error",
|
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",
|
SseEvent::Heartbeat => "heartbeat",
|
||||||
};
|
};
|
||||||
Ok(Event::default().event(event_type).data(data))
|
Ok(Event::default().event(event_type).data(data))
|
||||||
@@ -130,10 +91,8 @@ impl SseManager {
|
|||||||
counter,
|
counter,
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(
|
Sse::new(counted_stream)
|
||||||
Sse::new(counted_stream)
|
.keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text(""))
|
||||||
.keep_alive(KeepAlive::new().interval(Duration::from_secs(30)).text("")),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +152,13 @@ mod tests {
|
|||||||
|
|
||||||
manager.broadcast(SseEvent::Status {
|
manager.broadcast(SseEvent::Status {
|
||||||
message: "test".to_string(),
|
message: "test".to_string(),
|
||||||
thread_id: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let event = rx.next().await;
|
let event = rx.next().await;
|
||||||
assert!(event.is_some());
|
assert!(event.is_some());
|
||||||
let event = event.unwrap().unwrap();
|
let event = event.unwrap().unwrap();
|
||||||
match event {
|
match event {
|
||||||
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
|
SseEvent::Status { message } => assert_eq!(message, "test"),
|
||||||
_ => panic!("unexpected event type"),
|
_ => panic!("unexpected event type"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,18 +166,17 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_subscribe_raw_receives_events() {
|
async fn test_subscribe_raw_receives_events() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
let mut stream = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut stream = Box::pin(manager.subscribe_raw());
|
||||||
|
|
||||||
assert_eq!(manager.connection_count(), 1);
|
assert_eq!(manager.connection_count(), 1);
|
||||||
|
|
||||||
manager.broadcast(SseEvent::Thinking {
|
manager.broadcast(SseEvent::Thinking {
|
||||||
message: "working".to_string(),
|
message: "working".to_string(),
|
||||||
thread_id: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let event = stream.next().await.unwrap();
|
let event = stream.next().await.unwrap();
|
||||||
match event {
|
match event {
|
||||||
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"),
|
SseEvent::Thinking { message } => assert_eq!(message, "working"),
|
||||||
_ => panic!("Expected Thinking event"),
|
_ => panic!("Expected Thinking event"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +185,7 @@ mod tests {
|
|||||||
async fn test_subscribe_raw_decrements_on_drop() {
|
async fn test_subscribe_raw_decrements_on_drop() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
{
|
{
|
||||||
let _stream = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let _stream = Box::pin(manager.subscribe_raw());
|
||||||
assert_eq!(manager.connection_count(), 1);
|
assert_eq!(manager.connection_count(), 1);
|
||||||
}
|
}
|
||||||
// Stream dropped, counter should decrement
|
// Stream dropped, counter should decrement
|
||||||
@@ -238,8 +195,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_subscribe_raw_multiple_subscribers() {
|
async fn test_subscribe_raw_multiple_subscribers() {
|
||||||
let manager = SseManager::new();
|
let manager = SseManager::new();
|
||||||
let mut s1 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut s1 = Box::pin(manager.subscribe_raw());
|
||||||
let mut s2 = Box::pin(manager.subscribe_raw().expect("should subscribe"));
|
let mut s2 = Box::pin(manager.subscribe_raw());
|
||||||
assert_eq!(manager.connection_count(), 2);
|
assert_eq!(manager.connection_count(), 2);
|
||||||
|
|
||||||
manager.broadcast(SseEvent::Heartbeat);
|
manager.broadcast(SseEvent::Heartbeat);
|
||||||
@@ -254,18 +211,4 @@ mod tests {
|
|||||||
drop(s2);
|
drop(s2);
|
||||||
assert_eq!(manager.connection_count(), 0);
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-1388
File diff suppressed because it is too large
Load Diff
@@ -5,28 +5,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IronClaw</title>
|
<title>IronClaw</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<script
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
|
|
||||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Auth Screen -->
|
<!-- Auth Screen -->
|
||||||
<div id="auth-screen">
|
<div id="auth-screen">
|
||||||
<div class="auth-card-login">
|
<h1>IronClaw</h1>
|
||||||
<div class="auth-brand">
|
<div class="auth-form">
|
||||||
<h1>IronClaw</h1>
|
<input type="password" id="token-input" placeholder="Auth token" autofocus>
|
||||||
<p class="auth-tagline">Secure AI Assistant</p>
|
<button onclick="authenticate()">Connect</button>
|
||||||
</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>
|
||||||
|
<div id="auth-error"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main App (hidden until authenticated) -->
|
<!-- Main App (hidden until authenticated) -->
|
||||||
@@ -37,33 +26,16 @@
|
|||||||
<button data-tab="memory">Memory</button>
|
<button data-tab="memory">Memory</button>
|
||||||
<button data-tab="jobs">Jobs</button>
|
<button data-tab="jobs">Jobs</button>
|
||||||
<button data-tab="logs">Logs</button>
|
<button data-tab="logs">Logs</button>
|
||||||
<button data-tab="routines">Routines</button>
|
|
||||||
<button data-tab="extensions">Extensions</button>
|
<button data-tab="extensions">Extensions</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="status" id="gateway-status-trigger">
|
<div class="status">
|
||||||
<div class="dot" id="sse-dot"></div>
|
<div class="dot" id="sse-dot"></div>
|
||||||
<span id="sse-status">Connected</span>
|
<span id="sse-status">Connected</span>
|
||||||
<div class="gateway-popover" id="gateway-popover"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chat Tab -->
|
<!-- Chat Tab -->
|
||||||
<div class="tab-panel active" id="tab-chat">
|
<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-container">
|
||||||
<div class="chat-messages" id="chat-messages"></div>
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
<div class="chat-status" id="chat-status"></div>
|
<div class="chat-status" id="chat-status"></div>
|
||||||
@@ -84,20 +56,10 @@
|
|||||||
<div class="memory-tree" id="memory-tree"></div>
|
<div class="memory-tree" id="memory-tree"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="memory-content">
|
<div class="memory-content">
|
||||||
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
<div class="memory-breadcrumb" id="memory-breadcrumb">workspace /</div>
|
||||||
<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="memory-viewer" id="memory-viewer">
|
||||||
<div class="empty">Select a file to view its contents</div>
|
<div class="empty">Select a file to view its contents</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +73,6 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Source</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -143,48 +104,9 @@
|
|||||||
</div>
|
</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 -->
|
<!-- Extensions Tab -->
|
||||||
<div class="tab-panel" id="tab-extensions">
|
<div class="tab-panel" id="tab-extensions">
|
||||||
<div class="extensions-container">
|
<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">
|
<div class="extensions-section">
|
||||||
<h3>Installed Extensions</h3>
|
<h3>Installed Extensions</h3>
|
||||||
<div class="extensions-list" id="extensions-list">
|
<div class="extensions-list" id="extensions-list">
|
||||||
@@ -208,7 +130,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toasts"></div>
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+10
-414
@@ -24,17 +24,10 @@ pub struct ThreadInfo {
|
|||||||
pub turn_count: usize,
|
pub turn_count: usize,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ThreadListResponse {
|
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 threads: Vec<ThreadInfo>,
|
||||||
pub active_thread: Option<Uuid>,
|
pub active_thread: Option<Uuid>,
|
||||||
}
|
}
|
||||||
@@ -61,12 +54,6 @@ pub struct ToolCallInfo {
|
|||||||
pub struct HistoryResponse {
|
pub struct HistoryResponse {
|
||||||
pub thread_id: Uuid,
|
pub thread_id: Uuid,
|
||||||
pub turns: Vec<TurnInfo>,
|
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 ---
|
// --- Approval ---
|
||||||
@@ -76,8 +63,6 @@ pub struct ApprovalRequest {
|
|||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
/// "approve", "always", or "deny"
|
/// "approve", "always", or "deny"
|
||||||
pub action: String,
|
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 ---
|
// --- SSE Event Types ---
|
||||||
@@ -88,49 +73,17 @@ pub enum SseEvent {
|
|||||||
#[serde(rename = "response")]
|
#[serde(rename = "response")]
|
||||||
Response { content: String, thread_id: String },
|
Response { content: String, thread_id: String },
|
||||||
#[serde(rename = "thinking")]
|
#[serde(rename = "thinking")]
|
||||||
Thinking {
|
Thinking { message: String },
|
||||||
message: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "tool_started")]
|
#[serde(rename = "tool_started")]
|
||||||
ToolStarted {
|
ToolStarted { name: String },
|
||||||
name: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "tool_completed")]
|
#[serde(rename = "tool_completed")]
|
||||||
ToolCompleted {
|
ToolCompleted { name: String, success: bool },
|
||||||
name: String,
|
|
||||||
success: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "tool_result")]
|
#[serde(rename = "tool_result")]
|
||||||
ToolResult {
|
ToolResult { name: String, preview: String },
|
||||||
name: String,
|
|
||||||
preview: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "stream_chunk")]
|
#[serde(rename = "stream_chunk")]
|
||||||
StreamChunk {
|
StreamChunk { content: String },
|
||||||
content: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "status")]
|
#[serde(rename = "status")]
|
||||||
Status {
|
Status { message: String },
|
||||||
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")]
|
#[serde(rename = "approval_needed")]
|
||||||
ApprovalNeeded {
|
ApprovalNeeded {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -138,59 +91,10 @@ pub enum SseEvent {
|
|||||||
description: String,
|
description: String,
|
||||||
parameters: 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")]
|
#[serde(rename = "error")]
|
||||||
Error {
|
Error { message: String },
|
||||||
message: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "heartbeat")]
|
#[serde(rename = "heartbeat")]
|
||||||
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 ---
|
// --- Memory ---
|
||||||
@@ -284,54 +188,6 @@ pub struct JobSummaryResponse {
|
|||||||
pub stuck: 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 ---
|
// --- Extensions ---
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -406,21 +262,6 @@ impl ActionResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 ---
|
// --- WebSocket ---
|
||||||
|
|
||||||
/// Message sent by a WebSocket client to the server.
|
/// Message sent by a WebSocket client to the server.
|
||||||
@@ -439,18 +280,7 @@ pub enum WsClientMessage {
|
|||||||
request_id: String,
|
request_id: String,
|
||||||
/// "approve", "always", or "deny"
|
/// "approve", "always", or "deny"
|
||||||
action: String,
|
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.
|
/// Client heartbeat ping.
|
||||||
#[serde(rename = "ping")]
|
#[serde(rename = "ping")]
|
||||||
Ping,
|
Ping,
|
||||||
@@ -484,20 +314,12 @@ impl WsServerMessage {
|
|||||||
SseEvent::Thinking { .. } => "thinking",
|
SseEvent::Thinking { .. } => "thinking",
|
||||||
SseEvent::ToolStarted { .. } => "tool_started",
|
SseEvent::ToolStarted { .. } => "tool_started",
|
||||||
SseEvent::ToolCompleted { .. } => "tool_completed",
|
SseEvent::ToolCompleted { .. } => "tool_completed",
|
||||||
SseEvent::ToolResult { .. } => "tool_result",
|
|
||||||
SseEvent::StreamChunk { .. } => "stream_chunk",
|
SseEvent::StreamChunk { .. } => "stream_chunk",
|
||||||
SseEvent::Status { .. } => "status",
|
SseEvent::Status { .. } => "status",
|
||||||
SseEvent::JobStarted { .. } => "job_started",
|
|
||||||
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
SseEvent::ApprovalNeeded { .. } => "approval_needed",
|
||||||
SseEvent::AuthRequired { .. } => "auth_required",
|
|
||||||
SseEvent::AuthCompleted { .. } => "auth_completed",
|
|
||||||
SseEvent::Error { .. } => "error",
|
SseEvent::Error { .. } => "error",
|
||||||
|
SseEvent::ToolResult { .. } => "tool_result",
|
||||||
SseEvent::Heartbeat => "heartbeat",
|
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);
|
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||||
WsServerMessage::Event {
|
WsServerMessage::Event {
|
||||||
@@ -507,96 +329,6 @@ impl WsServerMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 ---
|
// --- Health ---
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -639,36 +371,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ws_client_approval_parse() {
|
fn test_ws_client_approval_parse() {
|
||||||
let json =
|
let json = r#"{"type":"approval","request_id":"abc-123","action":"approve"}"#;
|
||||||
r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#;
|
|
||||||
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
WsClientMessage::Approval {
|
WsClientMessage::Approval { request_id, action } => {
|
||||||
request_id,
|
|
||||||
action,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
assert_eq!(request_id, "abc-123");
|
assert_eq!(request_id, "abc-123");
|
||||||
assert_eq!(action, "approve");
|
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"),
|
_ => panic!("Expected Approval variant"),
|
||||||
}
|
}
|
||||||
@@ -729,7 +437,6 @@ mod tests {
|
|||||||
fn test_ws_server_from_sse_thinking() {
|
fn test_ws_server_from_sse_thinking() {
|
||||||
let sse = SseEvent::Thinking {
|
let sse = SseEvent::Thinking {
|
||||||
message: "reasoning...".to_string(),
|
message: "reasoning...".to_string(),
|
||||||
thread_id: None,
|
|
||||||
};
|
};
|
||||||
let ws = WsServerMessage::from_sse_event(&sse);
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
match ws {
|
match ws {
|
||||||
@@ -770,115 +477,4 @@ mod tests {
|
|||||||
_ => panic!("Expected Event variant"),
|
_ => 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-86
@@ -71,17 +71,8 @@ pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
|
|||||||
}
|
}
|
||||||
let tracker_for_drop = state.ws_tracker.clone();
|
let tracker_for_drop = state.ws_tracker.clone();
|
||||||
|
|
||||||
// Subscribe to broadcast events (same source as SSE).
|
// Subscribe to broadcast events (same source as SSE)
|
||||||
// Reject if we've hit the connection limit.
|
let mut event_stream = Box::pin(state.sse.subscribe_raw());
|
||||||
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
|
// Channel for the sender task to receive messages from both
|
||||||
// the broadcast stream and any direct sends (like Pong)
|
// the broadcast stream and any direct sends (like Pong)
|
||||||
@@ -179,11 +170,7 @@ async fn handle_client_message(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WsClientMessage::Approval {
|
WsClientMessage::Approval { request_id, action } => {
|
||||||
request_id,
|
|
||||||
action,
|
|
||||||
thread_id,
|
|
||||||
} => {
|
|
||||||
let (approved, always) = match action.as_str() {
|
let (approved, always) = match action.as_str() {
|
||||||
"approve" => (true, false),
|
"approve" => (true, false),
|
||||||
"always" => (true, true),
|
"always" => (true, true),
|
||||||
@@ -227,71 +214,12 @@ async fn handle_client_message(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", user_id, content);
|
let 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;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
if let Some(ref tx) = *tx_guard {
|
if let Some(ref tx) = *tx_guard {
|
||||||
let _ = tx.send(msg).await;
|
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 => {
|
WsClientMessage::Ping => {
|
||||||
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
let _ = direct_tx.send(WsServerMessage::Pong).await;
|
||||||
}
|
}
|
||||||
@@ -400,7 +328,6 @@ mod tests {
|
|||||||
WsClientMessage::Approval {
|
WsClientMessage::Approval {
|
||||||
request_id: request_id.to_string(),
|
request_id: request_id.to_string(),
|
||||||
action: "approve".to_string(),
|
action: "approve".to_string(),
|
||||||
thread_id: Some("thread-42".to_string()),
|
|
||||||
},
|
},
|
||||||
&state,
|
&state,
|
||||||
"user1",
|
"user1",
|
||||||
@@ -411,8 +338,6 @@ mod tests {
|
|||||||
let incoming = agent_rx.recv().await.unwrap();
|
let incoming = agent_rx.recv().await.unwrap();
|
||||||
// The content should be a serialized ExecApproval
|
// The content should be a serialized ExecApproval
|
||||||
assert!(incoming.content.contains("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]
|
#[tokio::test]
|
||||||
@@ -424,7 +349,6 @@ mod tests {
|
|||||||
WsClientMessage::Approval {
|
WsClientMessage::Approval {
|
||||||
request_id: Uuid::new_v4().to_string(),
|
request_id: Uuid::new_v4().to_string(),
|
||||||
action: "maybe".to_string(),
|
action: "maybe".to_string(),
|
||||||
thread_id: None,
|
|
||||||
},
|
},
|
||||||
&state,
|
&state,
|
||||||
"user1",
|
"user1",
|
||||||
@@ -450,7 +374,6 @@ mod tests {
|
|||||||
WsClientMessage::Approval {
|
WsClientMessage::Approval {
|
||||||
request_id: "not-a-uuid".to_string(),
|
request_id: "not-a-uuid".to_string(),
|
||||||
action: "approve".to_string(),
|
action: "approve".to_string(),
|
||||||
thread_id: None,
|
|
||||||
},
|
},
|
||||||
&state,
|
&state,
|
||||||
"user1",
|
"user1",
|
||||||
@@ -475,18 +398,14 @@ mod tests {
|
|||||||
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
msg_tx: tokio::sync::RwLock::new(msg_tx),
|
||||||
sse: SseManager::new(),
|
sse: SseManager::new(),
|
||||||
workspace: None,
|
workspace: None,
|
||||||
|
context_manager: None,
|
||||||
session_manager: None,
|
session_manager: None,
|
||||||
log_broadcaster: None,
|
log_broadcaster: None,
|
||||||
extension_manager: None,
|
extension_manager: None,
|
||||||
tool_registry: None,
|
tool_registry: None,
|
||||||
store: None,
|
|
||||||
job_manager: None,
|
|
||||||
prompt_queue: None,
|
|
||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
|
||||||
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-96
@@ -1,9 +1,6 @@
|
|||||||
//! Configuration management CLI commands.
|
//! Configuration management CLI commands.
|
||||||
//!
|
//!
|
||||||
//! Commands for viewing and modifying settings.
|
//! Commands for viewing and modifying settings.
|
||||||
//! Settings are stored in the database (env > DB > default).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
|
|
||||||
@@ -39,81 +36,41 @@ pub enum ConfigCommand {
|
|||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show the settings storage info
|
/// Show the settings file path
|
||||||
Path,
|
Path,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a config command.
|
/// Run a config command.
|
||||||
///
|
pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
|
||||||
/// Connects to the database to read/write settings. Falls back to disk
|
|
||||||
/// if the database is not available.
|
|
||||||
pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
|
|
||||||
// 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 {
|
match cmd {
|
||||||
ConfigCommand::List { filter } => list_settings(db_ref, filter).await,
|
ConfigCommand::List { filter } => list_settings(filter),
|
||||||
ConfigCommand::Get { path } => get_setting(db_ref, &path).await,
|
ConfigCommand::Get { path } => get_setting(&path),
|
||||||
ConfigCommand::Set { path, value } => set_setting(db_ref, &path, &value).await,
|
ConfigCommand::Set { path, value } => set_setting(&path, &value),
|
||||||
ConfigCommand::Reset { path } => reset_setting(db_ref, &path).await,
|
ConfigCommand::Reset { path } => reset_setting(&path),
|
||||||
ConfigCommand::Path => show_path(db_ref.is_some()),
|
ConfigCommand::Path => show_path(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
/// List all settings.
|
||||||
async fn list_settings(
|
fn list_settings(filter: Option<String>) -> anyhow::Result<()> {
|
||||||
store: Option<&dyn crate::db::Database>,
|
let settings = Settings::load();
|
||||||
filter: Option<String>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let settings = load_settings(store).await;
|
|
||||||
let all = settings.list();
|
let all = settings.list();
|
||||||
|
|
||||||
|
// Find the longest key for alignment
|
||||||
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||||
|
|
||||||
let source = if store.is_some() { "database" } else { "disk" };
|
println!("Settings:");
|
||||||
println!("Settings (source: {}):", source);
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for (key, value) in all {
|
for (key, value) in all {
|
||||||
if let Some(ref f) = filter
|
// Skip if filter is set and doesn't match
|
||||||
&& !key.starts_with(f)
|
if let Some(ref f) = filter {
|
||||||
{
|
if !key.starts_with(f) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Truncate long values for display
|
||||||
let display_value = if value.len() > 60 {
|
let display_value = if value.len() > 60 {
|
||||||
format!("{}...", &value[..57])
|
format!("{}...", &value[..57])
|
||||||
} else {
|
} else {
|
||||||
@@ -127,8 +84,8 @@ async fn list_settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific setting.
|
/// Get a specific setting.
|
||||||
async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
|
fn get_setting(path: &str) -> anyhow::Result<()> {
|
||||||
let settings = load_settings(store).await;
|
let settings = Settings::load();
|
||||||
|
|
||||||
match settings.get(path) {
|
match settings.get(path) {
|
||||||
Some(value) => {
|
Some(value) => {
|
||||||
@@ -142,63 +99,68 @@ async fn get_setting(store: Option<&dyn crate::db::Database>, path: &str) -> any
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set a setting value.
|
/// Set a setting value.
|
||||||
async fn set_setting(
|
fn set_setting(path: &str, value: &str) -> anyhow::Result<()> {
|
||||||
store: Option<&dyn crate::db::Database>,
|
let mut settings = Settings::load();
|
||||||
path: &str,
|
|
||||||
value: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let mut settings = load_settings(store).await;
|
|
||||||
|
|
||||||
|
// Try to set the value
|
||||||
settings
|
settings
|
||||||
.set(path, value)
|
.set(path, value)
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
let store = store.ok_or_else(|| {
|
// Save to disk
|
||||||
anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.")
|
settings.save()?;
|
||||||
})?;
|
|
||||||
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);
|
println!("Set {} = {}", path, value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset a setting to default.
|
/// Reset a setting to default.
|
||||||
async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> anyhow::Result<()> {
|
fn reset_setting(path: &str) -> anyhow::Result<()> {
|
||||||
|
let mut settings = Settings::load();
|
||||||
|
|
||||||
|
// Get the default value for display
|
||||||
let default = Settings::default();
|
let default = Settings::default();
|
||||||
let default_value = default
|
let default_value = default
|
||||||
.get(path)
|
.get(path)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
||||||
|
|
||||||
let store = store.ok_or_else(|| {
|
// Reset it
|
||||||
anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.")
|
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
})?;
|
|
||||||
store
|
// Save to disk
|
||||||
.delete_setting(DEFAULT_USER_ID, path)
|
settings.save()?;
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
|
|
||||||
|
|
||||||
println!("Reset {} to default: {}", path, default_value);
|
println!("Reset {} to default: {}", path, default_value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the settings storage info.
|
/// Show the settings file path.
|
||||||
fn show_path(has_db: bool) -> anyhow::Result<()> {
|
fn show_path() -> anyhow::Result<()> {
|
||||||
if has_db {
|
let path = Settings::default_path();
|
||||||
println!("Settings stored in: database (settings table)");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("Settings stored in: PostgreSQL (not connected, using defaults)");
|
println!(" (does not exist, using defaults)");
|
||||||
}
|
}
|
||||||
println!(
|
|
||||||
"Env config: {}",
|
|
||||||
crate::bootstrap::ironclaw_env_path().display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+745
@@ -0,0 +1,745 @@
|
|||||||
|
//! NEAR key management CLI commands.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use clap::Subcommand;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::history::Store;
|
||||||
|
use crate::keys::KeyManager;
|
||||||
|
use crate::keys::policy::{ChainSigRule, FunctionCallRule, PolicyConfig, SignatureDomain};
|
||||||
|
use crate::keys::types::{
|
||||||
|
AccessKeyPermission, NearAccountId, NearNetwork, format_yocto, parse_near_amount,
|
||||||
|
};
|
||||||
|
use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||||
|
|
||||||
|
/// Default policy config path.
|
||||||
|
fn default_policy_path() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".ironclaw").join("key_policy.json"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".ironclaw/key_policy.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum KeyCommand {
|
||||||
|
/// Generate a new ed25519 keypair
|
||||||
|
Generate {
|
||||||
|
/// Label for the key (used to reference it later)
|
||||||
|
label: String,
|
||||||
|
|
||||||
|
/// NEAR account ID this key belongs to
|
||||||
|
#[arg(long)]
|
||||||
|
account: String,
|
||||||
|
|
||||||
|
/// Permission level: "full-access" or "function-call"
|
||||||
|
#[arg(long, default_value = "function-call")]
|
||||||
|
permission: String,
|
||||||
|
|
||||||
|
/// Contract to scope function-call keys to
|
||||||
|
#[arg(long)]
|
||||||
|
receiver: Option<String>,
|
||||||
|
|
||||||
|
/// Comma-separated method names (empty = all methods on contract)
|
||||||
|
#[arg(long)]
|
||||||
|
methods: Option<String>,
|
||||||
|
|
||||||
|
/// Allowance in NEAR (e.g., "1.5")
|
||||||
|
#[arg(long)]
|
||||||
|
allowance: Option<String>,
|
||||||
|
|
||||||
|
/// Network: mainnet, testnet, or RPC URL
|
||||||
|
#[arg(long, default_value = "testnet")]
|
||||||
|
network: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Import an existing secret key
|
||||||
|
Import {
|
||||||
|
/// Label for the key
|
||||||
|
label: String,
|
||||||
|
|
||||||
|
/// NEAR account ID
|
||||||
|
#[arg(long)]
|
||||||
|
account: String,
|
||||||
|
|
||||||
|
/// Permission level
|
||||||
|
#[arg(long, default_value = "function-call")]
|
||||||
|
permission: String,
|
||||||
|
|
||||||
|
/// Contract to scope function-call keys to
|
||||||
|
#[arg(long)]
|
||||||
|
receiver: Option<String>,
|
||||||
|
|
||||||
|
/// Comma-separated method names
|
||||||
|
#[arg(long)]
|
||||||
|
methods: Option<String>,
|
||||||
|
|
||||||
|
/// Allowance in NEAR
|
||||||
|
#[arg(long)]
|
||||||
|
allowance: Option<String>,
|
||||||
|
|
||||||
|
/// Network
|
||||||
|
#[arg(long, default_value = "testnet")]
|
||||||
|
network: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List all stored keys
|
||||||
|
List {
|
||||||
|
/// Show verbose details
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show information about a key
|
||||||
|
Info {
|
||||||
|
/// Key label
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a key
|
||||||
|
Remove {
|
||||||
|
/// Key label
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Export public key (NEVER exports private key)
|
||||||
|
Export {
|
||||||
|
/// Key label
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Manage transaction approval policy
|
||||||
|
#[command(subcommand)]
|
||||||
|
Policy(PolicyCommand),
|
||||||
|
|
||||||
|
/// Create encrypted backup of all keys
|
||||||
|
Backup {
|
||||||
|
/// Output file path
|
||||||
|
#[arg(long)]
|
||||||
|
output: PathBuf,
|
||||||
|
|
||||||
|
/// List keys in a backup without restoring (still needs passphrase)
|
||||||
|
#[arg(long)]
|
||||||
|
list: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Restore keys from encrypted backup
|
||||||
|
Restore {
|
||||||
|
/// Backup file path
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum PolicyCommand {
|
||||||
|
/// Show current policy configuration
|
||||||
|
Show,
|
||||||
|
|
||||||
|
/// Set auto-approve transfer limit
|
||||||
|
SetTransferLimit {
|
||||||
|
/// Max NEAR amount for auto-approved transfers (e.g., "1.5")
|
||||||
|
amount: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Whitelist an account for transfers
|
||||||
|
WhitelistAccount {
|
||||||
|
/// Account ID to whitelist
|
||||||
|
account: String,
|
||||||
|
|
||||||
|
/// Max transfer amount in NEAR
|
||||||
|
#[arg(long)]
|
||||||
|
max_transfer: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Whitelist a validator for staking
|
||||||
|
WhitelistValidator {
|
||||||
|
/// Validator account ID
|
||||||
|
validator: String,
|
||||||
|
|
||||||
|
/// Max stake amount in NEAR
|
||||||
|
#[arg(long)]
|
||||||
|
max_stake: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Add a function call rule for a contract
|
||||||
|
AddContractRule {
|
||||||
|
/// Contract account ID
|
||||||
|
contract: String,
|
||||||
|
|
||||||
|
/// Comma-separated method names (empty = all)
|
||||||
|
#[arg(long)]
|
||||||
|
methods: Option<String>,
|
||||||
|
|
||||||
|
/// Max deposit in NEAR
|
||||||
|
#[arg(long, default_value = "0")]
|
||||||
|
max_deposit: String,
|
||||||
|
|
||||||
|
/// Auto-approve matching calls
|
||||||
|
#[arg(long)]
|
||||||
|
auto_approve: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Add a chain signature rule
|
||||||
|
AddChainSigRule {
|
||||||
|
/// Derivation path pattern (supports * glob)
|
||||||
|
path_pattern: String,
|
||||||
|
|
||||||
|
/// Signature domain: secp256k1 or ed25519
|
||||||
|
#[arg(long, default_value = "secp256k1")]
|
||||||
|
domain: String,
|
||||||
|
|
||||||
|
/// Max payload size in bytes
|
||||||
|
#[arg(long, default_value = "4096")]
|
||||||
|
max_payload: usize,
|
||||||
|
|
||||||
|
/// Auto-approve matching requests
|
||||||
|
#[arg(long)]
|
||||||
|
auto_approve: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set daily cumulative spend limit
|
||||||
|
SetDailyLimit {
|
||||||
|
/// Max NEAR amount per day
|
||||||
|
amount: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set per-transaction auto-approve limit
|
||||||
|
SetTxLimit {
|
||||||
|
/// Max NEAR amount per transaction
|
||||||
|
amount: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a key management command.
|
||||||
|
pub async fn run_key_command(cmd: KeyCommand) -> anyhow::Result<()> {
|
||||||
|
match cmd {
|
||||||
|
KeyCommand::Generate {
|
||||||
|
label,
|
||||||
|
account,
|
||||||
|
permission,
|
||||||
|
receiver,
|
||||||
|
methods,
|
||||||
|
allowance,
|
||||||
|
network,
|
||||||
|
} => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
let account_id = NearAccountId::new(&account)?;
|
||||||
|
let network: NearNetwork = network.parse()?;
|
||||||
|
let perm = parse_permission(&permission, receiver, methods, allowance)?;
|
||||||
|
|
||||||
|
let metadata = manager
|
||||||
|
.generate_key(&label, &account_id, perm.clone(), network)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Key generated successfully:");
|
||||||
|
println!(" Label: {}", metadata.label);
|
||||||
|
println!(" Account: {}", metadata.account_id);
|
||||||
|
println!(" Public key: {}", metadata.public_key);
|
||||||
|
println!(" Permission: {}", perm);
|
||||||
|
println!(" Network: {}", metadata.network);
|
||||||
|
|
||||||
|
if matches!(perm, AccessKeyPermission::FullAccess) {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" WARNING: This is a FULL ACCESS key for {}.",
|
||||||
|
metadata.account_id
|
||||||
|
);
|
||||||
|
println!(" If this is the ONLY full-access key for this account and you lose it,");
|
||||||
|
println!(" the account becomes permanently inaccessible.");
|
||||||
|
println!();
|
||||||
|
println!(" Create a backup: ironclaw key backup --output <file>");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Import {
|
||||||
|
label,
|
||||||
|
account,
|
||||||
|
permission,
|
||||||
|
receiver,
|
||||||
|
methods,
|
||||||
|
allowance,
|
||||||
|
network,
|
||||||
|
} => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
let account_id = NearAccountId::new(&account)?;
|
||||||
|
let network: NearNetwork = network.parse()?;
|
||||||
|
let perm = parse_permission(&permission, receiver, methods, allowance)?;
|
||||||
|
|
||||||
|
// Read secret key from stdin (hidden)
|
||||||
|
print!("Paste secret key (ed25519:...): ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let secret_key = read_hidden_line()?;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if secret_key.is_empty() {
|
||||||
|
anyhow::bail!("No secret key provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = manager
|
||||||
|
.import_key(&label, &account_id, &secret_key, perm.clone(), network)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Key imported successfully:");
|
||||||
|
println!(" Label: {}", metadata.label);
|
||||||
|
println!(" Account: {}", metadata.account_id);
|
||||||
|
println!(" Public key: {}", metadata.public_key);
|
||||||
|
println!(" Permission: {}", perm);
|
||||||
|
|
||||||
|
if matches!(perm, AccessKeyPermission::FullAccess) {
|
||||||
|
println!();
|
||||||
|
println!(" WARNING: Full-access key imported. Back it up!");
|
||||||
|
println!(" ironclaw key backup --output <file>");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::List { verbose } => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
let keys = manager.list_keys().await?;
|
||||||
|
|
||||||
|
if keys.is_empty() {
|
||||||
|
println!("No keys stored.");
|
||||||
|
println!("Generate one: ironclaw key generate <label> --account <id>");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Stored keys:");
|
||||||
|
println!();
|
||||||
|
for key in keys {
|
||||||
|
if verbose {
|
||||||
|
println!(" {} ({})", key.label, key.network);
|
||||||
|
println!(" Account: {}", key.account_id);
|
||||||
|
println!(" Public key: {}", key.public_key);
|
||||||
|
println!(" Permission: {}", key.permission);
|
||||||
|
println!(
|
||||||
|
" Created: {}",
|
||||||
|
key.created_at.format("%Y-%m-%d %H:%M UTC")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} | {} | {} | {}",
|
||||||
|
key.label, key.account_id, key.permission, key.network
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Info { label } => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
let key = manager.get_key(&label).await?;
|
||||||
|
|
||||||
|
println!("Key: {}", key.label);
|
||||||
|
println!(" Account: {}", key.account_id);
|
||||||
|
println!(" Public key: {}", key.public_key);
|
||||||
|
println!(" Permission: {}", key.permission);
|
||||||
|
println!(" Network: {}", key.network);
|
||||||
|
println!(
|
||||||
|
" Created: {}",
|
||||||
|
key.created_at.format("%Y-%m-%d %H:%M UTC")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Remove { label } => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
manager.remove_key(&label).await?;
|
||||||
|
println!("Key '{}' removed.", label);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Export { label } => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
let pubkey = manager.export_public_key(&label).await?;
|
||||||
|
println!("{}", pubkey.to_near_format());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Policy(policy_cmd) => run_policy_command(policy_cmd).await,
|
||||||
|
|
||||||
|
KeyCommand::Backup { output, list } => {
|
||||||
|
if list {
|
||||||
|
// List keys in backup
|
||||||
|
let data = fs::read(&output).await?;
|
||||||
|
|
||||||
|
print!("Backup passphrase: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let passphrase = read_hidden_line()?;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// We need to decrypt to list, so restore to a temp manager
|
||||||
|
// and just display, not actually import
|
||||||
|
let plaintext = crate::keys::decrypt_backup(&passphrase, &data)?;
|
||||||
|
let backup: serde_json::Value = serde_json::from_slice(&plaintext)?;
|
||||||
|
|
||||||
|
if let Some(keys) = backup.get("keys").and_then(|k| k.as_array()) {
|
||||||
|
println!("Keys in backup ({}):", output.display());
|
||||||
|
for key in keys {
|
||||||
|
let label = key.get("label").and_then(|l| l.as_str()).unwrap_or("?");
|
||||||
|
let account = key
|
||||||
|
.get("account_id")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
println!(" {} ({})", label, account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
|
||||||
|
print!("Backup passphrase: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let passphrase = read_hidden_line()?;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
print!("Confirm passphrase: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let confirm = read_hidden_line()?;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if passphrase != confirm {
|
||||||
|
anyhow::bail!("Passphrases do not match");
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase.len() < 8 {
|
||||||
|
anyhow::bail!("Passphrase must be at least 8 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_data = manager.create_backup(&passphrase).await?;
|
||||||
|
fs::write(&output, &backup_data).await?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Backup created: {} ({} bytes)",
|
||||||
|
output.display(),
|
||||||
|
backup_data.len()
|
||||||
|
);
|
||||||
|
println!("Store this file securely. You'll need the passphrase to restore.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyCommand::Restore { path } => {
|
||||||
|
let manager = create_key_manager().await?;
|
||||||
|
|
||||||
|
let data = fs::read(&path).await?;
|
||||||
|
|
||||||
|
print!("Backup passphrase: ");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let passphrase = read_hidden_line()?;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let restored = manager.restore_backup(&data, &passphrase).await?;
|
||||||
|
|
||||||
|
if restored.is_empty() {
|
||||||
|
println!("No new keys to restore (all already exist).");
|
||||||
|
} else {
|
||||||
|
println!("Restored {} keys:", restored.len());
|
||||||
|
for label in &restored {
|
||||||
|
println!(" {}", label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_policy_command(cmd: PolicyCommand) -> anyhow::Result<()> {
|
||||||
|
let policy_path = default_policy_path();
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
PolicyCommand::Show => {
|
||||||
|
let policy = load_policy(&policy_path).await?;
|
||||||
|
let json = serde_json::to_string_pretty(&policy)?;
|
||||||
|
println!("{}", json);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::SetTransferLimit { amount } => {
|
||||||
|
let yocto = parse_near_amount(&amount)?;
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
policy.transfer_auto_approve_max_yocto = yocto;
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!("Transfer auto-approve limit set to {}", format_yocto(yocto));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::WhitelistAccount {
|
||||||
|
account,
|
||||||
|
max_transfer,
|
||||||
|
} => {
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
if !policy.transfer_whitelist.contains(&account) {
|
||||||
|
policy.transfer_whitelist.push(account.clone());
|
||||||
|
}
|
||||||
|
if let Some(max) = max_transfer {
|
||||||
|
policy.transfer_whitelist_max_yocto = parse_near_amount(&max)?;
|
||||||
|
}
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!("Account '{}' added to transfer whitelist", account);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::WhitelistValidator {
|
||||||
|
validator,
|
||||||
|
max_stake,
|
||||||
|
} => {
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
if !policy.stake_validator_whitelist.contains(&validator) {
|
||||||
|
policy.stake_validator_whitelist.push(validator.clone());
|
||||||
|
}
|
||||||
|
if let Some(max) = max_stake {
|
||||||
|
policy.stake_auto_approve_max_yocto = parse_near_amount(&max)?;
|
||||||
|
}
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!("Validator '{}' added to staking whitelist", validator);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::AddContractRule {
|
||||||
|
contract,
|
||||||
|
methods,
|
||||||
|
max_deposit,
|
||||||
|
auto_approve,
|
||||||
|
} => {
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
let deposit = parse_near_amount(&max_deposit)?;
|
||||||
|
let method_list = methods
|
||||||
|
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
policy.function_call_rules.push(FunctionCallRule {
|
||||||
|
receiver_id: contract.clone(),
|
||||||
|
allowed_methods: method_list,
|
||||||
|
max_deposit_yocto: deposit,
|
||||||
|
max_gas: None,
|
||||||
|
auto_approve,
|
||||||
|
});
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!(
|
||||||
|
"Contract rule added for '{}' (auto_approve={})",
|
||||||
|
contract, auto_approve
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::AddChainSigRule {
|
||||||
|
path_pattern,
|
||||||
|
domain,
|
||||||
|
max_payload,
|
||||||
|
auto_approve,
|
||||||
|
} => {
|
||||||
|
let domain = match domain.to_lowercase().as_str() {
|
||||||
|
"secp256k1" => SignatureDomain::Secp256k1,
|
||||||
|
"ed25519" => SignatureDomain::Ed25519,
|
||||||
|
other => anyhow::bail!("Unknown domain '{}', expected secp256k1 or ed25519", other),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
policy.chain_sig_rules.push(ChainSigRule {
|
||||||
|
allowed_paths: vec![path_pattern.clone()],
|
||||||
|
allowed_domains: vec![domain],
|
||||||
|
max_payload_bytes: max_payload,
|
||||||
|
auto_approve,
|
||||||
|
});
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!(
|
||||||
|
"Chain signature rule added for '{}' (auto_approve={})",
|
||||||
|
path_pattern, auto_approve
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::SetDailyLimit { amount } => {
|
||||||
|
let yocto = parse_near_amount(&amount)?;
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
policy.daily_spend_limit_yocto = Some(yocto);
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!("Daily spend limit set to {}", format_yocto(yocto));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
PolicyCommand::SetTxLimit { amount } => {
|
||||||
|
let yocto = parse_near_amount(&amount)?;
|
||||||
|
let mut policy = load_policy(&policy_path).await?;
|
||||||
|
policy.per_tx_auto_approve_max_yocto = yocto;
|
||||||
|
save_policy(&policy_path, &policy).await?;
|
||||||
|
println!(
|
||||||
|
"Per-transaction auto-approve limit set to {}",
|
||||||
|
format_yocto(yocto)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_policy(path: &PathBuf) -> anyhow::Result<PolicyConfig> {
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(path).await?;
|
||||||
|
Ok(serde_json::from_str(&content)?)
|
||||||
|
} else {
|
||||||
|
Ok(PolicyConfig::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_policy(path: &PathBuf, policy: &PolicyConfig) -> anyhow::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
let content = serde_json::to_string_pretty(policy)?;
|
||||||
|
fs::write(path, content).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_permission(
|
||||||
|
permission: &str,
|
||||||
|
receiver: Option<String>,
|
||||||
|
methods: Option<String>,
|
||||||
|
allowance: Option<String>,
|
||||||
|
) -> anyhow::Result<AccessKeyPermission> {
|
||||||
|
match permission {
|
||||||
|
"full-access" | "FullAccess" => Ok(AccessKeyPermission::FullAccess),
|
||||||
|
"function-call" | "FunctionCall" => {
|
||||||
|
let receiver_id = receiver
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("--receiver required for function-call keys"))?;
|
||||||
|
|
||||||
|
let method_names = methods
|
||||||
|
.map(|m| m.split(',').map(|s| s.trim().to_string()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let allowance_yocto = allowance
|
||||||
|
.map(|a| parse_near_amount(&a))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid allowance: {}", e))?;
|
||||||
|
|
||||||
|
Ok(AccessKeyPermission::FunctionCall {
|
||||||
|
allowance: allowance_yocto,
|
||||||
|
receiver_id,
|
||||||
|
method_names,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => Err(anyhow::anyhow!(
|
||||||
|
"unknown permission '{}', expected full-access or function-call",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a KeyManager with the default secrets store.
|
||||||
|
async fn create_key_manager() -> anyhow::Result<KeyManager> {
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
|
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<dyn SecretsStore + Send + Sync> =
|
||||||
|
Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
|
||||||
|
|
||||||
|
let manager = KeyManager::new(secrets_store, "default".to_string());
|
||||||
|
|
||||||
|
// Load policy if it exists
|
||||||
|
let policy_path = default_policy_path();
|
||||||
|
if policy_path.exists() {
|
||||||
|
let content = fs::read_to_string(&policy_path).await?;
|
||||||
|
let policy: PolicyConfig = serde_json::from_str(&content)?;
|
||||||
|
Ok(manager.with_policy(policy))
|
||||||
|
} else {
|
||||||
|
Ok(manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a line of input with hidden characters.
|
||||||
|
fn read_hidden_line() -> anyhow::Result<String> {
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyModifiers},
|
||||||
|
terminal,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Event::Key(key_event) = event::read()? {
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Enter => break,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if !input.is_empty() {
|
||||||
|
input.pop();
|
||||||
|
print!("\x08 \x08");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
terminal::disable_raw_mode()?;
|
||||||
|
return Err(anyhow::anyhow!("Interrupted"));
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
input.push(c);
|
||||||
|
print!("*");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal::disable_raw_mode()?;
|
||||||
|
Ok(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::cli::key::parse_permission;
|
||||||
|
use crate::keys::types::AccessKeyPermission;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_full_access() {
|
||||||
|
let perm = parse_permission("full-access", None, None, None).unwrap();
|
||||||
|
assert!(matches!(perm, AccessKeyPermission::FullAccess));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_function_call() {
|
||||||
|
let perm = parse_permission(
|
||||||
|
"function-call",
|
||||||
|
Some("contract.near".to_string()),
|
||||||
|
Some("deposit,withdraw".to_string()),
|
||||||
|
Some("1.5".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match perm {
|
||||||
|
AccessKeyPermission::FunctionCall {
|
||||||
|
receiver_id,
|
||||||
|
method_names,
|
||||||
|
allowance,
|
||||||
|
} => {
|
||||||
|
assert_eq!(receiver_id, "contract.near");
|
||||||
|
assert_eq!(method_names, vec!["deposit", "withdraw"]);
|
||||||
|
assert!(allowance.is_some());
|
||||||
|
}
|
||||||
|
_ => panic!("expected FunctionCall"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_function_call_missing_receiver() {
|
||||||
|
let result = parse_permission("function-call", None, None, None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-117
@@ -8,14 +8,14 @@ use std::sync::Arc;
|
|||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::Database;
|
use crate::history::Store;
|
||||||
#[cfg(feature = "postgres")]
|
use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||||
use crate::secrets::PostgresSecretsStore;
|
|
||||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
|
||||||
use crate::tools::mcp::{
|
use crate::tools::mcp::{
|
||||||
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||||
auth::{authorize_mcp_server, is_authenticated},
|
auth::{authorize_mcp_server, is_authenticated},
|
||||||
config::{self, McpServersFile},
|
config::{
|
||||||
|
add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
@@ -173,11 +173,8 @@ async fn add_server(
|
|||||||
// Validate
|
// Validate
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
|
|
||||||
// Save (DB if available, else disk)
|
// Save
|
||||||
let db = connect_db().await;
|
add_mcp_server(config).await?;
|
||||||
let mut servers = load_servers(db.as_deref()).await?;
|
|
||||||
servers.upsert(config);
|
|
||||||
save_servers(db.as_deref(), &servers).await?;
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ✓ Added MCP server '{}'", name);
|
println!(" ✓ Added MCP server '{}'", name);
|
||||||
@@ -195,12 +192,7 @@ async fn add_server(
|
|||||||
|
|
||||||
/// Remove an MCP server.
|
/// Remove an MCP server.
|
||||||
async fn remove_server(name: String) -> anyhow::Result<()> {
|
async fn remove_server(name: String) -> anyhow::Result<()> {
|
||||||
let db = connect_db().await;
|
remove_mcp_server(&name).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!();
|
||||||
println!(" ✓ Removed MCP server '{}'", name);
|
println!(" ✓ Removed MCP server '{}'", name);
|
||||||
@@ -211,8 +203,7 @@ async fn remove_server(name: String) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// List configured MCP servers.
|
/// List configured MCP servers.
|
||||||
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
||||||
let db = connect_db().await;
|
let servers = load_mcp_servers().await?;
|
||||||
let servers = load_servers(db.as_deref()).await?;
|
|
||||||
|
|
||||||
if servers.servers.is_empty() {
|
if servers.servers.is_empty() {
|
||||||
println!();
|
println!();
|
||||||
@@ -270,12 +261,7 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> {
|
|||||||
/// Authenticate with an MCP server.
|
/// Authenticate with an MCP server.
|
||||||
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||||
// Get server config
|
// Get server config
|
||||||
let db = connect_db().await;
|
let server = get_mcp_server(&name).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
|
// Initialize secrets store
|
||||||
let secrets = get_secrets_store().await?;
|
let secrets = get_secrets_store().await?;
|
||||||
@@ -343,12 +329,7 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> {
|
|||||||
/// Test connection to an MCP server.
|
/// Test connection to an MCP server.
|
||||||
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||||
// Get server config
|
// Get server config
|
||||||
let db = connect_db().await;
|
let server = get_mcp_server(&name).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!();
|
||||||
println!(" Testing connection to '{}'...", name);
|
println!(" Testing connection to '{}'...", name);
|
||||||
@@ -439,8 +420,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Toggle server enabled/disabled state.
|
/// Toggle server enabled/disabled state.
|
||||||
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
|
async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> {
|
||||||
let db = connect_db().await;
|
let mut servers = load_mcp_servers().await?;
|
||||||
let mut servers = load_servers(db.as_deref()).await?;
|
|
||||||
|
|
||||||
let server = servers
|
let server = servers
|
||||||
.get_mut(&name)
|
.get_mut(&name)
|
||||||
@@ -455,7 +435,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
|||||||
};
|
};
|
||||||
|
|
||||||
server.enabled = new_state;
|
server.enabled = new_state;
|
||||||
save_servers(db.as_deref(), &servers).await?;
|
save_mcp_servers(&servers).await?;
|
||||||
|
|
||||||
let status = if new_state { "enabled" } else { "disabled" };
|
let status = if new_state { "enabled" } else { "disabled" };
|
||||||
println!();
|
println!();
|
||||||
@@ -465,38 +445,9 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_USER_ID: &str = "default";
|
|
||||||
|
|
||||||
/// Try to connect to the database (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.
|
/// Initialize and return the secrets store.
|
||||||
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||||
let config = Config::from_env().await?;
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
@@ -504,61 +455,14 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let store = Store::new(&config.database).await?;
|
||||||
|
store.run_migrations().await?;
|
||||||
|
|
||||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||||
|
Ok(Arc::new(PostgresSecretsStore::new(
|
||||||
#[cfg(feature = "postgres")]
|
store.pool(),
|
||||||
{
|
Arc::new(crypto),
|
||||||
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+1
-26
@@ -9,30 +9,6 @@ use clap::Subcommand;
|
|||||||
|
|
||||||
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
|
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)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
pub enum MemoryCommand {
|
pub enum MemoryCommand {
|
||||||
/// Search workspace memory (hybrid full-text + semantic)
|
/// Search workspace memory (hybrid full-text + semantic)
|
||||||
@@ -79,8 +55,7 @@ pub enum MemoryCommand {
|
|||||||
Status,
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a memory command (PostgreSQL backend).
|
/// Run a memory command.
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
pub async fn run_memory_command(
|
pub async fn run_memory_command(
|
||||||
cmd: MemoryCommand,
|
cmd: MemoryCommand,
|
||||||
pool: deadpool_postgres::Pool,
|
pool: deadpool_postgres::Pool,
|
||||||
|
|||||||
+7
-47
@@ -10,20 +10,16 @@
|
|||||||
//! - Checking system health (`status`)
|
//! - Checking system health (`status`)
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
pub mod key;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod oauth_defaults;
|
|
||||||
mod pairing;
|
|
||||||
pub mod status;
|
pub mod status;
|
||||||
mod tool;
|
mod tool;
|
||||||
|
|
||||||
pub use config::{ConfigCommand, run_config_command};
|
pub use config::{ConfigCommand, run_config_command};
|
||||||
|
pub use key::{KeyCommand, run_key_command};
|
||||||
pub use mcp::{McpCommand, run_mcp_command};
|
pub use mcp::{McpCommand, run_mcp_command};
|
||||||
pub use memory::MemoryCommand;
|
pub use memory::{MemoryCommand, run_memory_command};
|
||||||
#[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 status::run_status_command;
|
||||||
pub use tool::{ToolCommand, run_tool_command};
|
pub use tool::{ToolCommand, run_tool_command};
|
||||||
|
|
||||||
@@ -84,6 +80,10 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Tool(ToolCommand),
|
Tool(ToolCommand),
|
||||||
|
|
||||||
|
/// Manage NEAR blockchain keys
|
||||||
|
#[command(subcommand)]
|
||||||
|
Key(KeyCommand),
|
||||||
|
|
||||||
/// Manage MCP servers (hosted tool providers)
|
/// Manage MCP servers (hosted tool providers)
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Mcp(McpCommand),
|
Mcp(McpCommand),
|
||||||
@@ -92,48 +92,8 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Memory(MemoryCommand),
|
Memory(MemoryCommand),
|
||||||
|
|
||||||
/// DM pairing (approve inbound requests from unknown senders)
|
|
||||||
#[command(subcommand)]
|
|
||||||
Pairing(PairingCommand),
|
|
||||||
|
|
||||||
/// Show system health and diagnostics
|
/// Show system health and diagnostics
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
|
||||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
|
||||||
Worker {
|
|
||||||
/// Job ID to execute.
|
|
||||||
#[arg(long)]
|
|
||||||
job_id: uuid::Uuid,
|
|
||||||
|
|
||||||
/// URL of the orchestrator's internal API.
|
|
||||||
#[arg(long, default_value = "http://host.docker.internal:50051")]
|
|
||||||
orchestrator_url: String,
|
|
||||||
|
|
||||||
/// Maximum iterations before stopping.
|
|
||||||
#[arg(long, default_value = "50")]
|
|
||||||
max_iterations: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Run as a Claude Code bridge inside a Docker container (internal use).
|
|
||||||
/// Spawns the `claude` CLI and streams output back to the orchestrator.
|
|
||||||
ClaudeBridge {
|
|
||||||
/// Job ID to execute.
|
|
||||||
#[arg(long)]
|
|
||||||
job_id: uuid::Uuid,
|
|
||||||
|
|
||||||
/// URL of the orchestrator's internal API.
|
|
||||||
#[arg(long, default_value = "http://host.docker.internal:50051")]
|
|
||||||
orchestrator_url: String,
|
|
||||||
|
|
||||||
/// Maximum agentic turns for Claude Code.
|
|
||||||
#[arg(long, default_value = "50")]
|
|
||||||
max_turns: u32,
|
|
||||||
|
|
||||||
/// Claude model to use (e.g. "sonnet", "opus").
|
|
||||||
#[arg(long, default_value = "sonnet")]
|
|
||||||
model: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cli {
|
impl Cli {
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
//! 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
-51
@@ -9,7 +9,7 @@ use crate::settings::Settings;
|
|||||||
|
|
||||||
/// Run the status command, printing system health info.
|
/// Run the status command, printing system health info.
|
||||||
pub async fn run_status_command() -> anyhow::Result<()> {
|
pub async fn run_status_command() -> anyhow::Result<()> {
|
||||||
let settings = Settings::default();
|
let settings = Settings::load();
|
||||||
|
|
||||||
println!("IronClaw Status");
|
println!("IronClaw Status");
|
||||||
println!("===============\n");
|
println!("===============\n");
|
||||||
@@ -22,36 +22,16 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
|
let db_url_set = settings.database_url.is_some() || std::env::var("DATABASE_URL").is_ok();
|
||||||
print!(" Database: ");
|
print!(" Database: ");
|
||||||
let db_backend = std::env::var("DATABASE_BACKEND")
|
if db_url_set {
|
||||||
.ok()
|
// Try to connect
|
||||||
.unwrap_or_else(|| "postgres".to_string());
|
match check_database().await {
|
||||||
match db_backend.as_str() {
|
Ok(()) => println!("connected"),
|
||||||
"libsql" | "turso" | "sqlite" => {
|
Err(e) => println!("error ({})", e),
|
||||||
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
|
// Session / Auth
|
||||||
@@ -63,17 +43,15 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
println!("not found (run `ironclaw onboard`)");
|
println!("not found (run `ironclaw onboard`)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
// Secrets
|
||||||
// triggering macOS system password dialogs on a simple status check)
|
|
||||||
print!(" Secrets: ");
|
print!(" Secrets: ");
|
||||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|
||||||
println!("configured (env)");
|
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|
||||||
|
|| crate::secrets::keychain::has_master_key();
|
||||||
|
if secrets_configured {
|
||||||
|
println!("configured ({:?})", settings.secrets_master_key_source);
|
||||||
} else {
|
} else {
|
||||||
// We don't probe the keychain here because get_generic_password()
|
println!("not configured");
|
||||||
// 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
|
// Embeddings
|
||||||
@@ -151,18 +129,19 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
Err(_) => println!("none configured"),
|
Err(_) => println!("none configured"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config path
|
// Settings path
|
||||||
println!(
|
println!("\n Settings: {}", Settings::default_path().display());
|
||||||
"\n Config: {}",
|
|
||||||
crate::bootstrap::ironclaw_env_path().display()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
async fn check_database() -> anyhow::Result<()> {
|
async fn check_database() -> anyhow::Result<()> {
|
||||||
let url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))?;
|
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 config: deadpool_postgres::Config = deadpool_postgres::Config {
|
let config: deadpool_postgres::Config = deadpool_postgres::Config {
|
||||||
url: Some(url),
|
url: Some(url),
|
||||||
@@ -188,12 +167,6 @@ async fn check_database() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
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 {
|
fn count_wasm_files(dir: &std::path::Path) -> usize {
|
||||||
std::fs::read_dir(dir)
|
std::fs::read_dir(dir)
|
||||||
.map(|entries| {
|
.map(|entries| {
|
||||||
|
|||||||
+164
-236
@@ -11,11 +11,8 @@ use clap::Subcommand;
|
|||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
#[allow(unused_imports)]
|
use crate::history::Store;
|
||||||
use crate::db::Database;
|
use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
use crate::secrets::PostgresSecretsStore;
|
|
||||||
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
|
|
||||||
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||||
|
|
||||||
/// Default tools directory.
|
/// Default tools directory.
|
||||||
@@ -423,11 +420,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
|||||||
// Simple TOML parsing for [package] name
|
// Simple TOML parsing for [package] name
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("name")
|
if line.starts_with("name") {
|
||||||
&& let Some((_, value)) = line.split_once('=')
|
if let Some((_, value)) = line.split_once('=') {
|
||||||
{
|
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
return Ok(name.to_string());
|
||||||
return Ok(name.to_string());
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +488,10 @@ async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
if has_caps {
|
if has_caps {
|
||||||
let caps_path = path.with_extension("capabilities.json");
|
let caps_path = path.with_extension("capabilities.json");
|
||||||
if let Ok(content) = fs::read_to_string(&caps_path).await
|
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
||||||
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
||||||
{
|
print_capabilities_summary(&caps);
|
||||||
print_capabilities_summary(&caps);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
@@ -607,16 +604,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref secrets) = caps.secrets
|
if let Some(ref secrets) = caps.secrets {
|
||||||
&& !secrets.allowed_names.is_empty()
|
if !secrets.allowed_names.is_empty() {
|
||||||
{
|
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref ws) = caps.workspace
|
if let Some(ref ws) = caps.workspace {
|
||||||
&& !ws.allowed_prefixes.is_empty()
|
if !ws.allowed_prefixes.is_empty() {
|
||||||
{
|
parts.push("workspace: read".to_string());
|
||||||
parts.push("workspace: read".to_string());
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !parts.is_empty() {
|
if !parts.is_empty() {
|
||||||
@@ -653,30 +650,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref secrets) = caps.secrets
|
if let Some(ref secrets) = caps.secrets {
|
||||||
&& !secrets.allowed_names.is_empty()
|
if !secrets.allowed_names.is_empty() {
|
||||||
{
|
println!(" Secrets (existence check only):");
|
||||||
println!(" Secrets (existence check only):");
|
for name in &secrets.allowed_names {
|
||||||
for name in &secrets.allowed_names {
|
println!(" {}", name);
|
||||||
println!(" {}", name);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref tool_invoke) = caps.tool_invoke
|
if let Some(ref tool_invoke) = caps.tool_invoke {
|
||||||
&& !tool_invoke.aliases.is_empty()
|
if !tool_invoke.aliases.is_empty() {
|
||||||
{
|
println!(" Tool aliases:");
|
||||||
println!(" Tool aliases:");
|
for (alias, real_name) in &tool_invoke.aliases {
|
||||||
for (alias, real_name) in &tool_invoke.aliases {
|
println!(" {} -> {}", alias, real_name);
|
||||||
println!(" {} -> {}", alias, real_name);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref ws) = caps.workspace
|
if let Some(ref ws) = caps.workspace {
|
||||||
&& !ws.allowed_prefixes.is_empty()
|
if !ws.allowed_prefixes.is_empty() {
|
||||||
{
|
println!(" Workspace read prefixes:");
|
||||||
println!(" Workspace read prefixes:");
|
for prefix in &ws.allowed_prefixes {
|
||||||
for prefix in &ws.allowed_prefixes {
|
println!(" {}", prefix);
|
||||||
println!(" {}", prefix);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,65 +715,18 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Initialize secrets store
|
// Initialize secrets store
|
||||||
let config = Config::from_env().await?;
|
let config = Config::from_env()?;
|
||||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
|
"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 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
|
// Check if already configured
|
||||||
let already_configured = secrets_store
|
let already_configured = secrets_store
|
||||||
@@ -802,103 +752,51 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for environment variable
|
// Check for environment variable
|
||||||
if let Some(ref env_var) = auth.env_var
|
if let Some(ref env_var) = auth.env_var {
|
||||||
&& let Ok(token) = std::env::var(env_var)
|
if let Ok(token) = std::env::var(env_var) {
|
||||||
&& !token.is_empty()
|
if !token.is_empty() {
|
||||||
{
|
println!(" Found {} in environment.", env_var);
|
||||||
println!(" Found {} in environment.", env_var);
|
println!();
|
||||||
println!();
|
|
||||||
|
|
||||||
// Validate if endpoint is provided
|
// Validate if endpoint is provided
|
||||||
if let Some(ref validation) = auth.validation_endpoint {
|
if let Some(ref validation) = auth.validation_endpoint {
|
||||||
print!(" Validating token...");
|
print!(" Validating token...");
|
||||||
std::io::stdout().flush()?;
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
match validate_token(&token, validation, &auth.secret_name).await {
|
match validate_token(&token, validation, &auth.secret_name).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
println!(" ✓");
|
println!(" ✓");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!(" ✗");
|
println!(" ✗");
|
||||||
println!(" Validation failed: {}", e);
|
println!(" Validation failed: {}", e);
|
||||||
println!();
|
println!();
|
||||||
println!(" Falling back to manual entry...");
|
println!(" Falling back to manual entry...");
|
||||||
return auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await;
|
return auth_tool_manual(&secrets_store, &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
|
// Check for OAuth configuration
|
||||||
if let Some(ref oauth) = auth.oauth {
|
if let Some(ref oauth) = auth.oauth {
|
||||||
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
|
return auth_tool_oauth(&secrets_store, &user_id, &auth, oauth).await;
|
||||||
// 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
|
// Fall back to manual entry
|
||||||
auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await
|
auth_tool_manual(&secrets_store, &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.
|
/// OAuth browser-based login flow.
|
||||||
async fn auth_tool_oauth(
|
async fn auth_tool_oauth(
|
||||||
store: &(dyn SecretsStore + Send + Sync),
|
store: &PostgresSecretsStore,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||||
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||||
@@ -906,14 +804,12 @@ async fn auth_tool_oauth(
|
|||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
||||||
|
|
||||||
// Get client_id: capabilities file > runtime env var > built-in defaults
|
// Get client_id from config or env
|
||||||
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
|
|
||||||
|
|
||||||
let client_id = oauth
|
let client_id = oauth
|
||||||
.client_id
|
.client_id
|
||||||
.clone()
|
.clone()
|
||||||
@@ -923,32 +819,41 @@ async fn auth_tool_oauth(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|env| std::env::var(env).ok())
|
.and_then(|env| std::env::var(env).ok())
|
||||||
})
|
})
|
||||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"OAuth client_id not configured.\n\
|
"OAuth client_id not configured.\n\
|
||||||
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
|
Set it in the capabilities file or via environment variable."
|
||||||
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
// Get client_secret if provided
|
||||||
let client_secret = oauth
|
let client_secret = oauth.client_secret.clone().or_else(|| {
|
||||||
.client_secret
|
oauth
|
||||||
.clone()
|
.client_secret_env
|
||||||
.or_else(|| {
|
.as_ref()
|
||||||
oauth
|
.and_then(|env| std::env::var(env).ok())
|
||||||
.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!(" Starting OAuth authentication...");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let listener = oauth_defaults::bind_callback_listener().await?;
|
// Find an available port for the callback
|
||||||
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
|
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);
|
||||||
|
|
||||||
// Generate PKCE verifier and challenge
|
// Generate PKCE verifier and challenge
|
||||||
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
||||||
@@ -1007,8 +912,65 @@ async fn auth_tool_oauth(
|
|||||||
|
|
||||||
println!(" Waiting for authorization...");
|
println!(" Waiting for authorization...");
|
||||||
|
|
||||||
let code =
|
// Wait for callback with timeout
|
||||||
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
|
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"))??;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" Exchanging code for token...");
|
println!(" Exchanging code for token...");
|
||||||
@@ -1059,19 +1021,8 @@ async fn auth_tool_oauth(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str());
|
// Save the token
|
||||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
save_token(store, user_id, auth, access_token).await?;
|
||||||
|
|
||||||
// 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
|
// Extract any additional info for display
|
||||||
let workspace_name = token_data
|
let workspace_name = token_data
|
||||||
@@ -1093,7 +1044,7 @@ async fn auth_tool_oauth(
|
|||||||
|
|
||||||
/// Manual token entry flow.
|
/// Manual token entry flow.
|
||||||
async fn auth_tool_manual(
|
async fn auth_tool_manual(
|
||||||
store: &(dyn SecretsStore + Send + Sync),
|
store: &PostgresSecretsStore,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -1173,8 +1124,8 @@ async fn auth_tool_manual(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the token (manual path: no refresh token or expiry)
|
// Save the token
|
||||||
save_token(store, user_id, auth, &token, None, None).await?;
|
save_token(store, user_id, auth, &token).await?;
|
||||||
print_success(display_name);
|
print_success(display_name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1265,16 +1216,11 @@ async fn validate_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save token to secrets store.
|
/// 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(
|
async fn save_token(
|
||||||
store: &(dyn SecretsStore + Send + Sync),
|
store: &PostgresSecretsStore,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||||
token: &str,
|
token: &str,
|
||||||
refresh_token: Option<&str>,
|
|
||||||
expires_in: Option<u64>,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
||||||
|
|
||||||
@@ -1282,29 +1228,11 @@ async fn save_token(
|
|||||||
params = params.with_provider(provider);
|
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
|
store
|
||||||
.create(user_id, params)
|
.create(user_id, params)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-660
File diff suppressed because it is too large
Load Diff
@@ -45,21 +45,20 @@ impl ContextManager {
|
|||||||
title: impl Into<String>,
|
title: impl Into<String>,
|
||||||
description: impl Into<String>,
|
description: impl Into<String>,
|
||||||
) -> Result<Uuid, JobError> {
|
) -> Result<Uuid, JobError> {
|
||||||
// Hold write lock for the entire check-insert to prevent TOCTOU races
|
let contexts = self.contexts.read().await;
|
||||||
// where two concurrent calls both pass the active_count check.
|
|
||||||
let mut contexts = self.contexts.write().await;
|
|
||||||
let active_count = contexts.values().filter(|c| c.state.is_active()).count();
|
let active_count = contexts.values().filter(|c| c.state.is_active()).count();
|
||||||
|
|
||||||
if active_count >= self.max_jobs {
|
if active_count >= self.max_jobs {
|
||||||
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
|
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
|
||||||
}
|
}
|
||||||
|
drop(contexts);
|
||||||
|
|
||||||
let context = JobContext::with_user(user_id, title, description);
|
let context = JobContext::with_user(user_id, title, description);
|
||||||
let job_id = context.job_id;
|
let job_id = context.job_id;
|
||||||
contexts.insert(job_id, context);
|
|
||||||
drop(contexts);
|
|
||||||
|
|
||||||
let memory = Memory::new(job_id);
|
let memory = Memory::new(job_id);
|
||||||
|
|
||||||
|
self.contexts.write().await.insert(job_id, context);
|
||||||
self.memories.write().await.insert(job_id, memory);
|
self.memories.write().await.insert(job_id, memory);
|
||||||
|
|
||||||
Ok(job_id)
|
Ok(job_id)
|
||||||
|
|||||||
@@ -119,10 +119,6 @@ pub struct JobContext {
|
|||||||
pub estimated_duration: Option<Duration>,
|
pub estimated_duration: Option<Duration>,
|
||||||
/// Actual cost so far.
|
/// Actual cost so far.
|
||||||
pub actual_cost: Decimal,
|
pub actual_cost: Decimal,
|
||||||
/// Total tokens consumed by LLM calls in this job.
|
|
||||||
pub total_tokens_used: u64,
|
|
||||||
/// Maximum tokens allowed per job (0 = unlimited).
|
|
||||||
pub max_tokens: u64,
|
|
||||||
/// When the job was created.
|
/// When the job was created.
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
/// When the job was started.
|
/// When the job was started.
|
||||||
@@ -163,8 +159,6 @@ impl JobContext {
|
|||||||
estimated_cost: None,
|
estimated_cost: None,
|
||||||
estimated_duration: None,
|
estimated_duration: None,
|
||||||
actual_cost: Decimal::ZERO,
|
actual_cost: Decimal::ZERO,
|
||||||
total_tokens_used: 0,
|
|
||||||
max_tokens: 0,
|
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
started_at: None,
|
started_at: None,
|
||||||
completed_at: None,
|
completed_at: None,
|
||||||
@@ -195,14 +189,6 @@ impl JobContext {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.transitions.push(transition);
|
self.transitions.push(transition);
|
||||||
|
|
||||||
// Cap transition history to prevent unbounded memory growth
|
|
||||||
const MAX_TRANSITIONS: usize = 200;
|
|
||||||
if self.transitions.len() > MAX_TRANSITIONS {
|
|
||||||
let drain_count = self.transitions.len() - MAX_TRANSITIONS;
|
|
||||||
self.transitions.drain(..drain_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state = new_state;
|
self.state = new_state;
|
||||||
|
|
||||||
// Update timestamps
|
// Update timestamps
|
||||||
@@ -224,29 +210,6 @@ impl JobContext {
|
|||||||
self.actual_cost += cost;
|
self.actual_cost += cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record token usage from an LLM call. Returns an error string if the
|
|
||||||
/// token budget has been exceeded after this addition.
|
|
||||||
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> {
|
|
||||||
self.total_tokens_used += tokens;
|
|
||||||
if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
|
|
||||||
Err(format!(
|
|
||||||
"Token budget exceeded: used {} of {} allowed tokens",
|
|
||||||
self.total_tokens_used, self.max_tokens
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether the monetary budget has been exceeded.
|
|
||||||
pub fn budget_exceeded(&self) -> bool {
|
|
||||||
if let Some(ref budget) = self.budget {
|
|
||||||
self.actual_cost > *budget
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the duration since the job started.
|
/// Get the duration since the job started.
|
||||||
pub fn elapsed(&self) -> Option<Duration> {
|
pub fn elapsed(&self) -> Option<Duration> {
|
||||||
self.started_at.map(|start| {
|
self.started_at.map(|start| {
|
||||||
@@ -311,57 +274,6 @@ mod tests {
|
|||||||
assert_eq!(ctx.state, JobState::Completed);
|
assert_eq!(ctx.state, JobState::Completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_transition_history_capped() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Transition cap test");
|
|
||||||
// Cycle through Pending -> InProgress -> Stuck -> InProgress -> Stuck ...
|
|
||||||
ctx.transition_to(JobState::InProgress, None).unwrap();
|
|
||||||
for i in 0..250 {
|
|
||||||
ctx.mark_stuck(format!("stuck {}", i)).unwrap();
|
|
||||||
ctx.attempt_recovery().unwrap();
|
|
||||||
}
|
|
||||||
// 1 initial + 250*2 = 501 transitions, should be capped at 200
|
|
||||||
assert!(
|
|
||||||
ctx.transitions.len() <= 200,
|
|
||||||
"transitions should be capped at 200, got {}",
|
|
||||||
ctx.transitions.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_tokens_enforces_budget() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Budget test");
|
|
||||||
ctx.max_tokens = 1000;
|
|
||||||
assert!(ctx.add_tokens(500).is_ok());
|
|
||||||
assert_eq!(ctx.total_tokens_used, 500);
|
|
||||||
assert!(ctx.add_tokens(600).is_err());
|
|
||||||
assert_eq!(ctx.total_tokens_used, 1100); // tokens still recorded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_tokens_unlimited() {
|
|
||||||
let mut ctx = JobContext::new("Test", "No budget");
|
|
||||||
// max_tokens = 0 means unlimited
|
|
||||||
assert!(ctx.add_tokens(1_000_000).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_budget_exceeded() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Money test");
|
|
||||||
ctx.budget = Some(Decimal::new(100, 0)); // $100
|
|
||||||
assert!(!ctx.budget_exceeded());
|
|
||||||
ctx.add_cost(Decimal::new(50, 0));
|
|
||||||
assert!(!ctx.budget_exceeded());
|
|
||||||
ctx.add_cost(Decimal::new(60, 0));
|
|
||||||
assert!(ctx.budget_exceeded());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_budget_exceeded_none() {
|
|
||||||
let ctx = JobContext::new("Test", "No budget");
|
|
||||||
assert!(!ctx.budget_exceeded()); // No budget = never exceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_stuck_recovery() {
|
fn test_stuck_recovery() {
|
||||||
let mut ctx = JobContext::new("Test", "Test job");
|
let mut ctx = JobContext::new("Test", "Test job");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,549 +0,0 @@
|
|||||||
//! SQLite-dialect migrations for the libSQL/Turso backend.
|
|
||||||
//!
|
|
||||||
//! Consolidates all PostgreSQL migrations (V1-V8) into a single SQLite-compatible
|
|
||||||
//! schema. Run once on database creation; idempotent via `IF NOT EXISTS`.
|
|
||||||
|
|
||||||
/// Consolidated schema for libSQL.
|
|
||||||
///
|
|
||||||
/// Translates PostgreSQL types and features:
|
|
||||||
/// - `UUID` -> `TEXT` (store as hex string)
|
|
||||||
/// - `TIMESTAMPTZ` -> `TEXT` (ISO-8601)
|
|
||||||
/// - `JSONB` -> `TEXT` (JSON encoded)
|
|
||||||
/// - `BYTEA` -> `BLOB`
|
|
||||||
/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
|
|
||||||
/// - `TEXT[]` -> `TEXT` (JSON array)
|
|
||||||
/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
|
|
||||||
/// - `TSVECTOR` -> FTS5 virtual table
|
|
||||||
/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
|
|
||||||
/// - PL/pgSQL functions -> SQLite triggers
|
|
||||||
pub const SCHEMA: &str = r#"
|
|
||||||
|
|
||||||
-- ==================== Migration tracking ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS _migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==================== Conversations ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
channel TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
thread_id TEXT,
|
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_activity TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_channel ON conversations(channel);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_last_activity ON conversations(last_activity);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS conversation_messages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
||||||
role TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conversation
|
|
||||||
ON conversation_messages(conversation_id);
|
|
||||||
|
|
||||||
-- ==================== Agent Jobs ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
marketplace_job_id TEXT,
|
|
||||||
conversation_id TEXT REFERENCES conversations(id),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL DEFAULT 'default',
|
|
||||||
project_dir TEXT,
|
|
||||||
job_mode TEXT NOT NULL DEFAULT 'worker',
|
|
||||||
budget_amount TEXT,
|
|
||||||
budget_token TEXT,
|
|
||||||
bid_amount TEXT,
|
|
||||||
estimated_cost TEXT,
|
|
||||||
estimated_time_secs INTEGER,
|
|
||||||
estimated_value TEXT,
|
|
||||||
actual_cost TEXT,
|
|
||||||
actual_time_secs INTEGER,
|
|
||||||
success INTEGER,
|
|
||||||
failure_reason TEXT,
|
|
||||||
stuck_since TEXT,
|
|
||||||
repair_attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_status ON agent_jobs(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_marketplace ON agent_jobs(marketplace_job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_conversation ON agent_jobs(conversation_id);
|
|
||||||
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);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS job_actions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
|
|
||||||
sequence_num INTEGER NOT NULL,
|
|
||||||
tool_name TEXT NOT NULL,
|
|
||||||
input TEXT NOT NULL,
|
|
||||||
output_raw TEXT,
|
|
||||||
output_sanitized TEXT,
|
|
||||||
sanitization_warnings TEXT,
|
|
||||||
cost TEXT,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
success INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(job_id, sequence_num)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_job_actions_job_id ON job_actions(job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_job_actions_tool ON job_actions(tool_name);
|
|
||||||
|
|
||||||
-- ==================== Dynamic Tools ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dynamic_tools (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
parameters_schema TEXT NOT NULL,
|
|
||||||
code TEXT NOT NULL,
|
|
||||||
sandbox_config TEXT NOT NULL,
|
|
||||||
created_by_job_id TEXT REFERENCES agent_jobs(id),
|
|
||||||
success_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dynamic_tools_status ON dynamic_tools(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_dynamic_tools_name ON dynamic_tools(name);
|
|
||||||
|
|
||||||
-- ==================== LLM Calls ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
job_id TEXT REFERENCES agent_jobs(id) ON DELETE CASCADE,
|
|
||||||
conversation_id TEXT REFERENCES conversations(id),
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
model TEXT NOT NULL,
|
|
||||||
input_tokens INTEGER NOT NULL,
|
|
||||||
output_tokens INTEGER NOT NULL,
|
|
||||||
cost TEXT NOT NULL,
|
|
||||||
purpose TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_llm_calls_job ON llm_calls(job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_llm_calls_conversation ON llm_calls(conversation_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_llm_calls_provider ON llm_calls(provider);
|
|
||||||
|
|
||||||
-- ==================== Estimation ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS estimation_snapshots (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
job_id TEXT NOT NULL REFERENCES agent_jobs(id) ON DELETE CASCADE,
|
|
||||||
category TEXT NOT NULL,
|
|
||||||
tool_names TEXT NOT NULL DEFAULT '[]',
|
|
||||||
estimated_cost TEXT NOT NULL,
|
|
||||||
actual_cost TEXT,
|
|
||||||
estimated_time_secs INTEGER NOT NULL,
|
|
||||||
actual_time_secs INTEGER,
|
|
||||||
estimated_value TEXT NOT NULL,
|
|
||||||
actual_value TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_estimation_category ON estimation_snapshots(category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_estimation_job ON estimation_snapshots(job_id);
|
|
||||||
|
|
||||||
-- ==================== Self Repair ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS repair_attempts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
target_type TEXT NOT NULL,
|
|
||||||
target_id TEXT NOT NULL,
|
|
||||||
diagnosis TEXT NOT NULL,
|
|
||||||
action_taken TEXT NOT NULL,
|
|
||||||
success INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_repair_attempts_target ON repair_attempts(target_type, target_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_repair_attempts_created ON repair_attempts(created_at);
|
|
||||||
|
|
||||||
-- ==================== Workspace: Memory Documents ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_documents (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
agent_id TEXT,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}',
|
|
||||||
UNIQUE (user_id, agent_id, path)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memory_documents_user ON memory_documents(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memory_documents_path ON memory_documents(user_id, path);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memory_documents_updated ON memory_documents(updated_at DESC);
|
|
||||||
|
|
||||||
-- Trigger to auto-update updated_at on memory_documents
|
|
||||||
CREATE TRIGGER IF NOT EXISTS update_memory_documents_updated_at
|
|
||||||
AFTER UPDATE ON memory_documents
|
|
||||||
FOR EACH ROW
|
|
||||||
WHEN NEW.updated_at = OLD.updated_at
|
|
||||||
BEGIN
|
|
||||||
UPDATE memory_documents SET updated_at = datetime('now') WHERE id = NEW.id;
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- ==================== Workspace: Memory Chunks ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
||||||
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
id TEXT NOT NULL UNIQUE,
|
|
||||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
|
||||||
chunk_index INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
embedding F32_BLOB(1536),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE (document_id, chunk_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
|
||||||
|
|
||||||
-- Vector index for semantic search (libSQL native)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
|
|
||||||
ON memory_chunks (libsql_vector_idx(embedding));
|
|
||||||
|
|
||||||
-- FTS5 virtual table for full-text search
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
|
|
||||||
content,
|
|
||||||
content='memory_chunks',
|
|
||||||
content_rowid='_rowid'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS5 in sync with memory_chunks
|
|
||||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
|
|
||||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
|
|
||||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
|
||||||
VALUES ('delete', old._rowid, old.content);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
|
|
||||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
|
||||||
VALUES ('delete', old._rowid, old.content);
|
|
||||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- ==================== Workspace: Heartbeat State ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS heartbeat_state (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
agent_id TEXT,
|
|
||||||
last_run TEXT,
|
|
||||||
next_run TEXT,
|
|
||||||
interval_seconds INTEGER NOT NULL DEFAULT 1800,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_checks TEXT NOT NULL DEFAULT '{}',
|
|
||||||
UNIQUE (user_id, agent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_user ON heartbeat_state(user_id);
|
|
||||||
|
|
||||||
-- ==================== Secrets ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS secrets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
encrypted_value BLOB NOT NULL,
|
|
||||||
key_salt BLOB NOT NULL,
|
|
||||||
provider TEXT,
|
|
||||||
expires_at TEXT,
|
|
||||||
last_used_at TEXT,
|
|
||||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE (user_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_user ON secrets(user_id);
|
|
||||||
|
|
||||||
-- ==================== WASM Tools ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wasm_tools (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
wasm_binary BLOB NOT NULL,
|
|
||||||
binary_hash BLOB NOT NULL,
|
|
||||||
parameters_schema TEXT NOT NULL,
|
|
||||||
source_url TEXT,
|
|
||||||
trust_level TEXT NOT NULL DEFAULT 'user',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE (user_id, name, version)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_user ON wasm_tools(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_name ON wasm_tools(user_id, name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_status ON wasm_tools(status);
|
|
||||||
|
|
||||||
-- ==================== Tool Capabilities ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tool_capabilities (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
|
|
||||||
http_allowlist TEXT NOT NULL DEFAULT '[]',
|
|
||||||
allowed_secrets TEXT NOT NULL DEFAULT '[]',
|
|
||||||
tool_aliases TEXT NOT NULL DEFAULT '{}',
|
|
||||||
requests_per_minute INTEGER NOT NULL DEFAULT 60,
|
|
||||||
requests_per_hour INTEGER NOT NULL DEFAULT 1000,
|
|
||||||
max_request_body_bytes INTEGER NOT NULL DEFAULT 1048576,
|
|
||||||
max_response_body_bytes INTEGER NOT NULL DEFAULT 10485760,
|
|
||||||
workspace_read_prefixes TEXT NOT NULL DEFAULT '[]',
|
|
||||||
http_timeout_secs INTEGER NOT NULL DEFAULT 30,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE (wasm_tool_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==================== Leak Detection Patterns ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS leak_detection_patterns (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
pattern TEXT NOT NULL,
|
|
||||||
severity TEXT NOT NULL DEFAULT 'high',
|
|
||||||
action TEXT NOT NULL DEFAULT 'block',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==================== Rate Limit State ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tool_rate_limit_state (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
wasm_tool_id TEXT NOT NULL REFERENCES wasm_tools(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
minute_window_start TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
minute_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
hour_window_start TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
hour_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (wasm_tool_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==================== Secret Usage Audit Log ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS secret_usage_log (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
secret_id TEXT NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
|
|
||||||
wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
target_host TEXT NOT NULL,
|
|
||||||
target_path TEXT,
|
|
||||||
success INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_user ON secret_usage_log(user_id);
|
|
||||||
|
|
||||||
-- ==================== Leak Detection Events ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS leak_detection_events (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
pattern_id TEXT REFERENCES leak_detection_patterns(id) ON DELETE SET NULL,
|
|
||||||
wasm_tool_id TEXT REFERENCES wasm_tools(id) ON DELETE SET NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
action_taken TEXT NOT NULL,
|
|
||||||
context_preview TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ==================== Tool Failures ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tool_failures (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tool_name TEXT NOT NULL UNIQUE,
|
|
||||||
error_message TEXT,
|
|
||||||
error_count INTEGER DEFAULT 1,
|
|
||||||
first_failure TEXT DEFAULT (datetime('now')),
|
|
||||||
last_failure TEXT DEFAULT (datetime('now')),
|
|
||||||
last_build_result TEXT,
|
|
||||||
repaired_at TEXT,
|
|
||||||
repair_attempts INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tool_failures_name ON tool_failures(tool_name);
|
|
||||||
|
|
||||||
-- ==================== Job Events ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS job_events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id TEXT NOT NULL REFERENCES agent_jobs(id),
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id, id);
|
|
||||||
|
|
||||||
-- ==================== Routines ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS routines (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
trigger_type TEXT NOT NULL,
|
|
||||||
trigger_config TEXT NOT NULL,
|
|
||||||
action_type TEXT NOT NULL,
|
|
||||||
action_config TEXT NOT NULL,
|
|
||||||
cooldown_secs INTEGER NOT NULL DEFAULT 300,
|
|
||||||
max_concurrent INTEGER NOT NULL DEFAULT 1,
|
|
||||||
dedup_window_secs INTEGER,
|
|
||||||
notify_channel TEXT,
|
|
||||||
notify_user TEXT NOT NULL DEFAULT 'default',
|
|
||||||
notify_on_success INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notify_on_failure INTEGER NOT NULL DEFAULT 1,
|
|
||||||
notify_on_attention INTEGER NOT NULL DEFAULT 1,
|
|
||||||
state TEXT NOT NULL DEFAULT '{}',
|
|
||||||
last_run_at TEXT,
|
|
||||||
next_fire_at TEXT,
|
|
||||||
run_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE (user_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id);
|
|
||||||
|
|
||||||
-- ==================== Routine Runs ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS routine_runs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
routine_id TEXT NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
|
|
||||||
trigger_type TEXT NOT NULL,
|
|
||||||
trigger_detail TEXT,
|
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'running',
|
|
||||||
result_summary TEXT,
|
|
||||||
tokens_used INTEGER,
|
|
||||||
job_id TEXT REFERENCES agent_jobs(id),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id);
|
|
||||||
|
|
||||||
-- ==================== Settings ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (user_id, key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings(user_id);
|
|
||||||
|
|
||||||
-- ==================== Missing indexes (parity with PostgreSQL) ====================
|
|
||||||
|
|
||||||
-- agent_jobs
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_stuck ON agent_jobs(stuck_since);
|
|
||||||
|
|
||||||
-- secrets
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_provider ON secrets(provider);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at);
|
|
||||||
|
|
||||||
-- wasm_tools
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_trust ON wasm_tools(trust_level);
|
|
||||||
|
|
||||||
-- tool_capabilities
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tool_capabilities_tool ON tool_capabilities(wasm_tool_id);
|
|
||||||
|
|
||||||
-- leak_detection_patterns
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leak_patterns_enabled ON leak_detection_patterns(enabled);
|
|
||||||
|
|
||||||
-- tool_rate_limit_state
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_tool ON tool_rate_limit_state(wasm_tool_id);
|
|
||||||
|
|
||||||
-- secret_usage_log
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_secret ON secret_usage_log(secret_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_tool ON secret_usage_log(wasm_tool_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_created ON secret_usage_log(created_at DESC);
|
|
||||||
|
|
||||||
-- leak_detection_events
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leak_events_pattern ON leak_detection_events(pattern_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leak_events_tool ON leak_detection_events(wasm_tool_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leak_events_user ON leak_detection_events(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_leak_events_created ON leak_detection_events(created_at DESC);
|
|
||||||
|
|
||||||
-- tool_failures
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tool_failures_count ON tool_failures(error_count DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_name);
|
|
||||||
|
|
||||||
-- routines
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id);
|
|
||||||
|
|
||||||
-- routine_runs
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
|
|
||||||
|
|
||||||
-- heartbeat_state
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run);
|
|
||||||
|
|
||||||
-- ==================== Seed data ====================
|
|
||||||
|
|
||||||
-- Pre-populate leak detection patterns (matches PostgreSQL V2 migration).
|
|
||||||
INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES
|
|
||||||
('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440005', 'github_token', 'gh[pousr]_[A-Za-z0-9_]{36,}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440006', 'github_fine_grained_pat', 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440007', 'stripe_api_key', 'sk_(?:live|test)_[a-zA-Z0-9]{24,}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440008', 'nearai_session', 'sess_[a-zA-Z0-9]{32,}', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440009', 'bearer_token', 'Bearer\s+[a-zA-Z0-9_-]{20,}', 'high', 'redact', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000a', 'pem_private_key', '-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000b', 'ssh_private_key', '-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000c', 'google_api_key', 'AIza[0-9A-Za-z_-]{35}', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000d', 'slack_token', 'xox[baprs]-[0-9a-zA-Z-]{10,}', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000e', 'discord_token', '[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-44665544000f', 'twilio_api_key', 'SK[a-fA-F0-9]{32}', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440010', 'sendgrid_api_key', 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}', 'high', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, datetime('now')),
|
|
||||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
|
|
||||||
|
|
||||||
"#;
|
|
||||||
-538
@@ -1,538 +0,0 @@
|
|||||||
//! Database abstraction layer.
|
|
||||||
//!
|
|
||||||
//! Provides a backend-agnostic `Database` trait that unifies all persistence
|
|
||||||
//! operations. Two implementations exist behind feature flags:
|
|
||||||
//!
|
|
||||||
//! - `postgres` (default): Uses `deadpool-postgres` + `tokio-postgres`
|
|
||||||
//! - `libsql`: Uses libSQL (Turso's SQLite fork) for embedded/edge deployment
|
|
||||||
//!
|
|
||||||
//! The existing `Store`, `Repository`, `SecretsStore`, and `WasmToolStore`
|
|
||||||
//! types become thin wrappers that delegate to `Arc<dyn Database>`.
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
pub mod postgres;
|
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
pub mod libsql_backend;
|
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
pub mod libsql_migrations;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::agent::BrokenTool;
|
|
||||||
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
|
||||||
use crate::context::{ActionRecord, JobContext, JobState};
|
|
||||||
use crate::error::DatabaseError;
|
|
||||||
use crate::error::WorkspaceError;
|
|
||||||
use crate::history::{
|
|
||||||
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
|
||||||
SandboxJobSummary, SettingRow,
|
|
||||||
};
|
|
||||||
use crate::workspace::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
|
||||||
use crate::workspace::{SearchConfig, SearchResult};
|
|
||||||
|
|
||||||
/// Create a database backend from configuration, run migrations, and return it.
|
|
||||||
///
|
|
||||||
/// This is the shared helper for CLI commands and other call sites that need
|
|
||||||
/// a simple `Arc<dyn Database>` without retaining backend-specific handles
|
|
||||||
/// (e.g., `pg_pool` or `libsql_conn` for the secrets store). The main agent
|
|
||||||
/// startup in `main.rs` uses its own initialization block because it also
|
|
||||||
/// captures those backend-specific handles.
|
|
||||||
pub async fn connect_from_config(
|
|
||||||
config: &crate::config::DatabaseConfig,
|
|
||||||
) -> Result<Arc<dyn Database>, DatabaseError> {
|
|
||||||
match config.backend {
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
crate::config::DatabaseBackend::LibSql => {
|
|
||||||
use secrecy::ExposeSecret as _;
|
|
||||||
|
|
||||||
let default_path = crate::config::default_libsql_path();
|
|
||||||
let db_path = config.libsql_path.as_deref().unwrap_or(&default_path);
|
|
||||||
|
|
||||||
let backend = if let Some(ref url) = config.libsql_url {
|
|
||||||
let token = config.libsql_auth_token.as_ref().ok_or_else(|| {
|
|
||||||
DatabaseError::Pool(
|
|
||||||
"LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
libsql_backend::LibSqlBackend::new_remote_replica(
|
|
||||||
db_path,
|
|
||||||
url,
|
|
||||||
token.expose_secret(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
libsql_backend::LibSqlBackend::new_local(db_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
|
||||||
};
|
|
||||||
backend.run_migrations().await?;
|
|
||||||
Ok(Arc::new(backend))
|
|
||||||
}
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
_ => {
|
|
||||||
let pg = postgres::PgBackend::new(config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
|
||||||
pg.run_migrations().await?;
|
|
||||||
Ok(Arc::new(pg))
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "postgres"))]
|
|
||||||
_ => Err(DatabaseError::Pool(
|
|
||||||
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backend-agnostic database trait.
|
|
||||||
///
|
|
||||||
/// Combines all persistence operations from Store, Repository, and related
|
|
||||||
/// stores into a single trait that can be implemented for different backends.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Database: Send + Sync {
|
|
||||||
/// Run schema migrations for this backend.
|
|
||||||
async fn run_migrations(&self) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Conversations ====================
|
|
||||||
|
|
||||||
/// Create a new conversation.
|
|
||||||
async fn create_conversation(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update conversation last activity.
|
|
||||||
async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Add a message to a conversation.
|
|
||||||
async fn add_conversation_message(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
role: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
/// Ensure a conversation row exists (upsert).
|
|
||||||
async fn ensure_conversation(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// List conversations with a title preview.
|
|
||||||
async fn list_conversations_with_preview(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
channel: &str,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<ConversationSummary>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get or create the singleton assistant conversation.
|
|
||||||
async fn get_or_create_assistant_conversation(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
channel: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
/// Create a conversation with specific metadata.
|
|
||||||
async fn create_conversation_with_metadata(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
/// Load messages with cursor-based pagination.
|
|
||||||
async fn list_conversation_messages_paginated(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError>;
|
|
||||||
|
|
||||||
/// Merge a single key into conversation metadata.
|
|
||||||
async fn update_conversation_metadata_field(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Read conversation metadata.
|
|
||||||
async fn get_conversation_metadata(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Load all messages for a conversation.
|
|
||||||
async fn list_conversation_messages(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
) -> Result<Vec<ConversationMessage>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Check if a conversation belongs to a specific user.
|
|
||||||
async fn conversation_belongs_to_user(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Jobs ====================
|
|
||||||
|
|
||||||
/// Save a job context.
|
|
||||||
async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get a job by ID.
|
|
||||||
async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update job status.
|
|
||||||
async fn update_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: JobState,
|
|
||||||
failure_reason: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Mark job as stuck.
|
|
||||||
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get stuck jobs.
|
|
||||||
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Actions ====================
|
|
||||||
|
|
||||||
/// Save a job action.
|
|
||||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get actions for a job.
|
|
||||||
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== LLM Calls ====================
|
|
||||||
|
|
||||||
/// Record an LLM call.
|
|
||||||
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Estimation Snapshots ====================
|
|
||||||
|
|
||||||
/// Save an estimation snapshot.
|
|
||||||
async fn save_estimation_snapshot(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
category: &str,
|
|
||||||
tool_names: &[String],
|
|
||||||
estimated_cost: Decimal,
|
|
||||||
estimated_time_secs: i32,
|
|
||||||
estimated_value: Decimal,
|
|
||||||
) -> Result<Uuid, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update estimation snapshot with actual values.
|
|
||||||
async fn update_estimation_actuals(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
actual_cost: Decimal,
|
|
||||||
actual_time_secs: i32,
|
|
||||||
actual_value: Option<Decimal>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Sandbox Jobs ====================
|
|
||||||
|
|
||||||
/// Insert a new sandbox job.
|
|
||||||
async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get a sandbox job by ID.
|
|
||||||
async fn get_sandbox_job(&self, id: Uuid) -> Result<Option<SandboxJobRecord>, DatabaseError>;
|
|
||||||
|
|
||||||
/// List all sandbox jobs, most recent first.
|
|
||||||
async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update sandbox job status.
|
|
||||||
async fn update_sandbox_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: &str,
|
|
||||||
success: Option<bool>,
|
|
||||||
message: Option<&str>,
|
|
||||||
started_at: Option<DateTime<Utc>>,
|
|
||||||
completed_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Mark stale sandbox jobs as interrupted.
|
|
||||||
async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get sandbox job summary.
|
|
||||||
async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError>;
|
|
||||||
|
|
||||||
/// List sandbox jobs for a specific user, most recent first.
|
|
||||||
async fn list_sandbox_jobs_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Vec<SandboxJobRecord>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get sandbox job summary for a specific user.
|
|
||||||
async fn sandbox_job_summary_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<SandboxJobSummary, DatabaseError>;
|
|
||||||
|
|
||||||
/// Check if a sandbox job belongs to a specific user.
|
|
||||||
async fn sandbox_job_belongs_to_user(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update sandbox job mode.
|
|
||||||
async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get sandbox job mode.
|
|
||||||
async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Job Events ====================
|
|
||||||
|
|
||||||
/// Persist a job event.
|
|
||||||
async fn save_job_event(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
event_type: &str,
|
|
||||||
data: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Load all job events.
|
|
||||||
async fn list_job_events(&self, job_id: Uuid) -> Result<Vec<JobEventRecord>, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Routines ====================
|
|
||||||
|
|
||||||
/// Create a new routine.
|
|
||||||
async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get a routine by ID.
|
|
||||||
async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get a routine by user_id and name.
|
|
||||||
async fn get_routine_by_name(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError>;
|
|
||||||
|
|
||||||
/// List routines for a user.
|
|
||||||
async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError>;
|
|
||||||
|
|
||||||
/// List all enabled event routines.
|
|
||||||
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
|
|
||||||
|
|
||||||
/// List due cron routines.
|
|
||||||
async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Update a routine.
|
|
||||||
async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Update runtime state after a routine fires.
|
|
||||||
async fn update_routine_runtime(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
last_run_at: DateTime<Utc>,
|
|
||||||
next_fire_at: Option<DateTime<Utc>>,
|
|
||||||
run_count: u64,
|
|
||||||
consecutive_failures: u32,
|
|
||||||
state: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Delete a routine.
|
|
||||||
async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Routine Runs ====================
|
|
||||||
|
|
||||||
/// Record a routine run starting.
|
|
||||||
async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Complete a routine run.
|
|
||||||
async fn complete_routine_run(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: RunStatus,
|
|
||||||
result_summary: Option<&str>,
|
|
||||||
tokens_used: Option<i32>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// List recent runs for a routine.
|
|
||||||
async fn list_routine_runs(
|
|
||||||
&self,
|
|
||||||
routine_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<RoutineRun>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Count currently running runs for a routine.
|
|
||||||
async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Tool Failures ====================
|
|
||||||
|
|
||||||
/// Record a tool failure (upsert).
|
|
||||||
async fn record_tool_failure(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
error_message: &str,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Get broken tools exceeding threshold.
|
|
||||||
async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Mark a tool as repaired.
|
|
||||||
async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Increment repair attempts.
|
|
||||||
async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Settings ====================
|
|
||||||
|
|
||||||
/// Get a single setting.
|
|
||||||
async fn get_setting(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get a single setting with metadata.
|
|
||||||
async fn get_setting_full(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<SettingRow>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Set a single setting (upsert).
|
|
||||||
async fn set_setting(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Delete a single setting.
|
|
||||||
async fn delete_setting(&self, user_id: &str, key: &str) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
/// List all settings for a user.
|
|
||||||
async fn list_settings(&self, user_id: &str) -> Result<Vec<SettingRow>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get all settings as a flat map.
|
|
||||||
async fn get_all_settings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<HashMap<String, serde_json::Value>, DatabaseError>;
|
|
||||||
|
|
||||||
/// Bulk-write settings atomically.
|
|
||||||
async fn set_all_settings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
settings: &HashMap<String, serde_json::Value>,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Check if settings exist for a user.
|
|
||||||
async fn has_settings(&self, user_id: &str) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
// ==================== Workspace: Documents ====================
|
|
||||||
|
|
||||||
/// Get a document by path.
|
|
||||||
async fn get_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<MemoryDocument, WorkspaceError>;
|
|
||||||
|
|
||||||
/// Get a document by ID.
|
|
||||||
async fn get_document_by_id(&self, id: Uuid) -> Result<MemoryDocument, WorkspaceError>;
|
|
||||||
|
|
||||||
/// Get or create a document by path.
|
|
||||||
async fn get_or_create_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<MemoryDocument, WorkspaceError>;
|
|
||||||
|
|
||||||
/// Update a document's content.
|
|
||||||
async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError>;
|
|
||||||
|
|
||||||
/// Delete a document by path.
|
|
||||||
async fn delete_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<(), WorkspaceError>;
|
|
||||||
|
|
||||||
/// List files and directories in a directory path.
|
|
||||||
async fn list_directory(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
directory: &str,
|
|
||||||
) -> Result<Vec<WorkspaceEntry>, WorkspaceError>;
|
|
||||||
|
|
||||||
/// List all file paths in the workspace.
|
|
||||||
async fn list_all_paths(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<String>, WorkspaceError>;
|
|
||||||
|
|
||||||
/// List all documents for a user.
|
|
||||||
async fn list_documents(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<MemoryDocument>, WorkspaceError>;
|
|
||||||
|
|
||||||
// ==================== Workspace: Chunks ====================
|
|
||||||
|
|
||||||
/// Delete all chunks for a document.
|
|
||||||
async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError>;
|
|
||||||
|
|
||||||
/// Insert a chunk.
|
|
||||||
async fn insert_chunk(
|
|
||||||
&self,
|
|
||||||
document_id: Uuid,
|
|
||||||
chunk_index: i32,
|
|
||||||
content: &str,
|
|
||||||
embedding: Option<&[f32]>,
|
|
||||||
) -> Result<Uuid, WorkspaceError>;
|
|
||||||
|
|
||||||
/// Update a chunk's embedding.
|
|
||||||
async fn update_chunk_embedding(
|
|
||||||
&self,
|
|
||||||
chunk_id: Uuid,
|
|
||||||
embedding: &[f32],
|
|
||||||
) -> Result<(), WorkspaceError>;
|
|
||||||
|
|
||||||
/// Get chunks without embeddings for backfilling.
|
|
||||||
async fn get_chunks_without_embeddings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<MemoryChunk>, WorkspaceError>;
|
|
||||||
|
|
||||||
// ==================== Workspace: Search ====================
|
|
||||||
|
|
||||||
/// Perform hybrid search combining FTS and vector similarity.
|
|
||||||
async fn hybrid_search(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
query: &str,
|
|
||||||
embedding: Option<&[f32]>,
|
|
||||||
config: &SearchConfig,
|
|
||||||
) -> Result<Vec<SearchResult>, WorkspaceError>;
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
//! PostgreSQL backend for the Database trait.
|
|
||||||
//!
|
|
||||||
//! Delegates to the existing `Store` (history) and `Repository` (workspace)
|
|
||||||
//! implementations, avoiding SQL duplication.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use deadpool_postgres::Pool;
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::agent::BrokenTool;
|
|
||||||
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
|
||||||
use crate::config::DatabaseConfig;
|
|
||||||
use crate::context::{ActionRecord, JobContext, JobState};
|
|
||||||
use crate::db::Database;
|
|
||||||
use crate::error::{DatabaseError, WorkspaceError};
|
|
||||||
use crate::history::{
|
|
||||||
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
|
||||||
SandboxJobSummary, SettingRow, Store,
|
|
||||||
};
|
|
||||||
use crate::workspace::{
|
|
||||||
MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult, WorkspaceEntry,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// PostgreSQL database backend.
|
|
||||||
///
|
|
||||||
/// Wraps the existing `Store` (for history/conversations/jobs/routines/settings)
|
|
||||||
/// and `Repository` (for workspace documents/chunks/search) to implement the
|
|
||||||
/// unified `Database` trait.
|
|
||||||
pub struct PgBackend {
|
|
||||||
store: Store,
|
|
||||||
repo: Repository,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PgBackend {
|
|
||||||
/// Create a new PostgreSQL backend from configuration.
|
|
||||||
pub async fn new(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
|
|
||||||
let store = Store::new(config).await?;
|
|
||||||
let repo = Repository::new(store.pool());
|
|
||||||
Ok(Self { store, repo })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a clone of the connection pool.
|
|
||||||
///
|
|
||||||
/// Useful for sharing with components that still need raw pool access.
|
|
||||||
pub fn pool(&self) -> Pool {
|
|
||||||
self.store.pool()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Database for PgBackend {
|
|
||||||
async fn run_migrations(&self) -> Result<(), DatabaseError> {
|
|
||||||
self.store.run_migrations().await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Conversations ====================
|
|
||||||
|
|
||||||
async fn create_conversation(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.create_conversation(channel, user_id, thread_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
self.store.touch_conversation(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_conversation_message(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
role: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.add_conversation_message(conversation_id, role, content)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_conversation(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.ensure_conversation(id, channel, user_id, thread_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_conversations_with_preview(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
channel: &str,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<ConversationSummary>, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.list_conversations_with_preview(user_id, channel, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_or_create_assistant_conversation(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
channel: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.get_or_create_assistant_conversation(user_id, channel)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_conversation_with_metadata(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
user_id: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.create_conversation_with_metadata(channel, user_id, metadata)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_conversation_messages_paginated(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.list_conversation_messages_paginated(conversation_id, before, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_conversation_metadata_field(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_conversation_metadata_field(id, key, value)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_conversation_metadata(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError> {
|
|
||||||
self.store.get_conversation_metadata(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_conversation_messages(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
) -> Result<Vec<ConversationMessage>, DatabaseError> {
|
|
||||||
self.store.list_conversation_messages(conversation_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn conversation_belongs_to_user(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<bool, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.conversation_belongs_to_user(conversation_id, user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Jobs ====================
|
|
||||||
|
|
||||||
async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
|
|
||||||
self.store.save_job(ctx).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError> {
|
|
||||||
self.store.get_job(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: JobState,
|
|
||||||
failure_reason: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_job_status(id, status, failure_reason)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
self.store.mark_job_stuck(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError> {
|
|
||||||
self.store.get_stuck_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Actions ====================
|
|
||||||
|
|
||||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
|
||||||
self.store.save_action(job_id, action).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError> {
|
|
||||||
self.store.get_job_actions(job_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== LLM Calls ====================
|
|
||||||
|
|
||||||
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store.record_llm_call(record).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Estimation Snapshots ====================
|
|
||||||
|
|
||||||
async fn save_estimation_snapshot(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
category: &str,
|
|
||||||
tool_names: &[String],
|
|
||||||
estimated_cost: Decimal,
|
|
||||||
estimated_time_secs: i32,
|
|
||||||
estimated_value: Decimal,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.save_estimation_snapshot(
|
|
||||||
job_id,
|
|
||||||
category,
|
|
||||||
tool_names,
|
|
||||||
estimated_cost,
|
|
||||||
estimated_time_secs,
|
|
||||||
estimated_value,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_estimation_actuals(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
actual_cost: Decimal,
|
|
||||||
actual_time_secs: i32,
|
|
||||||
actual_value: Option<Decimal>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_estimation_actuals(id, actual_cost, actual_time_secs, actual_value)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Sandbox Jobs ====================
|
|
||||||
|
|
||||||
async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
|
|
||||||
self.store.save_sandbox_job(job).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_sandbox_job(&self, id: Uuid) -> Result<Option<SandboxJobRecord>, DatabaseError> {
|
|
||||||
self.store.get_sandbox_job(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
|
|
||||||
self.store.list_sandbox_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_sandbox_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: &str,
|
|
||||||
success: Option<bool>,
|
|
||||||
message: Option<&str>,
|
|
||||||
started_at: Option<DateTime<Utc>>,
|
|
||||||
completed_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_sandbox_job_status(id, status, success, message, started_at, completed_at)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError> {
|
|
||||||
self.store.cleanup_stale_sandbox_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError> {
|
|
||||||
self.store.sandbox_job_summary().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_sandbox_jobs_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
|
|
||||||
self.store.list_sandbox_jobs_for_user(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sandbox_job_summary_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<SandboxJobSummary, DatabaseError> {
|
|
||||||
self.store.sandbox_job_summary_for_user(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sandbox_job_belongs_to_user(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<bool, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.sandbox_job_belongs_to_user(job_id, user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.store.update_sandbox_job_mode(id, mode).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError> {
|
|
||||||
self.store.get_sandbox_job_mode(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Job Events ====================
|
|
||||||
|
|
||||||
async fn save_job_event(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
event_type: &str,
|
|
||||||
data: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store.save_job_event(job_id, event_type, data).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_job_events(&self, job_id: Uuid) -> Result<Vec<JobEventRecord>, DatabaseError> {
|
|
||||||
self.store.list_job_events(job_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Routines ====================
|
|
||||||
|
|
||||||
async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
|
|
||||||
self.store.create_routine(routine).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
self.store.get_routine(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_routine_by_name(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
self.store.get_routine_by_name(user_id, name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.store.list_routines(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.store.list_event_routines().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.store.list_due_cron_routines().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
|
|
||||||
self.store.update_routine(routine).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_routine_runtime(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
last_run_at: DateTime<Utc>,
|
|
||||||
next_fire_at: Option<DateTime<Utc>>,
|
|
||||||
run_count: u64,
|
|
||||||
consecutive_failures: u32,
|
|
||||||
state: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_routine_runtime(
|
|
||||||
id,
|
|
||||||
last_run_at,
|
|
||||||
next_fire_at,
|
|
||||||
run_count,
|
|
||||||
consecutive_failures,
|
|
||||||
state,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.delete_routine(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Routine Runs ====================
|
|
||||||
|
|
||||||
async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
|
|
||||||
self.store.create_routine_run(run).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn complete_routine_run(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: RunStatus,
|
|
||||||
result_summary: Option<&str>,
|
|
||||||
tokens_used: Option<i32>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.complete_routine_run(id, status, result_summary, tokens_used)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_routine_runs(
|
|
||||||
&self,
|
|
||||||
routine_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
self.store.list_routine_runs(routine_id, limit).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError> {
|
|
||||||
self.store.count_running_routine_runs(routine_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Tool Failures ====================
|
|
||||||
|
|
||||||
async fn record_tool_failure(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
error_message: &str,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.record_tool_failure(tool_name, error_message)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError> {
|
|
||||||
self.store.get_broken_tools(threshold).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.store.mark_tool_repaired(tool_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.store.increment_repair_attempts(tool_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Settings ====================
|
|
||||||
|
|
||||||
async fn get_setting(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError> {
|
|
||||||
self.store.get_setting(user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_setting_full(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<SettingRow>, DatabaseError> {
|
|
||||||
self.store.get_setting_full(user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_setting(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store.set_setting(user_id, key, value).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_setting(&self, user_id: &str, key: &str) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.delete_setting(user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_settings(&self, user_id: &str) -> Result<Vec<SettingRow>, DatabaseError> {
|
|
||||||
self.store.list_settings(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_all_settings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<HashMap<String, serde_json::Value>, DatabaseError> {
|
|
||||||
self.store.get_all_settings(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_all_settings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
settings: &HashMap<String, serde_json::Value>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store.set_all_settings(user_id, settings).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn has_settings(&self, user_id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.has_settings(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Workspace: Documents ====================
|
|
||||||
|
|
||||||
async fn get_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<MemoryDocument, WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.get_document_by_path(user_id, agent_id, path)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_document_by_id(&self, id: Uuid) -> Result<MemoryDocument, WorkspaceError> {
|
|
||||||
self.repo.get_document_by_id(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_or_create_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<MemoryDocument, WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.get_or_create_document_by_path(user_id, agent_id, path)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
|
|
||||||
self.repo.update_document(id, content).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_document_by_path(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<(), WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.delete_document_by_path(user_id, agent_id, path)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_directory(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
directory: &str,
|
|
||||||
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
|
||||||
self.repo.list_directory(user_id, agent_id, directory).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_all_paths(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<String>, WorkspaceError> {
|
|
||||||
self.repo.list_all_paths(user_id, agent_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_documents(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
|
|
||||||
self.repo.list_documents(user_id, agent_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Workspace: Chunks ====================
|
|
||||||
|
|
||||||
async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
|
|
||||||
self.repo.delete_chunks(document_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_chunk(
|
|
||||||
&self,
|
|
||||||
document_id: Uuid,
|
|
||||||
chunk_index: i32,
|
|
||||||
content: &str,
|
|
||||||
embedding: Option<&[f32]>,
|
|
||||||
) -> Result<Uuid, WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.insert_chunk(document_id, chunk_index, content, embedding)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_chunk_embedding(
|
|
||||||
&self,
|
|
||||||
chunk_id: Uuid,
|
|
||||||
embedding: &[f32],
|
|
||||||
) -> Result<(), WorkspaceError> {
|
|
||||||
self.repo.update_chunk_embedding(chunk_id, embedding).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_chunks_without_embeddings(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<MemoryChunk>, WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.get_chunks_without_embeddings(user_id, agent_id, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Workspace: Search ====================
|
|
||||||
|
|
||||||
async fn hybrid_search(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
agent_id: Option<Uuid>,
|
|
||||||
query: &str,
|
|
||||||
embedding: Option<&[f32]>,
|
|
||||||
config: &SearchConfig,
|
|
||||||
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
|
||||||
self.repo
|
|
||||||
.hybrid_search(user_id, agent_id, query, embedding, config)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-62
@@ -40,14 +40,8 @@ pub enum Error {
|
|||||||
#[error("Workspace error: {0}")]
|
#[error("Workspace error: {0}")]
|
||||||
Workspace(#[from] WorkspaceError),
|
Workspace(#[from] WorkspaceError),
|
||||||
|
|
||||||
#[error("Hook error: {0}")]
|
#[error("Key management error: {0}")]
|
||||||
Hook(#[from] crate::hooks::HookError),
|
Key(#[from] crate::keys::KeyError),
|
||||||
|
|
||||||
#[error("Orchestrator error: {0}")]
|
|
||||||
Orchestrator(#[from] OrchestratorError),
|
|
||||||
|
|
||||||
#[error("Worker error: {0}")]
|
|
||||||
Worker(#[from] WorkerError),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration-related errors.
|
/// Configuration-related errors.
|
||||||
@@ -90,21 +84,14 @@ pub enum DatabaseError {
|
|||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serialization(String),
|
Serialization(String),
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
#[error("PostgreSQL error: {0}")]
|
#[error("PostgreSQL error: {0}")]
|
||||||
Postgres(#[from] tokio_postgres::Error),
|
Postgres(#[from] tokio_postgres::Error),
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
#[error("Pool build error: {0}")]
|
#[error("Pool build error: {0}")]
|
||||||
PoolBuild(#[from] deadpool_postgres::BuildError),
|
PoolBuild(#[from] deadpool_postgres::BuildError),
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
#[error("Pool runtime error: {0}")]
|
#[error("Pool runtime error: {0}")]
|
||||||
PoolRuntime(#[from] deadpool_postgres::PoolError),
|
PoolRuntime(#[from] deadpool_postgres::PoolError),
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
#[error("LibSQL error: {0}")]
|
|
||||||
LibSql(#[from] libsql::Error),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel-related errors.
|
/// Channel-related errors.
|
||||||
@@ -324,52 +311,5 @@ pub enum WorkspaceError {
|
|||||||
HeartbeatError { reason: String },
|
HeartbeatError { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrator errors (internal API, container management).
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum OrchestratorError {
|
|
||||||
#[error("Container creation failed for job {job_id}: {reason}")]
|
|
||||||
ContainerCreationFailed { job_id: Uuid, reason: String },
|
|
||||||
|
|
||||||
#[error("Container not found for job {job_id}")]
|
|
||||||
ContainerNotFound { job_id: Uuid },
|
|
||||||
|
|
||||||
#[error("Container for job {job_id} is in unexpected state: {state}")]
|
|
||||||
InvalidContainerState { job_id: Uuid, state: String },
|
|
||||||
|
|
||||||
#[error("Worker authentication failed: {reason}")]
|
|
||||||
AuthFailed { reason: String },
|
|
||||||
|
|
||||||
#[error("Internal API error: {reason}")]
|
|
||||||
ApiError { reason: String },
|
|
||||||
|
|
||||||
#[error("Docker error: {reason}")]
|
|
||||||
Docker { reason: String },
|
|
||||||
|
|
||||||
#[error("Job {job_id} timed out in container")]
|
|
||||||
ContainerTimeout { job_id: Uuid },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Worker errors (container-side execution).
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum WorkerError {
|
|
||||||
#[error("Failed to connect to orchestrator at {url}: {reason}")]
|
|
||||||
ConnectionFailed { url: String, reason: String },
|
|
||||||
|
|
||||||
#[error("LLM proxy request failed: {reason}")]
|
|
||||||
LlmProxyFailed { reason: String },
|
|
||||||
|
|
||||||
#[error("Secret resolution failed for {secret_name}: {reason}")]
|
|
||||||
SecretResolveFailed { secret_name: String, reason: String },
|
|
||||||
|
|
||||||
#[error("Orchestrator returned error for job {job_id}: {reason}")]
|
|
||||||
OrchestratorRejected { job_id: Uuid, reason: String },
|
|
||||||
|
|
||||||
#[error("Worker execution failed: {reason}")]
|
|
||||||
ExecutionFailed { reason: String },
|
|
||||||
|
|
||||||
#[error("Missing worker token (IRONCLAW_WORKER_TOKEN not set)")]
|
|
||||||
MissingToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result type alias for the agent.
|
/// Result type alias for the agent.
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ impl CostEstimator {
|
|||||||
|
|
||||||
// Default tool costs (in USD or equivalent)
|
// Default tool costs (in USD or equivalent)
|
||||||
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
||||||
|
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
|
||||||
|
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
|
||||||
|
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
|
||||||
|
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
|
||||||
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
||||||
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
||||||
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
||||||
@@ -70,7 +74,7 @@ mod tests {
|
|||||||
let estimator = CostEstimator::new();
|
let estimator = CostEstimator::new();
|
||||||
|
|
||||||
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
||||||
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001));
|
assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
|
||||||
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ impl TimeEstimator {
|
|||||||
|
|
||||||
// Default tool durations
|
// Default tool durations
|
||||||
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
||||||
|
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
|
||||||
|
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
|
||||||
|
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
|
||||||
|
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
|
||||||
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
||||||
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
||||||
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
||||||
|
|||||||
@@ -144,11 +144,12 @@ impl SuccessEvaluator for RuleBasedEvaluator {
|
|||||||
|
|
||||||
// Check for critical errors
|
// Check for critical errors
|
||||||
for action in actions.iter().filter(|a| !a.success) {
|
for action in actions.iter().filter(|a| !a.success) {
|
||||||
if let Some(ref error) = action.error
|
if let Some(ref error) = action.error {
|
||||||
&& (error.to_lowercase().contains("critical")
|
if error.to_lowercase().contains("critical")
|
||||||
|| error.to_lowercase().contains("fatal"))
|
|| error.to_lowercase().contains("fatal")
|
||||||
{
|
{
|
||||||
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-121
@@ -23,7 +23,9 @@ use crate::tools::mcp::auth::{
|
|||||||
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
||||||
find_available_port, is_authenticated, register_client,
|
find_available_port, is_authenticated, register_client,
|
||||||
};
|
};
|
||||||
use crate::tools::mcp::config::McpServerConfig;
|
use crate::tools::mcp::config::{
|
||||||
|
McpServerConfig, add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server,
|
||||||
|
};
|
||||||
use crate::tools::mcp::session::McpSessionManager;
|
use crate::tools::mcp::session::McpSessionManager;
|
||||||
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
||||||
|
|
||||||
@@ -56,8 +58,6 @@ pub struct ExtensionManager {
|
|||||||
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
|
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
|
||||||
_tunnel_url: Option<String>,
|
_tunnel_url: Option<String>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
/// Optional database store for DB-backed MCP config.
|
|
||||||
store: Option<Arc<dyn crate::db::Database>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExtensionManager {
|
impl ExtensionManager {
|
||||||
@@ -71,7 +71,6 @@ impl ExtensionManager {
|
|||||||
wasm_channels_dir: PathBuf,
|
wasm_channels_dir: PathBuf,
|
||||||
tunnel_url: Option<String>,
|
tunnel_url: Option<String>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
store: Option<Arc<dyn crate::db::Database>>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry: ExtensionRegistry::new(),
|
registry: ExtensionRegistry::new(),
|
||||||
@@ -86,7 +85,6 @@ impl ExtensionManager {
|
|||||||
pending_auth: RwLock::new(HashMap::new()),
|
pending_auth: RwLock::new(HashMap::new()),
|
||||||
_tunnel_url: tunnel_url,
|
_tunnel_url: tunnel_url,
|
||||||
user_id,
|
user_id,
|
||||||
store,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +191,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
// List MCP servers
|
// List MCP servers
|
||||||
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
||||||
match self.load_mcp_servers().await {
|
match load_mcp_servers().await {
|
||||||
Ok(servers) => {
|
Ok(servers) => {
|
||||||
for server in &servers.servers {
|
for server in &servers.servers {
|
||||||
let authenticated =
|
let authenticated =
|
||||||
@@ -306,7 +304,7 @@ impl ExtensionManager {
|
|||||||
self.mcp_clients.write().await.remove(name);
|
self.mcp_clients.write().await.remove(name);
|
||||||
|
|
||||||
// Remove from config
|
// Remove from config
|
||||||
self.remove_mcp_server(name)
|
remove_mcp_server(name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||||
|
|
||||||
@@ -344,56 +342,6 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MCP config helpers (DB with disk fallback) ─────────────────────
|
|
||||||
|
|
||||||
async fn load_mcp_servers(
|
|
||||||
&self,
|
|
||||||
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
|
|
||||||
{
|
|
||||||
if let Some(ref store) = self.store {
|
|
||||||
crate::tools::mcp::config::load_mcp_servers_from_db(store.as_ref(), &self.user_id).await
|
|
||||||
} else {
|
|
||||||
crate::tools::mcp::config::load_mcp_servers().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_mcp_server(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
|
|
||||||
let servers = self.load_mcp_servers().await?;
|
|
||||||
servers.get(name).cloned().ok_or_else(|| {
|
|
||||||
crate::tools::mcp::config::ConfigError::ServerNotFound {
|
|
||||||
name: name.to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_mcp_server(
|
|
||||||
&self,
|
|
||||||
config: McpServerConfig,
|
|
||||||
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
||||||
config.validate()?;
|
|
||||||
if let Some(ref store) = self.store {
|
|
||||||
crate::tools::mcp::config::add_mcp_server_db(store.as_ref(), &self.user_id, config)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
crate::tools::mcp::config::add_mcp_server(config).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_mcp_server(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
||||||
if let Some(ref store) = self.store {
|
|
||||||
crate::tools::mcp::config::remove_mcp_server_db(store.as_ref(), &self.user_id, name)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
crate::tools::mcp::config::remove_mcp_server(name).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────────
|
// ── Private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn install_from_entry(
|
async fn install_from_entry(
|
||||||
@@ -433,7 +381,7 @@ impl ExtensionManager {
|
|||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<InstallResult, ExtensionError> {
|
) -> Result<InstallResult, ExtensionError> {
|
||||||
// Check if already installed
|
// Check if already installed
|
||||||
if self.get_mcp_server(name).await.is_ok() {
|
if get_mcp_server(name).await.is_ok() {
|
||||||
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +390,7 @@ impl ExtensionManager {
|
|||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
||||||
|
|
||||||
self.add_mcp_server(config)
|
add_mcp_server(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
||||||
|
|
||||||
@@ -463,16 +411,7 @@ impl ExtensionManager {
|
|||||||
name: &str,
|
name: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<InstallResult, ExtensionError> {
|
) -> Result<InstallResult, ExtensionError> {
|
||||||
// Require HTTPS to prevent downgrade attacks
|
// Download the WASM binary
|
||||||
if !url.starts_with("https://") {
|
|
||||||
return Err(ExtensionError::InstallFailed(
|
|
||||||
"Only HTTPS URLs are allowed for extension downloads".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 50 MB cap to prevent disk-fill DoS
|
|
||||||
const MAX_WASM_SIZE: usize = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
.build()
|
.build()
|
||||||
@@ -491,36 +430,11 @@ impl ExtensionManager {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Content-Length header before downloading the full body
|
|
||||||
if let Some(len) = response.content_length()
|
|
||||||
&& len as usize > MAX_WASM_SIZE
|
|
||||||
{
|
|
||||||
return Err(ExtensionError::InstallFailed(format!(
|
|
||||||
"WASM binary too large ({} bytes, max {} bytes)",
|
|
||||||
len, MAX_WASM_SIZE
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
||||||
|
|
||||||
if bytes.len() > MAX_WASM_SIZE {
|
|
||||||
return Err(ExtensionError::InstallFailed(format!(
|
|
||||||
"WASM binary too large ({} bytes, max {} bytes)",
|
|
||||||
bytes.len(),
|
|
||||||
MAX_WASM_SIZE
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic WASM magic number check (\0asm)
|
|
||||||
if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
|
|
||||||
return Err(ExtensionError::InstallFailed(
|
|
||||||
"Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure tools directory exists
|
// Ensure tools directory exists
|
||||||
tokio::fs::create_dir_all(&self.wasm_tools_dir)
|
tokio::fs::create_dir_all(&self.wasm_tools_dir)
|
||||||
.await
|
.await
|
||||||
@@ -533,10 +447,9 @@ impl ExtensionManager {
|
|||||||
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Installed WASM tool '{}' ({} bytes) from {} to {}",
|
"Installed WASM tool '{}' ({} bytes) to {}",
|
||||||
name,
|
name,
|
||||||
bytes.len(),
|
bytes.len(),
|
||||||
url,
|
|
||||||
wasm_path.display()
|
wasm_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -552,8 +465,7 @@ impl ExtensionManager {
|
|||||||
name: &str,
|
name: &str,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
) -> Result<AuthResult, ExtensionError> {
|
) -> Result<AuthResult, ExtensionError> {
|
||||||
let server = self
|
let server = get_mcp_server(name)
|
||||||
.get_mcp_server(name)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||||
|
|
||||||
@@ -768,27 +680,27 @@ impl ExtensionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check env var first
|
// Check env var first
|
||||||
if let Some(ref env_var) = auth.env_var
|
if let Some(ref env_var) = auth.env_var {
|
||||||
&& let Ok(value) = std::env::var(env_var)
|
if let Ok(value) = std::env::var(env_var) {
|
||||||
{
|
// Store the env var value as a secret
|
||||||
// Store the env var value as a secret
|
let params = CreateSecretParams::new(&auth.secret_name, &value)
|
||||||
let params =
|
.with_provider(name.to_string());
|
||||||
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
|
self.secrets
|
||||||
self.secrets
|
.create(&self.user_id, params)
|
||||||
.create(&self.user_id, params)
|
.await
|
||||||
.await
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
||||||
|
|
||||||
return Ok(AuthResult {
|
return Ok(AuthResult {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
kind: ExtensionKind::WasmTool,
|
kind: ExtensionKind::WasmTool,
|
||||||
auth_url: None,
|
auth_url: None,
|
||||||
callback_type: None,
|
callback_type: None,
|
||||||
instructions: None,
|
instructions: None,
|
||||||
setup_url: None,
|
setup_url: None,
|
||||||
awaiting_token: false,
|
awaiting_token: false,
|
||||||
status: "authenticated".to_string(),
|
status: "authenticated".to_string(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
@@ -872,8 +784,7 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = self
|
let server = get_mcp_server(name)
|
||||||
.get_mcp_server(name)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||||
|
|
||||||
@@ -982,7 +893,7 @@ impl ExtensionManager {
|
|||||||
/// Determine what kind of installed extension this is.
|
/// Determine what kind of installed extension this is.
|
||||||
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
||||||
// Check MCP servers first
|
// Check MCP servers first
|
||||||
if self.get_mcp_server(name).await.is_ok() {
|
if get_mcp_server(name).await.is_ok() {
|
||||||
return Ok(ExtensionKind::McpServer);
|
return Ok(ExtensionKind::McpServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-8
@@ -5,15 +5,8 @@
|
|||||||
//! - Learning from past executions
|
//! - Learning from past executions
|
||||||
//! - Analytics and metrics
|
//! - Analytics and metrics
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
mod analytics;
|
mod analytics;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
pub use analytics::{JobStats, ToolStats};
|
pub use analytics::{JobStats, ToolStats};
|
||||||
#[cfg(feature = "postgres")]
|
pub use store::{LlmCallRecord, Store};
|
||||||
pub use store::Store;
|
|
||||||
pub use store::{
|
|
||||||
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
|
||||||
SandboxJobSummary, SettingRow,
|
|
||||||
};
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user