mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Merge pull request #917 from nearai/staging-promote/369741fc-22935740447
chore: promote staging to main (2026-03-11 03:47 UTC)
This commit is contained in:
@@ -29,18 +29,36 @@ jobs:
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_bots: "ironclaw-ci[bot]"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Read,Glob,Grep,Agent,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
prompt: |
|
||||
Code review this pull request. Follow these steps precisely:
|
||||
|
||||
1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md
|
||||
and any CLAUDE.md files in directories whose files this PR modifies.
|
||||
1. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files
|
||||
in directories whose files this PR modifies. Use Glob to find them, then Read
|
||||
to load their contents.
|
||||
|
||||
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
|
||||
2. Get the PR diff with `gh pr diff` and summarize the change.
|
||||
|
||||
3. Launch 4 parallel agents to review the change independently. Each agent should
|
||||
read the PR diff with `gh pr diff` and the full source files for changed
|
||||
code, then return a list of issues found:
|
||||
code (using Read), then return a list of issues. Each agent MUST score its
|
||||
own findings inline using the severity and confidence rubric below.
|
||||
|
||||
Severity levels:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
|
||||
Confidence scoring (0-100):
|
||||
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
|
||||
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
|
||||
50: Real issue but nitpick or rare in practice. Not very important.
|
||||
75: Verified real issue, will be hit in practice. Directly impacts functionality
|
||||
or explicitly mentioned in CLAUDE.md.
|
||||
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
|
||||
|
||||
Each agent returns findings as: [SEVERITY:CONFIDENCE] <brief description>
|
||||
|
||||
Agent 1 — Security & Safety
|
||||
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
|
||||
@@ -63,22 +81,9 @@ jobs:
|
||||
timeouts, resource leaks (file handles, connections), large allocations
|
||||
in hot paths.
|
||||
|
||||
4. For each issue found, launch a parallel Haiku agent to:
|
||||
a. Assign a severity:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
b. Score confidence 0-100 (give this rubric verbatim):
|
||||
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
|
||||
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
|
||||
50: Real issue but nitpick or rare in practice. Not very important.
|
||||
75: Verified real issue, will be hit in practice. Directly impacts functionality
|
||||
or explicitly mentioned in CLAUDE.md.
|
||||
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
|
||||
|
||||
5. Post a single comment on the PR using `gh pr comment` with this format.
|
||||
If no issues were found, post "No issues found." instead:
|
||||
4. Consolidate all agent findings and post exactly one comment on the PR
|
||||
using `gh pr comment` with this format. If no issues were found,
|
||||
post "No issues found." instead:
|
||||
|
||||
### Code review
|
||||
|
||||
@@ -93,8 +98,12 @@ jobs:
|
||||
You MUST use the full git SHA in links (not HEAD or branch name).
|
||||
Provide 1 line of context before and after each linked range.
|
||||
|
||||
Notes:
|
||||
- Use `gh` for all GitHub interactions, not web fetch
|
||||
IMPORTANT rules:
|
||||
- Only YOU (the main process) may call `gh pr comment`. Agents must return
|
||||
their findings to you — they must NOT post comments themselves.
|
||||
- You MUST post exactly one `gh pr comment` before finishing, even if agents
|
||||
fail or return empty results. If review is incomplete, post "No issues found."
|
||||
- Use Read/Glob for file access, `gh` for GitHub interactions, not web fetch
|
||||
- Do NOT check build signal or attempt to build/test the code
|
||||
- Ignore pre-existing issues not introduced by this PR
|
||||
- Ignore issues a linter/compiler would catch (formatting, imports, types)
|
||||
|
||||
@@ -406,6 +406,10 @@ jobs:
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Only merge PRs targeting main. Chained PRs (targeting another
|
||||
# promotion branch) stay open — when the base PR merges into main,
|
||||
# GitHub auto-retargets the chained PR. Merging chained PRs would
|
||||
# trigger delete_branch_on_merge, auto-closing downstream PRs.
|
||||
- name: Merge promotion PR
|
||||
id: merge
|
||||
if: steps.evaluate.outputs.passed == 'true'
|
||||
@@ -414,12 +418,15 @@ jobs:
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER}"
|
||||
# Do NOT use --delete-branch: deleting a promotion branch closes
|
||||
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
|
||||
# Stale promotion branches are cleaned up separately.
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
|
||||
if [ "$BASE" = "main" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Update tested tag (always, so next batch covers only new commits) ──
|
||||
@@ -437,7 +444,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update staging-tested tag
|
||||
run: |
|
||||
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -57,8 +57,8 @@ jobs:
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -84,8 +84,8 @@ jobs:
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -107,8 +107,8 @@ jobs:
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
Generated
+1
-36
@@ -2787,15 +2787,6 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -3410,7 +3401,6 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rig-core",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
"rust_decimal_macros",
|
||||
"rustls 0.23.37",
|
||||
@@ -3707,7 +3697,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator 0.2.0",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink 0.8.4",
|
||||
"hashlink",
|
||||
"libsql-ffi",
|
||||
"smallvec",
|
||||
]
|
||||
@@ -3770,17 +3760,6 @@ dependencies = [
|
||||
"zerocopy 0.7.35",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libyml"
|
||||
version = "0.0.5"
|
||||
@@ -5351,20 +5330,6 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator 0.3.0",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink 0.9.1",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
|
||||
+1
-2
@@ -176,7 +176,6 @@ ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
|
||||
# OpenClaw import (feature gated)
|
||||
rusqlite = { version = "0.32", optional = true, features = ["bundled"] }
|
||||
json5 = { version = "0.4", optional = true }
|
||||
|
||||
# macOS keychain
|
||||
@@ -214,7 +213,7 @@ libsql = ["dep:libsql"]
|
||||
integration = []
|
||||
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
||||
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
|
||||
import = ["dep:rusqlite", "dep:json5"]
|
||||
import = ["dep:json5", "libsql"]
|
||||
|
||||
[[test]]
|
||||
name = "html_to_markdown"
|
||||
|
||||
+2
-1
@@ -440,6 +440,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
@@ -558,7 +559,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ 调度器 │ │ 定时任务引擎 │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, wh) │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, Webhook)│ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
|
||||
+13
-7
@@ -925,14 +925,20 @@ impl Agent {
|
||||
|
||||
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
||||
// all approval checks (including ApprovalRequirement::Always).
|
||||
let needs_approval = if self.config.auto_approve_tools {
|
||||
false
|
||||
} else {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
|
||||
if needs_approval {
|
||||
|
||||
@@ -106,6 +106,34 @@ pub fn verify_slack_signature(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
|
||||
///
|
||||
/// Computes `HMAC-SHA256(secret, body)` and compares against
|
||||
/// `prefix + hex_digest` in constant time.
|
||||
pub fn verify_hmac_sha256_prefixed(
|
||||
secret: &str,
|
||||
body: &[u8],
|
||||
signature_header: &str,
|
||||
prefix: &str,
|
||||
) -> bool {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
let computed_hex = hex::encode(computed);
|
||||
let expected = format!("{prefix}{computed_hex}");
|
||||
expected
|
||||
.as_bytes()
|
||||
.ct_eq(signature_header.as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -498,6 +526,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_sha256_prefixed_valid() {
|
||||
let secret = "github-secret";
|
||||
let body = br#"{"action":"opened"}"#;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
|
||||
assert!(!verify_hmac_sha256_prefixed(
|
||||
secret,
|
||||
body,
|
||||
"sha256=deadbeef",
|
||||
"sha256="
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_stale_timestamp_rejected() {
|
||||
let signing_secret = "my-signing-secret";
|
||||
|
||||
@@ -244,6 +244,12 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
||||
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
||||
self.rebuild_state(|s| s.routine_engine = slot);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the auth token (for printing to console on startup).
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
|
||||
@@ -318,7 +318,11 @@ pub async fn start_server(
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/favicon.ico", get(favicon_handler));
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/i18n/index.js", get(i18n_index_handler))
|
||||
.route("/i18n/en.js", get(i18n_en_handler))
|
||||
.route("/i18n/zh-CN.js", get(i18n_zh_handler))
|
||||
.route("/i18n-app.js", get(i18n_app_handler));
|
||||
|
||||
// Project file serving (behind auth to prevent unauthorized file access).
|
||||
let projects = Router::new()
|
||||
@@ -430,6 +434,46 @@ async fn favicon_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_index_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/index.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_en_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/en.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_zh_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/zh-CN.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_app_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n-app.js"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
async fn health_handler() -> Json<HealthResponse> {
|
||||
|
||||
@@ -55,7 +55,7 @@ let _activityThinking = null;
|
||||
function authenticate() {
|
||||
token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
document.getElementById('auth-error').textContent = 'Token required';
|
||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function authenticate() {
|
||||
sessionStorage.removeItem('ironclaw_token');
|
||||
document.getElementById('auth-screen').style.display = '';
|
||||
document.getElementById('app').style.display = 'none';
|
||||
document.getElementById('auth-error').textContent = 'Invalid token';
|
||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment
|
||||
|
||||
function triggerRestart() {
|
||||
if (!currentThreadId) {
|
||||
alert('Please start a conversation first');
|
||||
alert(I18n.t('error.startConversation'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ function triggerRestart() {
|
||||
|
||||
function confirmRestart() {
|
||||
if (!currentThreadId) {
|
||||
alert('Please start a conversation first');
|
||||
alert(I18n.t('error.startConversation'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ function confirmRestart() {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[confirmRestart] Restart request failed:', err);
|
||||
addMessage('system', 'Restart failed: ' + err.message);
|
||||
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
|
||||
isRestarting = false;
|
||||
restartBtn.disabled = false;
|
||||
if (restartIcon) restartIcon.classList.remove('spinning');
|
||||
@@ -234,7 +234,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.onopen = () => {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
document.getElementById('sse-status').textContent = 'Connected';
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
||||
|
||||
// If we were restarting, close the modal and reset button now that server is back
|
||||
if (isRestarting) {
|
||||
@@ -256,7 +256,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
document.getElementById('sse-dot').classList.add('disconnected');
|
||||
document.getElementById('sse-status').textContent = 'Reconnecting...';
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
||||
};
|
||||
|
||||
eventSource.addEventListener('response', (e) => {
|
||||
@@ -464,7 +464,7 @@ function enableChatInput() {
|
||||
const btn = document.getElementById('send-btn');
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.placeholder = 'Message or / for commands...';
|
||||
input.placeholder = I18n.t('chat.inputPlaceholder');
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
@@ -703,8 +703,8 @@ function copyCodeBlock(btn) {
|
||||
const code = pre.querySelector('code');
|
||||
const text = code ? code.textContent : pre.textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
||||
btn.textContent = I18n.t('btn.copied');
|
||||
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -991,7 +991,7 @@ function showApproval(data) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'approval-header';
|
||||
header.textContent = 'Tool requires approval';
|
||||
header.textContent = I18n.t('approval.title');
|
||||
card.appendChild(header);
|
||||
|
||||
const toolName = document.createElement('div');
|
||||
@@ -1009,7 +1009,7 @@ function showApproval(data) {
|
||||
if (data.parameters) {
|
||||
const paramsToggle = document.createElement('button');
|
||||
paramsToggle.className = 'approval-params-toggle';
|
||||
paramsToggle.textContent = 'Show parameters';
|
||||
paramsToggle.textContent = I18n.t('approval.showParams');
|
||||
const paramsBlock = document.createElement('pre');
|
||||
paramsBlock.className = 'approval-params';
|
||||
paramsBlock.textContent = data.parameters;
|
||||
@@ -1017,7 +1017,7 @@ function showApproval(data) {
|
||||
paramsToggle.addEventListener('click', () => {
|
||||
const visible = paramsBlock.style.display !== 'none';
|
||||
paramsBlock.style.display = visible ? 'none' : 'block';
|
||||
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
|
||||
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
|
||||
});
|
||||
card.appendChild(paramsToggle);
|
||||
card.appendChild(paramsBlock);
|
||||
@@ -1028,17 +1028,17 @@ function showApproval(data) {
|
||||
|
||||
const approveBtn = document.createElement('button');
|
||||
approveBtn.className = 'approve';
|
||||
approveBtn.textContent = 'Approve';
|
||||
approveBtn.textContent = I18n.t('approval.approve');
|
||||
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
||||
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = 'Always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
|
||||
const denyBtn = document.createElement('button');
|
||||
denyBtn.className = 'deny';
|
||||
denyBtn.textContent = 'Deny';
|
||||
denyBtn.textContent = I18n.t('approval.deny');
|
||||
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
||||
|
||||
actions.appendChild(approveBtn);
|
||||
@@ -1065,7 +1065,7 @@ function showJobCard(data) {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'job-card-title';
|
||||
title.textContent = data.title || 'Sandbox Job';
|
||||
title.textContent = data.title || I18n.t('sandbox.job');
|
||||
info.appendChild(title);
|
||||
|
||||
const id = document.createElement('div');
|
||||
@@ -1077,7 +1077,7 @@ function showJobCard(data) {
|
||||
|
||||
const viewBtn = document.createElement('button');
|
||||
viewBtn.className = 'job-card-view';
|
||||
viewBtn.textContent = 'View Job';
|
||||
viewBtn.textContent = I18n.t('jobs.viewJob');
|
||||
viewBtn.addEventListener('click', () => {
|
||||
switchTab('jobs');
|
||||
openJobDetail(data.job_id);
|
||||
@@ -1089,7 +1089,7 @@ function showJobCard(data) {
|
||||
browseBtn.className = 'job-card-browse';
|
||||
browseBtn.href = data.browse_url;
|
||||
browseBtn.target = '_blank';
|
||||
browseBtn.textContent = 'Browse';
|
||||
browseBtn.textContent = I18n.t('jobs.browse');
|
||||
card.appendChild(browseBtn);
|
||||
}
|
||||
|
||||
@@ -1110,7 +1110,7 @@ function showAuthCard(data) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'auth-header';
|
||||
header.textContent = 'Authentication required for ' + data.extension_name;
|
||||
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
|
||||
card.appendChild(header);
|
||||
|
||||
if (data.instructions) {
|
||||
@@ -1126,7 +1126,7 @@ function showAuthCard(data) {
|
||||
if (data.auth_url) {
|
||||
const oauthBtn = document.createElement('button');
|
||||
oauthBtn.className = 'auth-oauth';
|
||||
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
|
||||
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
|
||||
oauthBtn.addEventListener('click', () => {
|
||||
openOAuthUrl(data.auth_url);
|
||||
});
|
||||
@@ -1137,7 +1137,7 @@ function showAuthCard(data) {
|
||||
const setupLink = document.createElement('a');
|
||||
setupLink.href = data.setup_url;
|
||||
setupLink.target = '_blank';
|
||||
setupLink.textContent = 'Get your token';
|
||||
setupLink.textContent = I18n.t('authRequired.getToken');
|
||||
links.appendChild(setupLink);
|
||||
}
|
||||
|
||||
@@ -1151,7 +1151,9 @@ function showAuthCard(data) {
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'password';
|
||||
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
|
||||
tokenInput.placeholder = data.instructions
|
||||
|| I18n.t('auth.extensionTokenPlaceholder')
|
||||
|| I18n.t('auth.tokenPlaceholder');
|
||||
tokenInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
||||
});
|
||||
@@ -1170,12 +1172,12 @@ function showAuthCard(data) {
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'auth-submit';
|
||||
submitBtn.textContent = 'Submit';
|
||||
submitBtn.textContent = I18n.t('btn.submit');
|
||||
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'auth-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.textContent = I18n.t('btn.cancel');
|
||||
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
|
||||
|
||||
actions.appendChild(submitBtn);
|
||||
@@ -1967,7 +1969,7 @@ function prependLogEntry(entry) {
|
||||
function toggleLogsPause() {
|
||||
logsPaused = !logsPaused;
|
||||
const btn = document.getElementById('logs-pause-btn');
|
||||
btn.textContent = logsPaused ? 'Resume' : 'Pause';
|
||||
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
|
||||
|
||||
if (!logsPaused) {
|
||||
// Flush buffer: oldest-first + prepend naturally puts newest at top
|
||||
@@ -2039,7 +2041,7 @@ function loadExtensions() {
|
||||
]).then(([extData, toolData, registryData]) => {
|
||||
// Render installed extensions
|
||||
if (extData.extensions.length === 0) {
|
||||
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
|
||||
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
|
||||
} else {
|
||||
extList.innerHTML = '';
|
||||
for (const ext of extData.extensions) {
|
||||
@@ -2053,7 +2055,7 @@ function loadExtensions() {
|
||||
|
||||
// Available WASM extensions
|
||||
if (wasmEntries.length === 0) {
|
||||
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
|
||||
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
|
||||
} else {
|
||||
wasmList.innerHTML = '';
|
||||
for (const entry of wasmEntries) {
|
||||
@@ -2063,7 +2065,7 @@ function loadExtensions() {
|
||||
|
||||
// MCP servers (show both installed and uninstalled)
|
||||
if (mcpEntries.length === 0) {
|
||||
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
|
||||
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
|
||||
} else {
|
||||
mcpList.innerHTML = '';
|
||||
for (const entry of mcpEntries) {
|
||||
@@ -2128,16 +2130,16 @@ function renderAvailableExtensionCard(entry) {
|
||||
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.textContent = I18n.t('extensions.install');
|
||||
installBtn.addEventListener('click', function() {
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = 'Installing...';
|
||||
installBtn.textContent = I18n.t('extensions.installing');
|
||||
apiFetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
body: { name: entry.name, kind: entry.kind },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Installed ' + entry.display_name, 'success');
|
||||
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
|
||||
// OAuth popup if auth started during install (builtin creds)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + entry.display_name, 'info');
|
||||
@@ -2201,39 +2203,39 @@ function renderMcpServerCard(entry, installedExt) {
|
||||
if (!installedExt.active) {
|
||||
var activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.textContent = I18n.t('common.activate');
|
||||
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
|
||||
actions.appendChild(activateBtn);
|
||||
} else {
|
||||
var activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = 'Active';
|
||||
activeLabel.textContent = I18n.t('ext.active');
|
||||
actions.appendChild(activeLabel);
|
||||
}
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.textContent = I18n.t('ext.remove');
|
||||
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
|
||||
actions.appendChild(removeBtn);
|
||||
} else {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.textContent = I18n.t('ext.install');
|
||||
installBtn.addEventListener('click', function() {
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = 'Installing...';
|
||||
installBtn.textContent = I18n.t('ext.installing');
|
||||
apiFetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
body: { name: entry.name, kind: entry.kind },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Installed ' + entry.display_name, 'success');
|
||||
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
|
||||
} else {
|
||||
showToast('Install: ' + (res.message || 'unknown error'), 'error');
|
||||
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
loadExtensions();
|
||||
}).catch(function(err) {
|
||||
showToast('Install failed: ' + err.message, 'error');
|
||||
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
|
||||
loadExtensions();
|
||||
});
|
||||
});
|
||||
@@ -2247,7 +2249,7 @@ function renderMcpServerCard(entry, installedExt) {
|
||||
function createReconfigureButton(extName) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn-ext configure';
|
||||
btn.textContent = 'Reconfigure';
|
||||
btn.textContent = I18n.t('ext.reconfigure');
|
||||
btn.addEventListener('click', function() { showConfigureModal(extName); });
|
||||
return btn;
|
||||
}
|
||||
@@ -2331,13 +2333,13 @@ function renderExtensionCard(ext) {
|
||||
if (status === 'active') {
|
||||
var activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = 'Active';
|
||||
activeLabel.textContent = I18n.t('ext.active');
|
||||
actions.appendChild(activeLabel);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else if (status === 'pairing') {
|
||||
var pairingLabel = document.createElement('span');
|
||||
pairingLabel.className = 'ext-pairing-label';
|
||||
pairingLabel.textContent = 'Awaiting Pairing';
|
||||
pairingLabel.textContent = I18n.t('status.awaitingPairing');
|
||||
actions.appendChild(pairingLabel);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else if (status === 'failed') {
|
||||
@@ -2346,7 +2348,7 @@ function renderExtensionCard(ext) {
|
||||
// installed or configured: show Setup button
|
||||
var setupBtn = document.createElement('button');
|
||||
setupBtn.className = 'btn-ext configure';
|
||||
setupBtn.textContent = 'Setup';
|
||||
setupBtn.textContent = I18n.t('ext.setup');
|
||||
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
|
||||
actions.appendChild(setupBtn);
|
||||
}
|
||||
@@ -2354,14 +2356,14 @@ function renderExtensionCard(ext) {
|
||||
// WASM tools / MCP servers
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
|
||||
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
|
||||
actions.appendChild(activeLabel);
|
||||
|
||||
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
|
||||
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.textContent = I18n.t('common.activate');
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
}
|
||||
@@ -2373,7 +2375,7 @@ function renderExtensionCard(ext) {
|
||||
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
|
||||
const configBtn = document.createElement('button');
|
||||
configBtn.className = 'btn-ext configure';
|
||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
|
||||
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
|
||||
actions.appendChild(configBtn);
|
||||
}
|
||||
@@ -2381,7 +2383,7 @@ function renderExtensionCard(ext) {
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.textContent = I18n.t('ext.remove');
|
||||
removeBtn.addEventListener('click', () => removeExtension(ext.name));
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
@@ -2426,17 +2428,17 @@ function activateExtension(name) {
|
||||
}
|
||||
|
||||
function removeExtension(name) {
|
||||
if (!confirm('Remove extension "' + name + '"?')) return;
|
||||
if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
showToast('Remove failed: ' + res.message, 'error');
|
||||
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
|
||||
} else {
|
||||
showToast('Removed ' + name, 'success');
|
||||
showToast(I18n.t('ext.removed', { name: name }), 'success');
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
|
||||
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
|
||||
}
|
||||
|
||||
function showConfigureModal(name) {
|
||||
@@ -2463,7 +2465,7 @@ function renderConfigureModal(name, secrets) {
|
||||
modal.className = 'configure-modal';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.textContent = 'Configure ' + name;
|
||||
header.textContent = I18n.t('config.title', { name: name });
|
||||
modal.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
@@ -2479,7 +2481,7 @@ function renderConfigureModal(name, secrets) {
|
||||
if (secret.optional) {
|
||||
const opt = document.createElement('span');
|
||||
opt.className = 'field-optional';
|
||||
opt.textContent = ' (optional)';
|
||||
opt.textContent = I18n.t('config.optional');
|
||||
label.appendChild(opt);
|
||||
}
|
||||
field.appendChild(label);
|
||||
@@ -2490,7 +2492,7 @@ function renderConfigureModal(name, secrets) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'password';
|
||||
input.name = secret.name;
|
||||
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
|
||||
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
||||
});
|
||||
@@ -2500,13 +2502,13 @@ function renderConfigureModal(name, secrets) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'field-provided';
|
||||
badge.textContent = '\u2713';
|
||||
badge.title = 'Already configured';
|
||||
badge.title = I18n.t('config.alreadyConfigured');
|
||||
inputRow.appendChild(badge);
|
||||
}
|
||||
if (secret.auto_generate && !secret.provided) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'field-autogen';
|
||||
hint.textContent = 'Auto-generated if empty';
|
||||
hint.textContent = I18n.t('config.autoGenerate');
|
||||
inputRow.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -2522,13 +2524,13 @@ function renderConfigureModal(name, secrets) {
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = 'Save';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn-ext remove';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.textContent = I18n.t('config.cancel');
|
||||
cancelBtn.addEventListener('click', closeConfigureModal);
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
@@ -2768,11 +2770,11 @@ function loadJobs() {
|
||||
|
||||
function renderJobsSummary(s) {
|
||||
document.getElementById('jobs-summary').innerHTML = ''
|
||||
+ summaryCard('Total', s.total, '')
|
||||
+ summaryCard('In Progress', s.in_progress, 'active')
|
||||
+ summaryCard('Completed', s.completed, 'completed')
|
||||
+ summaryCard('Failed', s.failed, 'failed')
|
||||
+ summaryCard('Stuck', s.stuck, 'stuck');
|
||||
+ summaryCard(I18n.t('jobs.summary.total'), s.total, '')
|
||||
+ summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active')
|
||||
+ summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed')
|
||||
+ summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed')
|
||||
+ summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck');
|
||||
}
|
||||
|
||||
function summaryCard(label, count, cls) {
|
||||
@@ -3302,11 +3304,11 @@ function loadRoutines() {
|
||||
|
||||
function renderRoutinesSummary(s) {
|
||||
document.getElementById('routines-summary').innerHTML = ''
|
||||
+ summaryCard('Total', s.total, '')
|
||||
+ summaryCard('Enabled', s.enabled, 'active')
|
||||
+ summaryCard('Disabled', s.disabled, '')
|
||||
+ summaryCard('Failing', s.failing, 'failed')
|
||||
+ summaryCard('Runs Today', s.runs_today, 'completed');
|
||||
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
|
||||
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
|
||||
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
|
||||
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
|
||||
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
|
||||
}
|
||||
|
||||
function renderRoutinesList(routines) {
|
||||
@@ -3472,17 +3474,18 @@ function formatRelativeTime(isoString) {
|
||||
const absDiff = Math.abs(diffMs);
|
||||
const future = diffMs < 0;
|
||||
|
||||
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
|
||||
if (absDiff < 60000)
|
||||
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
|
||||
if (absDiff < 3600000) {
|
||||
const m = Math.floor(absDiff / 60000);
|
||||
return future ? 'in ' + m + 'm' : m + 'm ago';
|
||||
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
|
||||
}
|
||||
if (absDiff < 86400000) {
|
||||
const h = Math.floor(absDiff / 3600000);
|
||||
return future ? 'in ' + h + 'h' : h + 'h ago';
|
||||
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
|
||||
}
|
||||
const days = Math.floor(absDiff / 86400000);
|
||||
return future ? 'in ' + days + 'd' : days + 'd ago';
|
||||
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
||||
}
|
||||
|
||||
// --- Gateway status widget ---
|
||||
@@ -3532,18 +3535,18 @@ function fetchGatewayStatus() {
|
||||
}
|
||||
|
||||
// Connection info
|
||||
html += '<div class="gw-section-label">Connections</div>';
|
||||
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
||||
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
||||
|
||||
// Cost tracker
|
||||
if (data.daily_cost != null) {
|
||||
html += '<div class="gw-divider"></div>';
|
||||
html += '<div class="gw-section-label">Cost Today</div>';
|
||||
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
||||
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
||||
if (data.actions_this_hour != null) {
|
||||
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3751,7 +3754,7 @@ function loadSkills() {
|
||||
var skillsList = document.getElementById('skills-list');
|
||||
apiFetch('/api/skills').then(function(data) {
|
||||
if (!data.skills || data.skills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
|
||||
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
|
||||
return;
|
||||
}
|
||||
skillsList.innerHTML = '';
|
||||
@@ -3759,7 +3762,7 @@ function loadSkills() {
|
||||
skillsList.appendChild(renderSkillCard(data.skills[i]));
|
||||
}
|
||||
}).catch(function(err) {
|
||||
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
|
||||
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3796,7 +3799,7 @@ function renderSkillCard(skill) {
|
||||
if (skill.keywords && skill.keywords.length > 0) {
|
||||
var kw = document.createElement('div');
|
||||
kw.className = 'ext-keywords';
|
||||
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
|
||||
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
|
||||
card.appendChild(kw);
|
||||
}
|
||||
|
||||
@@ -3807,7 +3810,7 @@ function renderSkillCard(skill) {
|
||||
if (skill.trust.toLowerCase() !== 'trusted') {
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.textContent = I18n.t('skills.remove');
|
||||
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
|
||||
actions.appendChild(removeBtn);
|
||||
}
|
||||
@@ -3822,7 +3825,7 @@ function searchClawHub() {
|
||||
if (!query) return;
|
||||
|
||||
var resultsDiv = document.getElementById('skill-search-results');
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
|
||||
|
||||
apiFetch('/api/skills/search', {
|
||||
method: 'POST',
|
||||
@@ -3838,7 +3841,7 @@ function searchClawHub() {
|
||||
warning.style.borderLeft = '3px solid #f0ad4e';
|
||||
warning.style.paddingLeft = '12px';
|
||||
warning.style.marginBottom = '16px';
|
||||
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
|
||||
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
|
||||
resultsDiv.appendChild(warning);
|
||||
}
|
||||
|
||||
@@ -3870,10 +3873,10 @@ function searchClawHub() {
|
||||
}
|
||||
|
||||
if (resultsDiv.children.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
|
||||
}
|
||||
}).catch(function(err) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3967,17 +3970,17 @@ function renderCatalogSkillCard(entry, installedNames) {
|
||||
if (isInstalled) {
|
||||
var label = document.createElement('span');
|
||||
label.className = 'ext-active-label';
|
||||
label.textContent = 'Installed';
|
||||
label.textContent = I18n.t('status.installed');
|
||||
actions.appendChild(label);
|
||||
} else {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.textContent = I18n.t('extensions.install');
|
||||
installBtn.addEventListener('click', (function(s, btn) {
|
||||
return function() {
|
||||
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing...';
|
||||
btn.textContent = I18n.t('extensions.installing');
|
||||
installSkill(s, null, btn);
|
||||
};
|
||||
})(slug, installBtn));
|
||||
@@ -4019,7 +4022,7 @@ function installSkill(nameOrSlug, url, btn) {
|
||||
body: body,
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Installed skill "' + nameOrSlug + '"', 'success');
|
||||
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
|
||||
} else {
|
||||
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
@@ -4032,19 +4035,19 @@ function installSkill(nameOrSlug, url, btn) {
|
||||
}
|
||||
|
||||
function removeSkill(name) {
|
||||
if (!confirm('Remove skill "' + name + '"?')) return;
|
||||
if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
|
||||
apiFetch('/api/skills/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Confirm-Action': 'true' },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Removed skill "' + name + '"', 'success');
|
||||
showToast(I18n.t('skills.removed', { name: name }), 'success');
|
||||
} else {
|
||||
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
|
||||
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
|
||||
}
|
||||
loadSkills();
|
||||
}).catch(function(err) {
|
||||
showToast('Remove failed: ' + err.message, 'error');
|
||||
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// i18n Integration for IronClaw App
|
||||
// This file contains i18n-related functions that extend app.js
|
||||
|
||||
// Initialize i18n when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize i18n
|
||||
I18n.init();
|
||||
I18n.updatePageContent();
|
||||
updateSlashCommands();
|
||||
updateLanguageMenu();
|
||||
});
|
||||
|
||||
// Update slash commands with current language
|
||||
function updateSlashCommands() {
|
||||
// Update SLASH_COMMANDS descriptions
|
||||
SLASH_COMMANDS.forEach(cmd => {
|
||||
const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc';
|
||||
const translated = I18n.t(key);
|
||||
if (translated !== key) {
|
||||
cmd.desc = translated;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle language menu
|
||||
function toggleLanguageMenu() {
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Switch language
|
||||
function switchLanguage(lang) {
|
||||
if (I18n.setLanguage(lang)) {
|
||||
// Update slash commands
|
||||
updateSlashCommands();
|
||||
|
||||
// Update language menu active state
|
||||
updateLanguageMenu();
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
|
||||
}
|
||||
}
|
||||
|
||||
// Update language menu active state
|
||||
function updateLanguageMenu() {
|
||||
const currentLang = I18n.getCurrentLang();
|
||||
document.querySelectorAll('.language-option').forEach(option => {
|
||||
if (option.getAttribute('data-lang') === currentLang) {
|
||||
option.classList.add('active');
|
||||
} else {
|
||||
option.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close language menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.language-switcher')) {
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// English Language Pack for IronClaw
|
||||
|
||||
I18n.register('en', {
|
||||
// Auth Page
|
||||
'auth.title': 'IronClaw',
|
||||
'auth.tagline': 'Secure AI Assistant',
|
||||
'auth.tokenLabel': 'Gateway Token',
|
||||
'auth.tokenPlaceholder': 'Paste your token',
|
||||
'auth.connect': 'Connect',
|
||||
'auth.errorRequired': 'Token required',
|
||||
'auth.errorInvalid': 'Invalid token',
|
||||
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
|
||||
|
||||
// Chat
|
||||
'chat.inputPlaceholder': 'Message or / for commands...',
|
||||
|
||||
// Restart Modal
|
||||
'restart.title': 'Restart IronClaw Instance',
|
||||
'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.',
|
||||
'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.',
|
||||
'restart.cancel': 'Cancel',
|
||||
'restart.confirm': 'Confirm Restart',
|
||||
'restart.progressTitle': 'Restarting IronClaw',
|
||||
'restart.progressSubtitle': 'Please wait for the process to restart...',
|
||||
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
|
||||
|
||||
// Tabs
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
'tab.jobs': 'Jobs',
|
||||
'tab.routines': 'Routines',
|
||||
'tab.extensions': 'Extensions',
|
||||
'tab.skills': 'Skills',
|
||||
'tab.logs': 'Logs',
|
||||
|
||||
// Status
|
||||
'status.connected': 'Connected',
|
||||
'status.disconnected': 'Disconnected',
|
||||
'status.connecting': 'Connecting...',
|
||||
'status.reconnecting': 'Reconnecting...',
|
||||
'status.teeVerified': 'TEE Verified',
|
||||
'status.restart': 'Restart',
|
||||
'status.active': 'Active',
|
||||
'status.installed': 'Installed',
|
||||
'status.awaitingPairing': 'Awaiting Pairing',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.connections': 'Connections',
|
||||
'dashboard.uptime': 'Uptime',
|
||||
'dashboard.costToday': 'Cost Today',
|
||||
'dashboard.spent': 'Spent',
|
||||
'dashboard.actionsPerHour': 'Actions/hr',
|
||||
'dashboard.sse': 'SSE',
|
||||
'dashboard.websocket': 'WebSocket',
|
||||
|
||||
// Chat Tab
|
||||
'chat.newThread': 'New Thread',
|
||||
'chat.toggleSidebar': 'Toggle Sidebar',
|
||||
'chat.assistant': 'Assistant',
|
||||
'chat.conversations': 'Conversations',
|
||||
'chat.send': 'Send',
|
||||
'chat.attachImages': 'Attach Images',
|
||||
'chat.empty': 'Select a file to view content',
|
||||
'chat.loading': 'Loading...',
|
||||
'chat.loadingOlder': 'Loading older messages...',
|
||||
'chat.noFiles': 'No files in workspace',
|
||||
'chat.noResults': 'No results',
|
||||
|
||||
// Thread Sidebar
|
||||
'thread.assistant': 'Assistant',
|
||||
'thread.new': 'New Thread',
|
||||
|
||||
// Memory Tab
|
||||
'memory.searchPlaceholder': 'Search memory...',
|
||||
'memory.workspace': 'workspace',
|
||||
'memory.edit': 'Edit',
|
||||
'memory.save': 'Save',
|
||||
'memory.cancel': 'Cancel',
|
||||
'memory.selectFile': 'Select a file to view content',
|
||||
|
||||
// Jobs Tab
|
||||
'jobs.summary': 'Jobs Summary',
|
||||
'jobs.id': 'ID',
|
||||
'jobs.title': 'Title',
|
||||
'jobs.source': 'Source',
|
||||
'jobs.status': 'Status',
|
||||
'jobs.created': 'Created',
|
||||
'jobs.actions': 'Actions',
|
||||
'jobs.empty': 'No jobs',
|
||||
'jobs.statusRunning': 'Running',
|
||||
'jobs.statusCompleted': 'Completed',
|
||||
'jobs.statusFailed': 'Failed',
|
||||
'jobs.statusPending': 'Pending',
|
||||
'jobs.jobId': 'Job ID',
|
||||
'jobs.description': 'Description',
|
||||
'jobs.stateTransitions': 'State Transitions',
|
||||
'jobs.projectFiles': 'Project Files',
|
||||
'jobs.noProjectFiles': 'No project files',
|
||||
'jobs.viewJob': 'View Job',
|
||||
'jobs.browse': 'Browse',
|
||||
|
||||
// Routines Tab
|
||||
'routines.summary': 'Routines Summary',
|
||||
'routines.name': 'Name',
|
||||
'routines.trigger': 'Trigger',
|
||||
'routines.action': 'Action',
|
||||
'routines.lastRun': 'Last Run',
|
||||
'routines.nextRun': 'Next Run',
|
||||
'routines.runs': 'Runs',
|
||||
'routines.status': 'Status',
|
||||
'routines.actions': 'Actions',
|
||||
'routines.runsToday': 'Runs Today',
|
||||
'routines.empty': 'No routines',
|
||||
'routines.noConfigured': 'No routines configured. Ask the assistant to create one.',
|
||||
'routines.triggerFailed': 'Trigger failed: {message}',
|
||||
|
||||
// Logs Tab
|
||||
'logs.serverLevel': 'Server: ERROR',
|
||||
'logs.clientLevel': 'Client Log Level',
|
||||
'logs.pause': 'Pause',
|
||||
'logs.resume': 'Resume',
|
||||
'logs.clear': 'Clear',
|
||||
'logs.autoScroll': 'Auto-scroll',
|
||||
'logs.filter': 'Filter logs...',
|
||||
'logs.empty': 'No logs',
|
||||
'logs.allLevels': 'All Levels',
|
||||
'logs.error': 'Error',
|
||||
'logs.warn': 'Warn',
|
||||
'logs.info': 'Info',
|
||||
'logs.debug': 'Debug',
|
||||
|
||||
// Extensions Tab
|
||||
'extensions.installed': 'Installed Extensions',
|
||||
'extensions.available': 'Available WASM Extensions',
|
||||
'extensions.installWasm': 'Install WASM Extension',
|
||||
'extensions.noInstalled': 'No extensions installed',
|
||||
'extensions.noAvailable': 'No additional WASM extensions available',
|
||||
'extensions.loading': 'Loading...',
|
||||
'extensions.install': 'Install',
|
||||
'extensions.installing': 'Installing...',
|
||||
'extensions.installedSuccess': 'Installed {name}',
|
||||
'extensions.remove': 'Remove',
|
||||
'extensions.activate': 'Activate',
|
||||
'extensions.reconfigure': 'Reconfigure',
|
||||
'extensions.tools': 'Tools',
|
||||
'extensions.noConfigNeeded': 'No configuration needed for {name}',
|
||||
'extensions.configure': 'Configure {name}',
|
||||
'extensions.optional': ' (optional)',
|
||||
'extensions.autoGenerated': 'Auto-generated if empty',
|
||||
'extensions.pendingPairing': 'Pending pairing requests',
|
||||
'extensions.from': 'from',
|
||||
|
||||
// MCP Servers
|
||||
'mcp.servers': 'MCP Servers',
|
||||
'mcp.noServers': 'No MCP servers available',
|
||||
'mcp.addCustom': 'Add Custom MCP Server',
|
||||
'mcp.add': 'Add',
|
||||
'mcp.addedSuccess': 'Added MCP server {name}',
|
||||
|
||||
// Registered Tools
|
||||
'tools.registered': 'Registered Tools',
|
||||
'tools.name': 'Name',
|
||||
'tools.description': 'Description',
|
||||
'tools.empty': 'No tools registered',
|
||||
|
||||
// Skills Tab
|
||||
'skills.installed': 'Installed Skills',
|
||||
'skills.noInstalled': 'No skills installed',
|
||||
'skills.searchClawHub': 'Search ClawHub',
|
||||
'skills.searchPlaceholder': 'Search...',
|
||||
'skills.installByUrl': 'Install Skill by URL',
|
||||
'skills.namePlaceholder': 'Skill name or slug',
|
||||
'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)',
|
||||
'skills.search': 'Search',
|
||||
'skills.searching': 'Searching...',
|
||||
'skills.noResults': 'No skills found for "{query}"',
|
||||
'skills.searchFailed': 'Search failed: {message}',
|
||||
'skills.install': 'Install',
|
||||
'skills.installing': 'Installing...',
|
||||
'skills.installedSuccess': 'Installed skill "{name}"',
|
||||
'skills.remove': 'Remove',
|
||||
'skills.activatesOn': 'Activates on',
|
||||
'skills.registryError': 'Could not reach ClawHub registry: {message}',
|
||||
'skills.by': 'by',
|
||||
'skills.updated': 'updated',
|
||||
'skills.loading': 'Loading skills...',
|
||||
'skills.loadFailed': 'Failed to load skills: {message}',
|
||||
'skills.confirmRemove': 'Remove skill "{name}"?',
|
||||
'skills.removeFailed': 'Remove failed: {message}',
|
||||
'skills.removed': 'Removed skill "{name}"',
|
||||
|
||||
// Jobs Summary
|
||||
'jobs.summary.total': 'Total',
|
||||
'jobs.summary.inProgress': 'In Progress',
|
||||
'jobs.summary.completed': 'Completed',
|
||||
'jobs.summary.failed': 'Failed',
|
||||
'jobs.summary.stuck': 'Stuck',
|
||||
|
||||
// Routines Summary
|
||||
'routines.summary.total': 'Total',
|
||||
'routines.summary.enabled': 'Enabled',
|
||||
'routines.summary.disabled': 'Disabled',
|
||||
'routines.summary.failing': 'Failing',
|
||||
'routines.summary.runsToday': 'Runs Today',
|
||||
|
||||
// Buttons
|
||||
'btn.close': 'Close',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.save': 'Save',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.confirm': 'Confirm',
|
||||
'btn.send': 'Send',
|
||||
'btn.refresh': 'Refresh',
|
||||
'btn.loadMore': 'Load More',
|
||||
'btn.copy': 'Copy',
|
||||
'btn.copied': 'Copied!',
|
||||
'btn.submit': 'Submit',
|
||||
'btn.setup': 'Setup',
|
||||
|
||||
// Time
|
||||
'time.lessThan1MinuteAgo': '<1m ago',
|
||||
'time.lessThan1MinuteFromNow': 'in <1m',
|
||||
'time.minutesAgo': '{n}m ago',
|
||||
'time.minutesFromNow': 'in {n}m',
|
||||
'time.hoursAgo': '{n}h ago',
|
||||
'time.hoursFromNow': 'in {n}h',
|
||||
'time.daysAgo': '{n}d ago',
|
||||
'time.daysFromNow': 'in {n}d',
|
||||
|
||||
// Tool Approval
|
||||
'approval.title': 'Tool requires approval',
|
||||
'approval.description': 'A tool is requesting permission to run.',
|
||||
'approval.approve': 'Approve',
|
||||
'approval.deny': 'Deny',
|
||||
'approval.always': 'Always',
|
||||
'approval.approved': 'Approved',
|
||||
'approval.alwaysApproved': 'Always approved',
|
||||
'approval.denied': 'Denied',
|
||||
'approval.showParams': 'Show parameters',
|
||||
'approval.hideParams': 'Hide parameters',
|
||||
|
||||
// Authentication Required
|
||||
'authRequired.title': 'Authentication required for {name}',
|
||||
'authRequired.authenticateWith': 'Authenticate with {name}',
|
||||
'authRequired.getToken': 'Get your token',
|
||||
'authRequired.instructions': 'Instructions',
|
||||
|
||||
// Sandbox Jobs
|
||||
'sandbox.job': 'Sandbox Job',
|
||||
'sandbox.doneSignal': 'Done signal sent',
|
||||
|
||||
// Error Messages
|
||||
'error.startConversation': 'Please start a conversation first',
|
||||
'error.restartFailed': 'Restart failed: {message}',
|
||||
'error.tokenRequired': 'Token required',
|
||||
'error.tokenInvalid': 'Invalid token',
|
||||
'error.connectionFailed': 'Connection failed',
|
||||
'error.unknown': 'Unknown error',
|
||||
'error.loadFailed': 'Failed to load: {message}',
|
||||
|
||||
// Success Messages
|
||||
'success.restartInitiated': 'Restart initiated',
|
||||
'success.saved': 'Saved successfully',
|
||||
|
||||
// Slash Commands
|
||||
'cmd.status.desc': 'Show all jobs, or /status <id> for a specific job',
|
||||
'cmd.list.desc': 'List all jobs',
|
||||
'cmd.cancel.desc': '/cancel <job-id> — Cancel a running job',
|
||||
'cmd.undo.desc': 'Undo last action',
|
||||
'cmd.redo.desc': 'Redo undone action',
|
||||
'cmd.compact.desc': 'Compact context window',
|
||||
'cmd.clear.desc': 'Clear conversation and start fresh',
|
||||
'cmd.interrupt.desc': 'Stop current operation',
|
||||
'cmd.heartbeat.desc': 'Trigger manual heartbeat check',
|
||||
'cmd.summarize.desc': 'Summarize current conversation',
|
||||
'cmd.suggest.desc': 'Suggest next actions',
|
||||
'cmd.help.desc': 'Show help',
|
||||
'cmd.version.desc': 'Show version info',
|
||||
'cmd.tools.desc': 'List available tools',
|
||||
'cmd.skills.desc': 'List installed skills',
|
||||
'cmd.model.desc': 'Show or switch LLM model',
|
||||
'cmd.threadNew.desc': 'Create new conversation thread',
|
||||
|
||||
// Language Switcher
|
||||
'language.title': 'Language',
|
||||
'language.en': 'English',
|
||||
'language.zhCN': '简体中文',
|
||||
'language.switch': 'Switch Language',
|
||||
|
||||
// Tool Activity
|
||||
'tool.thinking': 'Thinking...',
|
||||
'tool.completed': 'Completed',
|
||||
'tool.failed': 'Failed',
|
||||
'tool.running': 'Running',
|
||||
'tool.used': '{count} tool(s) used',
|
||||
'tool.requiresApproval': 'Tool requires approval',
|
||||
|
||||
|
||||
// TEE
|
||||
'tee.loadingReport': 'Loading attestation report...',
|
||||
'tee.loadFailed': 'Could not load attestation report',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Loading...',
|
||||
'common.noData': 'No data',
|
||||
'common.search': 'Search',
|
||||
'common.add': 'Add',
|
||||
'common.remove': 'Remove',
|
||||
'common.install': 'Install',
|
||||
'common.activate': 'Activate',
|
||||
'common.deactivate': 'Deactivate',
|
||||
'common.configure': 'Configure',
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.close': 'Close',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Delete',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
'common.name': 'Name',
|
||||
'common.description': 'Description',
|
||||
'common.status': 'Status',
|
||||
'common.actions': 'Actions',
|
||||
'common.version': 'Version',
|
||||
'common.owner': 'Owner',
|
||||
'common.tags': 'Tags',
|
||||
|
||||
// Extensions
|
||||
'ext.active': 'Active',
|
||||
'ext.remove': 'Remove',
|
||||
'ext.install': 'Install',
|
||||
'ext.installing': 'Installing...',
|
||||
'ext.installed': 'Installed',
|
||||
'ext.setup': 'Setup',
|
||||
'ext.reconfigure': 'Reconfigure',
|
||||
'ext.configure': 'Configure',
|
||||
'ext.confirmRemove': 'Remove extension "{name}"?',
|
||||
'ext.removeFailed': 'Remove failed: {message}',
|
||||
'ext.removed': 'Removed {name}',
|
||||
'ext.installFailed': 'Install failed: {message}',
|
||||
|
||||
// Configure
|
||||
'config.title': 'Configure {name}',
|
||||
'config.optional': ' (optional)',
|
||||
'config.alreadySet': '(already set — leave empty to keep)',
|
||||
'config.alreadyConfigured': 'Already configured',
|
||||
'config.autoGenerate': 'Auto-generated if empty',
|
||||
'config.save': 'Save',
|
||||
'config.cancel': 'Cancel',
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Lightweight internationalization implementation with dynamic language switching
|
||||
|
||||
const I18n = {
|
||||
currentLang: 'en',
|
||||
fallbackLang: 'en',
|
||||
translations: {},
|
||||
|
||||
// Initialize i18n
|
||||
init() {
|
||||
// Read user preference from localStorage
|
||||
const savedLang = localStorage.getItem('ironclaw_language');
|
||||
if (savedLang && this.translations[savedLang]) {
|
||||
this.currentLang = savedLang;
|
||||
} else {
|
||||
// Detect browser language
|
||||
const browserLang = navigator.language || navigator.userLanguage;
|
||||
this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en';
|
||||
}
|
||||
this.updateHtmlLang();
|
||||
},
|
||||
|
||||
// Register language pack
|
||||
register(lang, translations) {
|
||||
this.translations[lang] = translations;
|
||||
},
|
||||
|
||||
// Switch language
|
||||
setLanguage(lang) {
|
||||
if (this.translations[lang]) {
|
||||
this.currentLang = lang;
|
||||
localStorage.setItem('ironclaw_language', lang);
|
||||
this.updateHtmlLang();
|
||||
this.updatePageContent();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Get current language
|
||||
getCurrentLang() {
|
||||
return this.currentLang;
|
||||
},
|
||||
|
||||
// Translate function
|
||||
t(key, params = {}) {
|
||||
const translation = this.translations[this.currentLang]?.[key]
|
||||
|| this.translations[this.fallbackLang]?.[key]
|
||||
|| key;
|
||||
|
||||
// Support placeholder replacement: {name}
|
||||
return translation.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
return params[key] !== undefined ? params[key] : match;
|
||||
});
|
||||
},
|
||||
|
||||
// Update HTML lang attribute
|
||||
updateHtmlLang() {
|
||||
document.documentElement.lang = this.currentLang;
|
||||
},
|
||||
|
||||
// Update page content (traverse all data-i18n elements)
|
||||
updatePageContent() {
|
||||
// Update text content
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const attr = el.getAttribute('data-i18n-attr');
|
||||
if (attr) {
|
||||
el.setAttribute(attr, this.t(key));
|
||||
} else {
|
||||
el.textContent = this.t(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Update placeholder attributes
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// Update title attributes
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = this.t(key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Global access
|
||||
window.I18n = I18n;
|
||||
@@ -0,0 +1,351 @@
|
||||
// 中文语言包 for IronClaw
|
||||
|
||||
I18n.register('zh-CN', {
|
||||
// 认证页面
|
||||
'auth.title': 'IronClaw',
|
||||
'auth.tagline': '安全可靠的 AI 助手',
|
||||
'auth.tokenLabel': '网关令牌',
|
||||
'auth.tokenPlaceholder': '粘贴你的网关令牌',
|
||||
'auth.connect': '连接',
|
||||
'auth.errorRequired': '请输入令牌',
|
||||
'auth.errorInvalid': '令牌无效',
|
||||
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
|
||||
|
||||
// 聊天
|
||||
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
|
||||
|
||||
// 重启弹窗
|
||||
'restart.title': '重启 IronClaw 实例',
|
||||
'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。',
|
||||
'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。',
|
||||
'restart.cancel': '取消',
|
||||
'restart.confirm': '确认重启',
|
||||
'restart.progressTitle': '正在重启 IronClaw',
|
||||
'restart.progressSubtitle': '请等待进程重启...',
|
||||
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
|
||||
|
||||
// 标签页
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
'tab.jobs': '任务',
|
||||
'tab.routines': '定时任务',
|
||||
'tab.extensions': '扩展',
|
||||
'tab.skills': '技能',
|
||||
'tab.logs': '日志',
|
||||
|
||||
// 状态
|
||||
'status.connected': '已连接',
|
||||
'status.disconnected': '已断开',
|
||||
'status.connecting': '连接中...',
|
||||
'status.reconnecting': '重新连接中...',
|
||||
'status.teeVerified': 'TEE 已验证',
|
||||
'status.restart': '重启',
|
||||
'status.active': '已激活',
|
||||
'status.installed': '已安装',
|
||||
'status.awaitingPairing': '等待配对',
|
||||
|
||||
// 仪表盘
|
||||
'dashboard.connections': '连接数',
|
||||
'dashboard.uptime': '运行时间',
|
||||
'dashboard.costToday': '今日费用',
|
||||
'dashboard.spent': '已花费',
|
||||
'dashboard.actionsPerHour': '每小时操作',
|
||||
'dashboard.sse': 'SSE',
|
||||
'dashboard.websocket': 'WebSocket',
|
||||
|
||||
// 聊天标签页
|
||||
'chat.newThread': '新对话',
|
||||
'chat.toggleSidebar': '切换侧边栏',
|
||||
'chat.assistant': '助手',
|
||||
'chat.conversations': '对话列表',
|
||||
'chat.send': '发送',
|
||||
'chat.attachImages': '附加图片',
|
||||
'chat.empty': '选择文件查看内容',
|
||||
'chat.loading': '加载中...',
|
||||
'chat.loadingOlder': '加载更早的消息...',
|
||||
'chat.noFiles': '工作区没有文件',
|
||||
'chat.noResults': '没有结果',
|
||||
|
||||
// 对话侧边栏
|
||||
'thread.assistant': '助手',
|
||||
'thread.new': '新对话',
|
||||
|
||||
// 记忆标签页
|
||||
'memory.searchPlaceholder': '搜索记忆...',
|
||||
'memory.workspace': '工作区',
|
||||
'memory.edit': '编辑',
|
||||
'memory.save': '保存',
|
||||
'memory.cancel': '取消',
|
||||
'memory.selectFile': '选择文件查看内容',
|
||||
|
||||
// 任务标签页
|
||||
'jobs.summary': '任务摘要',
|
||||
'jobs.id': 'ID',
|
||||
'jobs.title': '标题',
|
||||
'jobs.source': '来源',
|
||||
'jobs.status': '状态',
|
||||
'jobs.created': '创建时间',
|
||||
'jobs.actions': '操作',
|
||||
'jobs.empty': '暂无任务',
|
||||
'jobs.statusRunning': '运行中',
|
||||
'jobs.statusCompleted': '已完成',
|
||||
'jobs.statusFailed': '失败',
|
||||
'jobs.statusPending': '等待中',
|
||||
'jobs.jobId': '任务 ID',
|
||||
'jobs.description': '描述',
|
||||
'jobs.stateTransitions': '状态转换',
|
||||
'jobs.projectFiles': '项目文件',
|
||||
'jobs.noProjectFiles': '没有项目文件',
|
||||
'jobs.viewJob': '查看任务',
|
||||
'jobs.browse': '浏览',
|
||||
|
||||
// 定时任务标签页
|
||||
'routines.summary': '定时任务摘要',
|
||||
'routines.name': '名称',
|
||||
'routines.trigger': '触发器',
|
||||
'routines.action': '操作',
|
||||
'routines.lastRun': '上次运行',
|
||||
'routines.nextRun': '下次运行',
|
||||
'routines.runs': '运行次数',
|
||||
'routines.status': '状态',
|
||||
'routines.actions': '操作',
|
||||
'routines.runsToday': '今日运行',
|
||||
'routines.empty': '暂无定时任务',
|
||||
'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。',
|
||||
'routines.triggerFailed': '触发失败: {message}',
|
||||
|
||||
// 日志标签页
|
||||
'logs.serverLevel': '服务端日志级别',
|
||||
'logs.clientLevel': '客户端日志级别',
|
||||
'logs.pause': '暂停',
|
||||
'logs.resume': '继续',
|
||||
'logs.clear': '清空',
|
||||
'logs.autoScroll': '自动滚动',
|
||||
'logs.filter': '筛选日志...',
|
||||
'logs.empty': '暂无日志',
|
||||
'logs.allLevels': '所有级别',
|
||||
'logs.error': '错误',
|
||||
'logs.warn': '警告',
|
||||
'logs.info': '信息',
|
||||
'logs.debug': '调试',
|
||||
|
||||
// 扩展标签页
|
||||
'extensions.installed': '已安装扩展',
|
||||
'extensions.available': '可用 WASM 扩展',
|
||||
'extensions.installWasm': '安装 WASM 扩展',
|
||||
'extensions.noInstalled': '没有安装扩展',
|
||||
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
|
||||
'extensions.loading': '加载中...',
|
||||
'extensions.install': '安装',
|
||||
'extensions.installing': '安装中...',
|
||||
'extensions.installedSuccess': '已安装 {name}',
|
||||
'extensions.remove': '移除',
|
||||
'extensions.activate': '激活',
|
||||
'extensions.reconfigure': '重新配置',
|
||||
'extensions.tools': '工具',
|
||||
'extensions.noConfigNeeded': '{name} 不需要配置',
|
||||
'extensions.configure': '配置 {name}',
|
||||
'extensions.optional': ' (可选)',
|
||||
'extensions.autoGenerated': '留空则自动生成',
|
||||
'extensions.pendingPairing': '等待配对请求',
|
||||
'extensions.from': '来自',
|
||||
|
||||
// MCP 服务器
|
||||
'mcp.servers': 'MCP 服务器',
|
||||
'mcp.noServers': '没有可用的 MCP 服务器',
|
||||
'mcp.addCustom': '添加自定义 MCP 服务器',
|
||||
'mcp.add': '添加',
|
||||
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
|
||||
|
||||
// 注册工具
|
||||
'tools.registered': '注册工具',
|
||||
'tools.name': '名称',
|
||||
'tools.description': '描述',
|
||||
'tools.empty': '没有注册工具',
|
||||
|
||||
// 技能标签页
|
||||
'skills.installed': '已安装技能',
|
||||
'skills.noInstalled': '没有安装技能',
|
||||
'skills.searchClawHub': '搜索 ClawHub',
|
||||
'skills.searchPlaceholder': '搜索...',
|
||||
'skills.installByUrl': '通过 URL 安装技能',
|
||||
'skills.namePlaceholder': '技能名称或标识',
|
||||
'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)',
|
||||
'skills.search': '搜索',
|
||||
'skills.searching': '搜索中...',
|
||||
'skills.noResults': '没有找到 "{query}" 相关技能',
|
||||
'skills.searchFailed': '搜索失败: {message}',
|
||||
'skills.install': '安装',
|
||||
'skills.installing': '安装中...',
|
||||
'skills.installedSuccess': '已安装技能 "{name}"',
|
||||
'skills.remove': '移除',
|
||||
'skills.activatesOn': '激活关键词',
|
||||
'skills.registryError': '无法连接 ClawHub 注册表: {message}',
|
||||
'skills.by': '作者',
|
||||
'skills.updated': '更新于',
|
||||
'skills.loading': '加载技能中...',
|
||||
'skills.loadFailed': '加载技能失败: {message}',
|
||||
'skills.confirmRemove': '确定要移除技能 "{name}" 吗?',
|
||||
'skills.removeFailed': '移除失败: {message}',
|
||||
'skills.removed': '已移除技能 "{name}"',
|
||||
|
||||
// 任务摘要
|
||||
'jobs.summary.total': '总计',
|
||||
'jobs.summary.inProgress': '进行中',
|
||||
'jobs.summary.completed': '已完成',
|
||||
'jobs.summary.failed': '失败',
|
||||
'jobs.summary.stuck': '卡住',
|
||||
|
||||
// 定时任务摘要
|
||||
'routines.summary.total': '总计',
|
||||
'routines.summary.enabled': '已启用',
|
||||
'routines.summary.disabled': '已禁用',
|
||||
'routines.summary.failing': '失败',
|
||||
'routines.summary.runsToday': '今日运行',
|
||||
|
||||
// 按钮
|
||||
'btn.close': '关闭',
|
||||
'btn.cancel': '取消',
|
||||
'btn.save': '保存',
|
||||
'btn.edit': '编辑',
|
||||
'btn.confirm': '确认',
|
||||
'btn.send': '发送',
|
||||
'btn.refresh': '刷新',
|
||||
'btn.loadMore': '加载更多',
|
||||
'btn.copy': '复制',
|
||||
'btn.copied': '已复制!',
|
||||
'btn.submit': '提交',
|
||||
'btn.setup': '设置',
|
||||
|
||||
// 时间
|
||||
'time.lessThan1MinuteAgo': '刚刚',
|
||||
'time.lessThan1MinuteFromNow': '1分钟内',
|
||||
'time.minutesAgo': '{n}分钟前',
|
||||
'time.minutesFromNow': '{n}分钟后',
|
||||
'time.hoursAgo': '{n}小时前',
|
||||
'time.hoursFromNow': '{n}小时后',
|
||||
'time.daysAgo': '{n}天前',
|
||||
'time.daysFromNow': '{n}天后',
|
||||
|
||||
// 工具审批
|
||||
'approval.title': '工具需要审批',
|
||||
'approval.description': '一个工具请求运行权限。',
|
||||
'approval.approve': '批准',
|
||||
'approval.deny': '拒绝',
|
||||
'approval.always': '始终允许',
|
||||
'approval.approved': '已批准',
|
||||
'approval.alwaysApproved': '始终批准',
|
||||
'approval.denied': '已拒绝',
|
||||
'approval.showParams': '显示参数',
|
||||
'approval.hideParams': '隐藏参数',
|
||||
|
||||
// 认证
|
||||
'authRequired.title': '{name} 需要认证',
|
||||
'authRequired.authenticateWith': '使用 {name} 认证',
|
||||
'authRequired.getToken': '获取令牌',
|
||||
'authRequired.instructions': '说明',
|
||||
|
||||
// 沙盒任务
|
||||
'sandbox.job': '沙盒任务',
|
||||
'sandbox.doneSignal': '完成信号已发送',
|
||||
|
||||
// 错误消息
|
||||
'error.startConversation': '请先开始一个对话',
|
||||
'error.restartFailed': '重启失败: {message}',
|
||||
'error.tokenRequired': '请输入令牌',
|
||||
'error.tokenInvalid': '令牌无效',
|
||||
'error.connectionFailed': '连接失败',
|
||||
'error.unknown': '未知错误',
|
||||
'error.loadFailed': '加载失败: {message}',
|
||||
|
||||
// 成功消息
|
||||
'success.restartInitiated': '已开始重启',
|
||||
'success.saved': '保存成功',
|
||||
|
||||
// 斜杠命令
|
||||
'cmd.status.desc': '显示所有任务,或使用 /status <id> 查看特定任务',
|
||||
'cmd.list.desc': '列出所有任务',
|
||||
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
|
||||
'cmd.undo.desc': '撤销上一步',
|
||||
'cmd.redo.desc': '重做已撤销的操作',
|
||||
'cmd.compact.desc': '压缩上下文窗口',
|
||||
'cmd.clear.desc': '清空对话并重新开始',
|
||||
'cmd.interrupt.desc': '停止当前操作',
|
||||
'cmd.heartbeat.desc': '触发手动心跳检查',
|
||||
'cmd.summarize.desc': '总结当前对话',
|
||||
'cmd.suggest.desc': '建议下一步操作',
|
||||
'cmd.help.desc': '显示帮助',
|
||||
'cmd.version.desc': '显示版本信息',
|
||||
'cmd.tools.desc': '列出可用工具',
|
||||
'cmd.skills.desc': '列出已安装的 AI 技能',
|
||||
'cmd.model.desc': '显示或切换 LLM 模型',
|
||||
'cmd.threadNew.desc': '创建新对话线程',
|
||||
|
||||
// 语言切换
|
||||
'language.title': '语言',
|
||||
'language.en': 'English',
|
||||
'language.zhCN': '简体中文',
|
||||
'language.switch': '切换语言',
|
||||
|
||||
// 工具活动
|
||||
'tool.thinking': '思考中...',
|
||||
'tool.completed': '已完成',
|
||||
'tool.failed': '失败',
|
||||
'tool.running': '运行中',
|
||||
'tool.used': '{count} 个工具已使用',
|
||||
'tool.requiresApproval': '工具需要审批',
|
||||
|
||||
|
||||
// TEE
|
||||
'tee.loadingReport': '正在加载证明报告...',
|
||||
'tee.loadFailed': '无法加载证明报告',
|
||||
|
||||
// 通用
|
||||
'common.loading': '加载中...',
|
||||
'common.noData': '暂无数据',
|
||||
'common.search': '搜索',
|
||||
'common.add': '添加',
|
||||
'common.remove': '移除',
|
||||
'common.install': '安装',
|
||||
'common.activate': '激活',
|
||||
'common.deactivate': '停用',
|
||||
'common.configure': '配置',
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.close': '关闭',
|
||||
'common.edit': '编辑',
|
||||
'common.delete': '删除',
|
||||
'common.refresh': '刷新',
|
||||
'common.searchPlaceholder': '搜索...',
|
||||
'common.name': '名称',
|
||||
'common.description': '描述',
|
||||
'common.status': '状态',
|
||||
'common.actions': '操作',
|
||||
'common.version': '版本',
|
||||
'common.owner': '作者',
|
||||
'common.tags': '标签',
|
||||
|
||||
// 扩展
|
||||
'ext.active': '已激活',
|
||||
'ext.remove': '移除',
|
||||
'ext.install': '安装',
|
||||
'ext.installing': '安装中...',
|
||||
'ext.installed': '已安装',
|
||||
'ext.setup': '设置',
|
||||
'ext.reconfigure': '重新配置',
|
||||
'ext.configure': '配置',
|
||||
'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?',
|
||||
'ext.removeFailed': '移除失败: {message}',
|
||||
'ext.removed': '已移除 {name}',
|
||||
'ext.installFailed': '安装失败: {message}',
|
||||
|
||||
// 配置
|
||||
'config.title': '配置 {name}',
|
||||
'config.optional': '(可选)',
|
||||
'config.alreadySet': '(已设置 — 留空以保持不变)',
|
||||
'config.alreadyConfigured': '已配置',
|
||||
'config.autoGenerate': '如果为空则自动生成',
|
||||
'config.save': '保存',
|
||||
'config.cancel': '取消',
|
||||
});
|
||||
@@ -9,6 +9,12 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
|
||||
<!-- i18n Modules -->
|
||||
<script src="/i18n/index.js"></script>
|
||||
<script src="/i18n/en.js"></script>
|
||||
<script src="/i18n/zh-CN.js"></script>
|
||||
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
@@ -20,16 +26,16 @@
|
||||
<div id="auth-screen">
|
||||
<div class="auth-card-login">
|
||||
<div class="auth-brand">
|
||||
<h1>IronClaw</h1>
|
||||
<p class="auth-tagline">Secure AI Assistant</p>
|
||||
<h1 data-i18n="auth.title">IronClaw</h1>
|
||||
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
|
||||
</div>
|
||||
<div class="auth-form">
|
||||
<label for="token-input">Gateway Token</label>
|
||||
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
|
||||
<button onclick="authenticate()">Connect</button>
|
||||
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
|
||||
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
|
||||
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
</div>
|
||||
<div id="auth-error"></div>
|
||||
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,21 +44,22 @@
|
||||
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
|
||||
<div class="restart-modal-content">
|
||||
<div class="restart-modal-header">
|
||||
<h2>Restart IronClaw Instance</h2>
|
||||
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
|
||||
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
|
||||
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
|
||||
title="Close">×</button>
|
||||
</div>
|
||||
<div class="restart-modal-body">
|
||||
<p class="restart-modal-description">
|
||||
<p class="restart-modal-description" data-i18n="restart.description">
|
||||
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
|
||||
</p>
|
||||
<div class="restart-modal-warning">
|
||||
<span class="restart-modal-warning-icon">⚠️</span>
|
||||
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
|
||||
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="restart-modal-footer">
|
||||
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
|
||||
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
|
||||
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
|
||||
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,13 +70,13 @@
|
||||
<div class="restart-loader-content">
|
||||
<div class="restart-spinner"></div>
|
||||
<div class="restart-loader-text">
|
||||
<p class="restart-title">Restarting IronClaw</p>
|
||||
<p class="restart-subtitle">Please wait while the process restarts...</p>
|
||||
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
|
||||
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
|
||||
</div>
|
||||
<div class="restart-progress-bar">
|
||||
<div class="restart-progress-fill"></div>
|
||||
</div>
|
||||
<p class="restart-modal-info">
|
||||
<p class="restart-modal-info" data-i18n="restart.checkLogs">
|
||||
Check the Logs tab for details after the restart completes.
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,33 +86,45 @@
|
||||
<div id="app">
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<button class="active" data-tab="chat">Chat</button>
|
||||
<button data-tab="memory">Memory</button>
|
||||
<button data-tab="jobs">Jobs</button>
|
||||
<button data-tab="routines">Routines</button>
|
||||
<button data-tab="extensions">Extensions</button>
|
||||
<button data-tab="skills">Skills</button>
|
||||
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
|
||||
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
|
||||
<div class="language-menu" id="language-menu" style="display: none;">
|
||||
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
|
||||
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<span id="tee-shield-label">TEE Verified</span>
|
||||
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
|
||||
<div class="tee-popover" id="tee-popover"></div>
|
||||
</div>
|
||||
<div class="status" id="gateway-status-trigger">
|
||||
<div class="dot" id="sse-dot"></div>
|
||||
<span id="sse-status">Connected</span>
|
||||
<span id="sse-status" data-i18n="status.connected">Connected</span>
|
||||
<div class="gateway-popover" id="gateway-popover"></div>
|
||||
</div>
|
||||
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process" style="display: none;">
|
||||
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
|
||||
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
|
||||
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M1 20v-6h6"></path>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
|
||||
</svg>
|
||||
<span>Restart</span>
|
||||
<span data-i18n="status.restart">Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -113,16 +132,18 @@
|
||||
<div class="tab-panel active" id="tab-chat">
|
||||
<div class="thread-sidebar" id="thread-sidebar">
|
||||
<div class="thread-sidebar-header">
|
||||
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">«</button>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
|
||||
<span class="assistant-label" id="assistant-label">Assistant</span>
|
||||
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
||||
<span class="assistant-meta" id="assistant-meta"></span>
|
||||
</div>
|
||||
<div class="threads-section-header">
|
||||
<span>Conversations</span>
|
||||
<span data-i18n="chat.conversations">Conversations</span>
|
||||
</div>
|
||||
<div class="thread-list" id="thread-list"></div>
|
||||
</div>
|
||||
@@ -131,10 +152,11 @@
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<div id="image-preview-strip" class="image-preview-strip"></div>
|
||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
|
||||
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">📎</button>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
|
||||
aria-label="Attach images">📎</button>
|
||||
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,23 +166,23 @@
|
||||
<div class="memory-container">
|
||||
<div class="memory-sidebar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="memory-search" placeholder="Search memory...">
|
||||
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
|
||||
</div>
|
||||
<div class="memory-tree" id="memory-tree"></div>
|
||||
</div>
|
||||
<div class="memory-content">
|
||||
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
||||
<span id="memory-breadcrumb-path">workspace /</span>
|
||||
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
|
||||
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
|
||||
</div>
|
||||
<div class="memory-viewer" id="memory-viewer">
|
||||
<div class="empty">Select a file to view its contents</div>
|
||||
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
|
||||
</div>
|
||||
<div class="memory-editor" id="memory-editor" style="display:none">
|
||||
<textarea id="memory-edit-textarea"></textarea>
|
||||
<div class="memory-editor-actions">
|
||||
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
|
||||
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
|
||||
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
|
||||
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,17 +196,17 @@
|
||||
<table class="jobs-table" id="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
<th data-i18n="jobs.id">ID</th>
|
||||
<th data-i18n="jobs.title">Title</th>
|
||||
<th data-i18n="jobs.source">Source</th>
|
||||
<th data-i18n="jobs.status">Status</th>
|
||||
<th data-i18n="jobs.created">Created</th>
|
||||
<th data-i18n="jobs.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
|
||||
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,16 +221,16 @@
|
||||
<option value="debug">Server: DEBUG</option>
|
||||
</select>
|
||||
<select id="logs-level-filter">
|
||||
<option value="all">All Levels</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
<option value="all" data-i18n="logs.allLevels">All Levels</option>
|
||||
<option value="ERROR" data-i18n="logs.error">Error</option>
|
||||
<option value="WARN" data-i18n="logs.warn">Warn</option>
|
||||
<option value="INFO" data-i18n="logs.info">Info</option>
|
||||
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
|
||||
</select>
|
||||
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
|
||||
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
|
||||
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
|
||||
<button onclick="clearLogs()">Clear</button>
|
||||
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
|
||||
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
|
||||
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
|
||||
</div>
|
||||
<div class="logs-output" id="logs-output"></div>
|
||||
</div>
|
||||
@@ -221,20 +243,20 @@
|
||||
<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>
|
||||
<th data-i18n="routines.name">Name</th>
|
||||
<th data-i18n="routines.trigger">Trigger</th>
|
||||
<th data-i18n="routines.action">Action</th>
|
||||
<th data-i18n="routines.lastRun">Last Run</th>
|
||||
<th data-i18n="routines.nextRun">Next Run</th>
|
||||
<th data-i18n="routines.runs">Runs</th>
|
||||
<th data-i18n="routines.status">Status</th>
|
||||
<th data-i18n="routines.actions">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.
|
||||
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
|
||||
</div>
|
||||
<div class="routine-detail" id="routine-detail" style="display:none"></div>
|
||||
</div>
|
||||
@@ -244,44 +266,44 @@
|
||||
<div class="tab-panel" id="tab-extensions">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3>Installed Extensions</h3>
|
||||
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
||||
<div class="extensions-list" id="extensions-list">
|
||||
<div class="empty-state">Loading extensions...</div>
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section" id="available-wasm-section">
|
||||
<h3>Available WASM Extensions</h3>
|
||||
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
|
||||
<div class="extensions-list" id="available-wasm-list">
|
||||
<div class="empty-state">Loading...</div>
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Install WASM Extension</h3>
|
||||
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="wasm-install-name" placeholder="Extension name">
|
||||
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
|
||||
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
||||
<button onclick="installWasmExtension()">Install</button>
|
||||
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>MCP Servers</h3>
|
||||
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
||||
<div class="extensions-list" id="mcp-servers-list">
|
||||
<div class="empty-state">Loading...</div>
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
<h4>Add Custom MCP Server</h4>
|
||||
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="mcp-install-name" placeholder="Server name">
|
||||
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
||||
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||
<button onclick="addMcpServer()">Add</button>
|
||||
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Registered Tools</h3>
|
||||
<h3 data-i18n="tools.registered">Registered Tools</h3>
|
||||
<table class="tools-table" id="tools-table">
|
||||
<thead><tr><th>Name</th><th>Description</th></tr></thead>
|
||||
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
|
||||
<tbody id="tools-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
|
||||
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,25 +312,25 @@
|
||||
<div class="tab-panel" id="tab-skills">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3>Search ClawHub</h3>
|
||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||
<div class="skill-search-box">
|
||||
<input type="text" id="skill-search-input" placeholder="Search for skills...">
|
||||
<button onclick="searchClawHub()">Search</button>
|
||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
||||
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
|
||||
</div>
|
||||
<div class="extensions-list" id="skill-search-results"></div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Installed Skills</h3>
|
||||
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
||||
<div class="extensions-list" id="skills-list">
|
||||
<div class="empty-state">Loading skills...</div>
|
||||
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3>Install Skill by URL</h3>
|
||||
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button onclick="installSkillFromForm()">Install</button>
|
||||
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,5 +339,6 @@
|
||||
|
||||
<div id="toasts"></div>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/i18n-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3885,6 +3885,61 @@ mark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.language-switcher {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.language-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.language-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.language-option.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.generated-image-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct WebhookServerConfig {
|
||||
pub struct WebhookServer {
|
||||
config: WebhookServerConfig,
|
||||
routes: Vec<Router>,
|
||||
/// Merged router saved after start() for restart_with_addr().
|
||||
/// Merged router saved after start() for restarts via `install_listener()`.
|
||||
merged_router: Option<Router>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
@@ -59,7 +59,7 @@ impl WebhookServer {
|
||||
}
|
||||
|
||||
/// Bind a listener to the configured address and spawn the server task.
|
||||
/// Private helper used by both start() and restart_with_addr().
|
||||
/// Private helper used by `start()`.
|
||||
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
|
||||
let listener = tokio::net::TcpListener::bind(self.config.addr)
|
||||
.await
|
||||
@@ -89,47 +89,49 @@ impl WebhookServer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shut down the current listener and rebind to a new address.
|
||||
/// The merged router from the original `start()` call is reused.
|
||||
///
|
||||
/// If binding to the new address fails, the old listener remains active and
|
||||
/// state is restored. This prevents a denial-of-service if the new address
|
||||
/// is invalid or already in use.
|
||||
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
|
||||
let app = self
|
||||
.merged_router
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::StartupFailed {
|
||||
name: "webhook_server".to_string(),
|
||||
reason: "restart_with_addr called before start()".to_string(),
|
||||
})?;
|
||||
/// Clone the merged router, if `start()` has been called.
|
||||
pub fn merged_router_clone(&self) -> Option<Router> {
|
||||
self.merged_router.clone()
|
||||
}
|
||||
|
||||
// Save old state for rollback if new bind fails
|
||||
let old_addr = self.config.addr;
|
||||
/// Install a pre-bound listener, replacing the current one.
|
||||
///
|
||||
/// The caller is responsible for binding the `TcpListener` *outside* any
|
||||
/// lock so that the async bind does not block other lock waiters. This
|
||||
/// method only does synchronous bookkeeping plus spawning the (non-blocking)
|
||||
/// server task, so it is safe to call while holding a mutex.
|
||||
pub fn install_listener(
|
||||
&mut self,
|
||||
new_addr: SocketAddr,
|
||||
listener: tokio::net::TcpListener,
|
||||
app: Router,
|
||||
) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
|
||||
// Capture old handles so the caller can shut them down outside the lock.
|
||||
let old_shutdown_tx = self.shutdown_tx.take();
|
||||
let old_handle = self.handle.take();
|
||||
|
||||
// Update config to new address and try to bind
|
||||
self.config.addr = new_addr;
|
||||
match self.bind_and_spawn(app).await {
|
||||
Ok(()) => {
|
||||
// New listener is running, gracefully shut down the old one
|
||||
if let Some(tx) = old_shutdown_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
// Spawn the new server task (non-blocking).
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
tracing::debug!("Webhook server shutting down");
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Webhook server error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
// Restore old state; old listener remains active
|
||||
self.config.addr = old_addr;
|
||||
self.shutdown_tx = old_shutdown_tx;
|
||||
self.handle = old_handle;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
});
|
||||
self.handle = Some(handle);
|
||||
|
||||
tracing::info!("Webhook server listening on {}", new_addr);
|
||||
|
||||
(old_shutdown_tx, old_handle)
|
||||
}
|
||||
|
||||
/// Return the current bind address.
|
||||
@@ -213,12 +215,21 @@ mod tests {
|
||||
"First server should respond to health check"
|
||||
);
|
||||
|
||||
// Restart on second port
|
||||
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
|
||||
server
|
||||
.restart_with_addr(addr2)
|
||||
// Restart on second port using two-phase approach
|
||||
let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap();
|
||||
let app = server
|
||||
.merged_router_clone()
|
||||
.expect("Router should exist after start()");
|
||||
let listener = tokio::net::TcpListener::bind(addr2)
|
||||
.await
|
||||
.expect("Failed to restart with new addr");
|
||||
.expect("Failed to bind to new addr");
|
||||
let (old_tx, old_handle) = server.install_listener(addr2, listener, app);
|
||||
if let Some(tx) = old_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Assert the address changed
|
||||
assert_eq!(
|
||||
@@ -295,13 +306,18 @@ mod tests {
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(response.status(), 200, "Server should be listening");
|
||||
|
||||
// Try to restart on an invalid address (port 0 is reserved, won't bind)
|
||||
// Use port 1 which typically requires elevated privileges
|
||||
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
|
||||
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
|
||||
// Attempt restart (should fail)
|
||||
let result = server.restart_with_addr(invalid_addr).await;
|
||||
assert!(result.is_err(), "Restart with invalid address should fail");
|
||||
// Attempt bind (should fail); server state is untouched because we
|
||||
// never call install_listener on failure.
|
||||
let app = server
|
||||
.merged_router_clone()
|
||||
.expect("Router should exist after start()");
|
||||
let result = tokio::net::TcpListener::bind(invalid_addr).await;
|
||||
assert!(result.is_err(), "Bind to privileged port should fail");
|
||||
// `app` is dropped — server state unchanged (rollback by construction)
|
||||
drop(app);
|
||||
|
||||
// Verify the old address is still responding (rollback succeeded)
|
||||
let response = client
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ mod state;
|
||||
|
||||
pub use manager::ContextManager;
|
||||
pub use memory::{ActionRecord, ConversationMemory, Memory};
|
||||
pub use state::{JobContext, JobState, StateTransition};
|
||||
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
|
||||
|
||||
+17
-7
@@ -11,6 +11,16 @@ use uuid::Uuid;
|
||||
|
||||
use crate::llm::recording::HttpInterceptor;
|
||||
|
||||
/// Error returned when a job exceeds its token budget.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Token budget exceeded: used {used} of {limit} allowed tokens")]
|
||||
pub struct TokenBudgetExceeded {
|
||||
/// Total tokens consumed (including the call that exceeded the budget).
|
||||
pub used: u64,
|
||||
/// Configured token limit for this job.
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
/// State of a job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -265,15 +275,15 @@ impl JobContext {
|
||||
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> {
|
||||
/// Record token usage from an LLM call. Returns an error if the token
|
||||
/// budget has been exceeded after this addition.
|
||||
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), TokenBudgetExceeded> {
|
||||
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
|
||||
))
|
||||
Err(TokenBudgetExceeded {
|
||||
used: self.total_tokens_used,
|
||||
limit: self.max_tokens,
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ impl OpenClawImporter {
|
||||
// Pre-read all conversation data to validate before writing
|
||||
let mut all_conversations = Vec::new();
|
||||
for (_agent_name, db_path) in &agent_dbs {
|
||||
match reader.read_conversations(db_path) {
|
||||
match reader.read_conversations(db_path).await {
|
||||
Ok(convs) => all_conversations.extend(convs),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read conversations: {}", e);
|
||||
@@ -88,7 +88,7 @@ impl OpenClawImporter {
|
||||
// Pre-read all memory chunks
|
||||
let mut all_chunks = Vec::new();
|
||||
for (_agent_name, db_path) in &agent_dbs {
|
||||
match reader.read_memory_chunks(db_path) {
|
||||
match reader.read_memory_chunks(db_path).await {
|
||||
Ok(chunks) => all_chunks.extend(chunks),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read memory chunks: {}", e);
|
||||
|
||||
+102
-84
@@ -80,6 +80,16 @@ pub struct OpenClawMessage {
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Open an OpenClaw SQLite database file via libsql for read-only access.
|
||||
#[cfg(feature = "import")]
|
||||
async fn open_sqlite(db_path: &Path) -> Result<libsql::Connection, ImportError> {
|
||||
let db = libsql::Builder::new_local(db_path)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
db.connect().map_err(|e| ImportError::Sqlite(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reader for OpenClaw data files and databases.
|
||||
pub struct OpenClawReader {
|
||||
openclaw_dir: PathBuf,
|
||||
@@ -226,51 +236,52 @@ impl OpenClawReader {
|
||||
|
||||
/// Read all memory chunks from an OpenClaw SQLite database.
|
||||
#[cfg(feature = "import")]
|
||||
pub fn read_memory_chunks(
|
||||
pub async fn read_memory_chunks(
|
||||
&self,
|
||||
db_path: &Path,
|
||||
) -> Result<Vec<OpenClawMemoryChunk>, ImportError> {
|
||||
use rusqlite::Connection;
|
||||
let conn = open_sqlite(db_path).await?;
|
||||
|
||||
let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT path, content, embedding, chunk_index FROM chunks")
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let chunks = stmt
|
||||
.query_map([], |row| {
|
||||
let path: String = row.get(0)?;
|
||||
let content: String = row.get(1)?;
|
||||
let embedding_bytes: Option<Vec<u8>> = row.get(2)?;
|
||||
let chunk_index: i32 = row.get(3)?;
|
||||
|
||||
// Convert binary embedding blob to Vec<f32> if present
|
||||
let embedding = embedding_bytes.map(|bytes| {
|
||||
bytes
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
if chunk.len() == 4 {
|
||||
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(OpenClawMemoryChunk {
|
||||
path,
|
||||
content,
|
||||
embedding,
|
||||
chunk_index,
|
||||
})
|
||||
})
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT path, content, embedding, chunk_index FROM chunks",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for chunk_result in chunks {
|
||||
result.push(chunk_result.map_err(|e| ImportError::Sqlite(e.to_string()))?);
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let path: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let content: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let embedding_blob: Option<Vec<u8>> =
|
||||
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
// Convert binary embedding blob to Vec<f32> if present
|
||||
let embedding = embedding_blob.map(|bytes| {
|
||||
bytes
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
if chunk.len() == 4 {
|
||||
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
result.push(OpenClawMemoryChunk {
|
||||
path,
|
||||
content,
|
||||
embedding,
|
||||
chunk_index,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
@@ -278,63 +289,70 @@ impl OpenClawReader {
|
||||
|
||||
/// Read all conversations from an OpenClaw SQLite database.
|
||||
#[cfg(feature = "import")]
|
||||
pub fn read_conversations(
|
||||
pub async fn read_conversations(
|
||||
&self,
|
||||
db_path: &Path,
|
||||
) -> Result<Vec<OpenClawConversation>, ImportError> {
|
||||
use rusqlite::Connection;
|
||||
let conn = open_sqlite(db_path).await?;
|
||||
|
||||
let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
// First, read all conversations
|
||||
let mut conv_stmt = conn
|
||||
.prepare("SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC")
|
||||
let mut conv_rows = conn
|
||||
.query(
|
||||
"SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut conversations = Vec::new();
|
||||
let conv_rows = conv_stmt
|
||||
.query_map([], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let channel: String = row.get(1)?;
|
||||
let created_at: Option<String> = row.get(2)?;
|
||||
while let Some(row) = conv_rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let id: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let channel: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let created_at: Option<String> =
|
||||
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let created_at = created_at
|
||||
let created_at = created_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
// Read messages for this conversation
|
||||
let mut msg_rows = conn
|
||||
.query(
|
||||
"SELECT role, content, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at",
|
||||
libsql::params![id.as_str()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
while let Some(msg_row) = msg_rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let role: String = msg_row
|
||||
.get(0)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let content: String = msg_row
|
||||
.get(1)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let msg_created_at: Option<String> = msg_row
|
||||
.get(2)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let msg_created_at = msg_created_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
Ok((id, channel, created_at))
|
||||
})
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
for row_result in conv_rows {
|
||||
let (id, channel, created_at) =
|
||||
row_result.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
// Read messages for this conversation
|
||||
let mut msg_stmt = conn.prepare(
|
||||
"SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at"
|
||||
)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let messages = msg_stmt
|
||||
.query_map([&id], |row| {
|
||||
let role: String = row.get(0)?;
|
||||
let content: String = row.get(1)?;
|
||||
let created_at: Option<String> = row.get(2)?;
|
||||
|
||||
let created_at = created_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
Ok(OpenClawMessage {
|
||||
role,
|
||||
content,
|
||||
created_at,
|
||||
})
|
||||
})
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
messages.push(OpenClawMessage {
|
||||
role,
|
||||
content,
|
||||
created_at: msg_created_at,
|
||||
});
|
||||
}
|
||||
|
||||
conversations.push(OpenClawConversation {
|
||||
id,
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod tracing_fmt;
|
||||
pub mod transcription;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
pub mod webhooks;
|
||||
pub mod worker;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
+60
-19
@@ -24,6 +24,7 @@ use ironclaw::{
|
||||
orchestrator::{ReaperConfig, SandboxReaper},
|
||||
pairing::PairingStore,
|
||||
tracing_fmt::{init_cli_tracing, init_worker_tracing},
|
||||
webhooks::{self, ToolWebhookState},
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
@@ -277,9 +278,25 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared routine engine slot for gateway + generic webhook ingress.
|
||||
let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// Collect webhook route fragments; a single WebhookServer hosts them all.
|
||||
let mut webhook_routes: Vec<axum::Router> = Vec::new();
|
||||
|
||||
webhook_routes.push(webhooks::routes(ToolWebhookState {
|
||||
tools: Arc::clone(&components.tools),
|
||||
routine_engine: Arc::clone(&shared_routine_engine_slot),
|
||||
user_id: config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
secrets_store: components.secrets_store.clone(),
|
||||
}));
|
||||
|
||||
// Load WASM channels and register their webhook routes.
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||
@@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut sse_sender: Option<
|
||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
||||
> = None;
|
||||
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw =
|
||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
||||
@@ -455,6 +471,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
gw = gw.with_job_manager(Arc::clone(jm));
|
||||
}
|
||||
gw = gw.with_scheduler(scheduler_slot.clone());
|
||||
gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot));
|
||||
if let Some(ref sr) = components.skill_registry {
|
||||
gw = gw.with_skill_registry(Arc::clone(sr));
|
||||
}
|
||||
@@ -489,8 +506,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
// creates a new SseManager, which would orphan this sender.
|
||||
sse_sender = Some(gw.state().sse.sender());
|
||||
routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine));
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
@@ -689,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Give the agent the routine engine slot so it can expose the engine to the gateway.
|
||||
if let Some(slot) = routine_engine_slot {
|
||||
agent.set_routine_engine_slot(slot);
|
||||
}
|
||||
agent.set_routine_engine_slot(shared_routine_engine_slot);
|
||||
|
||||
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
|
||||
// Broadcast channel for clean shutdown of background tasks
|
||||
@@ -787,12 +800,12 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
// Restart listener if addr changed.
|
||||
// Minimize lock scope: acquire, read old addr, release, then restart.
|
||||
// Two-phase approach: bind outside the lock, then swap under lock.
|
||||
let mut restart_failed = false;
|
||||
if let Some(ref ws_arc) = sighup_webhook_server {
|
||||
let old_addr = {
|
||||
let (old_addr, router) = {
|
||||
let ws = ws_arc.lock().await;
|
||||
ws.current_addr()
|
||||
(ws.current_addr(), ws.merged_router_clone())
|
||||
}; // Lock released here
|
||||
|
||||
if old_addr != new_addr {
|
||||
@@ -801,17 +814,45 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
old_addr,
|
||||
new_addr
|
||||
);
|
||||
// NOTE: Lock is held across restart_with_addr().await. This is
|
||||
// acceptable because SIGHUP is infrequent and restart is fast. A full
|
||||
// fix would require refactoring restart_with_addr to separate state
|
||||
// mutation from async I/O.
|
||||
let mut ws = ws_arc.lock().await;
|
||||
match ws.restart_with_addr(new_addr).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("SIGHUP: webhook server restarted on {}", new_addr);
|
||||
|
||||
match router {
|
||||
Some(app) => {
|
||||
// Phase 1: Bind new listener WITHOUT holding the lock.
|
||||
match tokio::net::TcpListener::bind(new_addr).await {
|
||||
Ok(listener) => {
|
||||
// Phase 2: Swap state under lock (no await inside).
|
||||
let (old_tx, old_handle) = {
|
||||
let mut ws = ws_arc.lock().await;
|
||||
ws.install_listener(new_addr, listener, app)
|
||||
}; // Lock released here
|
||||
|
||||
// Phase 3: Shut down old listener outside the lock.
|
||||
if let Some(tx) = old_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"SIGHUP: webhook server restarted on {}",
|
||||
new_addr
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"SIGHUP: failed to bind to {}: {}",
|
||||
new_addr,
|
||||
e
|
||||
);
|
||||
restart_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("SIGHUP: listener restart failed: {}", e);
|
||||
None => {
|
||||
tracing::error!(
|
||||
"SIGHUP: cannot restart — server was never started"
|
||||
);
|
||||
restart_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
+49
-4
@@ -197,13 +197,20 @@ impl Validator {
|
||||
pub fn validate_tool_params(&self, params: &serde_json::Value) -> ValidationResult {
|
||||
let mut result = ValidationResult::ok();
|
||||
|
||||
// Recursively check all string values in the JSON
|
||||
// Recursively check all string values in the JSON.
|
||||
// Depth is capped to prevent stack overflow on pathological input.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
fn check_strings(
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
validator: &Validator,
|
||||
result: &mut ValidationResult,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let string_result = if s.is_empty() {
|
||||
@@ -216,7 +223,7 @@ impl Validator {
|
||||
serde_json::Value::Array(arr) => {
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
let child_path = format!("{path}[{i}]");
|
||||
check_strings(item, &child_path, validator, result);
|
||||
check_strings(item, &child_path, validator, result, depth + 1);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
@@ -226,14 +233,14 @@ impl Validator {
|
||||
} else {
|
||||
format!("{path}.{k}")
|
||||
};
|
||||
check_strings(v, &child_path, validator, result);
|
||||
check_strings(v, &child_path, validator, result, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
check_strings(params, "", self, &mut result);
|
||||
check_strings(params, "", self, &mut result, 0);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -423,4 +430,42 @@ mod tests {
|
||||
.expect("expected forbidden content error");
|
||||
assert_eq!(error.field, "metadata.tags[1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_params_depth_limit_prevents_stack_overflow() {
|
||||
let validator = Validator::new().forbid_pattern("evil");
|
||||
|
||||
// Build a deeply nested JSON object (depth > MAX_DEPTH of 32)
|
||||
let mut value = serde_json::json!("evil payload");
|
||||
for _ in 0..50 {
|
||||
value = serde_json::json!({ "nested": value });
|
||||
}
|
||||
|
||||
let result = validator.validate_tool_params(&value);
|
||||
|
||||
// The "evil payload" is beyond the depth limit so it should NOT be
|
||||
// detected — the traversal stops before reaching it.
|
||||
assert!(
|
||||
result.is_valid,
|
||||
"Strings beyond depth limit should be silently skipped, got errors: {:?}",
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_params_within_depth_limit_still_validated() {
|
||||
let validator = Validator::new().forbid_pattern("evil");
|
||||
|
||||
// Build a nested object within the depth limit
|
||||
let mut value = serde_json::json!("evil payload");
|
||||
for _ in 0..5 {
|
||||
value = serde_json::json!({ "nested": value });
|
||||
}
|
||||
|
||||
let result = validator.validate_tool_params(&value);
|
||||
assert!(
|
||||
!result.is_valid,
|
||||
"Strings within depth limit should still be validated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,14 @@ pub trait Tool: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Optional host-side webhook verification configuration for this tool.
|
||||
///
|
||||
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
|
||||
/// before invoking the tool. Tools should then only handle payload normalization.
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the tool schema for LLM function calling.
|
||||
fn schema(&self) -> ToolSchema {
|
||||
ToolSchema {
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct Capabilities {
|
||||
pub tool_invoke: Option<ToolInvokeCapability>,
|
||||
/// Check if secrets exist.
|
||||
pub secrets: Option<SecretsCapability>,
|
||||
/// Webhook authentication and signature verification.
|
||||
pub webhook: Option<WebhookCapability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
@@ -308,6 +310,25 @@ impl SecretsCapability {
|
||||
/// WASM capabilities use it to configure per-tool HTTP request limits.
|
||||
pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig;
|
||||
|
||||
/// Webhook auth/signature capability configuration for tools.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WebhookCapability {
|
||||
/// Optional header name for shared-secret validation.
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing validation.
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header. When present, Slack-style v0 signature is used.
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
|
||||
@@ -319,6 +340,7 @@ mod tests {
|
||||
assert!(caps.http.is_none());
|
||||
assert!(caps.tool_invoke.is_none());
|
||||
assert!(caps.secrets.is_none());
|
||||
assert!(caps.webhook.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WorkspaceCapability,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
|
||||
};
|
||||
|
||||
/// Root schema for a capabilities JSON file.
|
||||
@@ -65,6 +65,10 @@ pub struct CapabilitiesFile {
|
||||
#[serde(default)]
|
||||
pub workspace: Option<WorkspaceCapabilitySchema>,
|
||||
|
||||
/// Tool webhook authentication/signature configuration.
|
||||
#[serde(default)]
|
||||
pub webhook: Option<WebhookCapabilitySchema>,
|
||||
|
||||
/// Authentication setup instructions.
|
||||
/// Used by `ironclaw config` to guide users through auth setup.
|
||||
#[serde(default)]
|
||||
@@ -107,6 +111,7 @@ impl CapabilitiesFile {
|
||||
self.secrets = self.secrets.or(inner.secrets);
|
||||
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
|
||||
self.workspace = self.workspace.or(inner.workspace);
|
||||
self.webhook = self.webhook.or(inner.webhook);
|
||||
self.auth = self.auth.or(inner.auth);
|
||||
self.setup = self.setup.or(inner.setup);
|
||||
}
|
||||
@@ -198,6 +203,10 @@ impl CapabilitiesFile {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(webhook) = &self.webhook {
|
||||
caps.webhook = Some(webhook.to_webhook_capability());
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
}
|
||||
@@ -419,6 +428,46 @@ pub struct WorkspaceCapabilitySchema {
|
||||
pub allowed_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Webhook capability schema for tools.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WebhookCapabilitySchema {
|
||||
/// HTTP header name for secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key.
|
||||
#[serde(default)]
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing.
|
||||
#[serde(default)]
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Signature header for HMAC verification.
|
||||
#[serde(default)]
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header for Slack-style v0 verification.
|
||||
#[serde(default)]
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix for body-only HMAC mode (default sha256=).
|
||||
#[serde(default)]
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl WebhookCapabilitySchema {
|
||||
fn to_webhook_capability(&self) -> WebhookCapability {
|
||||
WebhookCapability {
|
||||
secret_header: self.secret_header.clone(),
|
||||
secret_name: self.secret_name.clone(),
|
||||
signature_key_secret_name: self.signature_key_secret_name.clone(),
|
||||
hmac_secret_name: self.hmac_secret_name.clone(),
|
||||
hmac_signature_header: self.hmac_signature_header.clone(),
|
||||
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
|
||||
hmac_prefix: self.hmac_prefix.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication setup schema.
|
||||
///
|
||||
/// Tools declare their auth requirements here. The agent uses this to provide
|
||||
@@ -769,6 +818,28 @@ mod tests {
|
||||
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_webhook_capability() {
|
||||
let json = r#"{
|
||||
"webhook": {
|
||||
"hmac_secret_name": "github_webhook_secret",
|
||||
"hmac_signature_header": "x-hub-signature-256",
|
||||
"hmac_prefix": "sha256="
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
let webhook = caps.webhook.unwrap();
|
||||
assert_eq!(
|
||||
webhook.hmac_secret_name.as_deref(),
|
||||
Some("github_webhook_secret")
|
||||
);
|
||||
assert_eq!(
|
||||
webhook.hmac_signature_header.as_deref(),
|
||||
Some("x-hub-signature-256")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_capabilities() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
|
||||
// Capabilities (V2)
|
||||
pub use capabilities::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
|
||||
};
|
||||
|
||||
// Security components (V2)
|
||||
|
||||
@@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper {
|
||||
// Use the timeout as a conservative estimate
|
||||
Some(self.prepared.limits.timeout)
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
self.capabilities.webhook.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmToolWrapper {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Generic webhook ingress for tools.
|
||||
//!
|
||||
//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST
|
||||
//! payloads that are normalized by the target tool into `system_event`s.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Shared routine engine slot, populated by Agent after startup.
|
||||
pub type RoutineEngineSlot = Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
|
||||
|
||||
/// Shared state for the generic tools webhook ingress.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolWebhookState {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub routine_engine: RoutineEngineSlot,
|
||||
pub user_id: String,
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolWebhookResponse {
|
||||
status: &'static str,
|
||||
tool: String,
|
||||
emitted_events: usize,
|
||||
fired_routines: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolWebhookOutput {
|
||||
#[serde(default)]
|
||||
emit_events: Vec<SystemEventIntent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SystemEventIntent {
|
||||
source: String,
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Build routes for tool-driven webhook ingestion.
|
||||
pub fn routes(state: ToolWebhookState) -> Router {
|
||||
Router::new()
|
||||
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
|
||||
.route(
|
||||
"/webhook/tools/{tool}/{*rest}",
|
||||
post(tool_webhook_with_rest_handler),
|
||||
)
|
||||
.route("/webhook/tools/{tool}", get(tool_webhook_health))
|
||||
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn tool_webhook_health(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
if tool_impl.webhook_capability().is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "ok", "tool": tool })),
|
||||
)
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_with_rest_handler(
|
||||
Path((tool, rest)): Path<(String, String)>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler_inner(
|
||||
tool: String,
|
||||
rest: Option<String>,
|
||||
state: ToolWebhookState,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
query: HashMap<String, String>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
if body.len() > MAX_WEBHOOK_BODY_BYTES {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
|
||||
if let Err(msg) = validate_webhook_auth(
|
||||
&*tool_impl,
|
||||
state.secrets_store.as_deref(),
|
||||
&state.user_id,
|
||||
&headers,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
);
|
||||
}
|
||||
|
||||
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
|
||||
let headers_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|v| (k.as_str().to_string(), v.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
|
||||
format!("/webhook/tools/{tool}/{rest}")
|
||||
} else {
|
||||
format!("/webhook/tools/{tool}")
|
||||
};
|
||||
|
||||
let params = serde_json::json!({
|
||||
"action": "handle_webhook",
|
||||
"webhook": {
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"query": query,
|
||||
"headers": headers_map,
|
||||
"body_json": body_json,
|
||||
"body_raw": String::from_utf8_lossy(&body),
|
||||
}
|
||||
});
|
||||
|
||||
let ctx = JobContext::with_user(
|
||||
state.user_id.clone(),
|
||||
format!("webhook:{tool}"),
|
||||
"Process external webhook",
|
||||
);
|
||||
|
||||
let output = match tool_impl.execute(params, &ctx).await {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Tool execution failed" })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let emitted_events = parsed.emit_events.len();
|
||||
let mut fired_routines = 0usize;
|
||||
if emitted_events > 0 {
|
||||
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "Routine engine not available" })),
|
||||
);
|
||||
};
|
||||
|
||||
for event in parsed.emit_events {
|
||||
fired_routines += engine
|
||||
.emit_system_event(
|
||||
&event.source,
|
||||
&event.event_type,
|
||||
&event.payload,
|
||||
Some(&state.user_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let response = ToolWebhookResponse {
|
||||
status: "accepted",
|
||||
tool,
|
||||
emitted_events,
|
||||
fired_routines,
|
||||
};
|
||||
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
|
||||
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
|
||||
headers.get(key).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
async fn validate_webhook_auth(
|
||||
tool: &dyn crate::tools::Tool,
|
||||
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
user_id: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let Some(cfg) = tool.webhook_capability() else {
|
||||
return Err(
|
||||
"Tool does not declare a webhook capability; webhook access denied".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
// Require at least one authentication mechanism to be configured.
|
||||
if cfg.secret_name.is_none()
|
||||
&& cfg.signature_key_secret_name.is_none()
|
||||
&& cfg.hmac_secret_name.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Webhook capability misconfigured: at least one auth mechanism must be configured"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(store) = secrets_store else {
|
||||
return Err("Secrets store not available for webhook verification".to_string());
|
||||
};
|
||||
|
||||
if let Some(secret_name) = cfg.secret_name.as_deref() {
|
||||
let expected = store
|
||||
.get_decrypted(user_id, secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
|
||||
let expected = expected.expose();
|
||||
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
|
||||
let provided = header_value(headers, secret_header)
|
||||
.or_else(|| {
|
||||
if secret_header != "x-webhook-secret" {
|
||||
header_value(headers, "x-webhook-secret")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "Webhook secret required".to_string())?;
|
||||
|
||||
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
|
||||
return Err("Invalid webhook secret".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
|
||||
let key = store
|
||||
.get_decrypted(user_id, public_key_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
|
||||
let key = key.expose();
|
||||
let sig = header_value(headers, "x-signature-ed25519")
|
||||
.ok_or_else(|| "Missing signature header".to_string())?;
|
||||
let ts = header_value(headers, "x-signature-timestamp")
|
||||
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
|
||||
{
|
||||
return Err("Invalid signature".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
|
||||
let secret = store
|
||||
.get_decrypted(user_id, hmac_secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
|
||||
let secret = secret.expose();
|
||||
|
||||
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-slack-signature");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
let ts = header_value(headers, timestamp_header)
|
||||
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_slack_signature(
|
||||
secret, ts, body, sig, now_secs,
|
||||
) {
|
||||
return Err("Invalid timestamped HMAC signature".to_string());
|
||||
}
|
||||
} else {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-hub-signature-256");
|
||||
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
|
||||
secret, body, sig, prefix,
|
||||
) {
|
||||
return Err("Invalid HMAC signature".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestWebhookTool;
|
||||
struct ProtectedWebhookTool;
|
||||
struct HmacWebhookTool;
|
||||
/// Tool that declares webhook_capability() but with no auth mechanism configured.
|
||||
struct MisconfiguredWebhookTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TestWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"test_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ProtectedWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"protected_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"protected test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
secret_name: Some("test_webhook_secret".to_string()),
|
||||
secret_header: Some("x-webhook-secret".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HmacWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"hmac_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"hmac test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
hmac_secret_name: Some("hmac_secret".to_string()),
|
||||
hmac_signature_header: Some("x-hub-signature-256".to_string()),
|
||||
hmac_prefix: Some("sha256=".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MisconfiguredWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"misconfigured_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"misconfigured test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_not_found_for_unknown_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/missing")
|
||||
.body(Body::from("{}"))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tool_without_webhook_capability() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_when_required_secret_missing() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("test_webhook_secret", "s3cret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_with_valid_hmac_signature() {
|
||||
use hmac::Mac;
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(HmacWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("hmac_secret", "github-secret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let payload = br#"{"action":"opened"}"#;
|
||||
let mut mac =
|
||||
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
|
||||
mac.update(payload);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/hmac_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-hub-signature-256", sig)
|
||||
.body(Body::from(payload.to_vec()))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_webhook_capability_as_misconfigured() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/misconfigured_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_ok_for_webhook_capable_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_not_found_for_non_webhook_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1187,13 +1187,13 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
// TokenUsage; only respond_with_tools() usage is tracked here.
|
||||
let total_tokens = output.usage.total() as u64;
|
||||
if total_tokens > 0
|
||||
&& let Err(msg) = self
|
||||
&& let Err(err) = self
|
||||
.worker
|
||||
.context_manager()
|
||||
.update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens))
|
||||
.await?
|
||||
{
|
||||
self.worker.mark_failed(&msg).await?;
|
||||
self.worker.mark_failed(&err.to_string()).await?;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
@@ -1796,7 +1796,7 @@ mod tests {
|
||||
|
||||
// Verify that mark_failed transitions job to Failed
|
||||
worker
|
||||
.mark_failed(&budget_result.unwrap_err())
|
||||
.mark_failed(&budget_result.unwrap_err().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx = worker
|
||||
|
||||
@@ -58,6 +58,7 @@ mod advanced {
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -95,7 +96,11 @@ mod advanced {
|
||||
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Write 'recovered successfully' to a file for me.")
|
||||
.await;
|
||||
@@ -138,7 +143,11 @@ mod advanced {
|
||||
std::fs::create_dir_all(test_dir).unwrap();
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message(
|
||||
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
|
||||
@@ -232,6 +241,7 @@ mod advanced {
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_max_tool_iterations(3)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -242,8 +252,8 @@ mod advanced {
|
||||
|
||||
let started = rig.tool_calls_started();
|
||||
assert!(
|
||||
started.len() <= 4,
|
||||
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len() <= 8,
|
||||
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len()
|
||||
);
|
||||
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
|
||||
@@ -295,6 +305,7 @@ mod advanced {
|
||||
.with_trace(trace.clone())
|
||||
.with_routines()
|
||||
.with_http_exchanges(http_exchanges)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -180,6 +181,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -325,6 +327,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -394,6 +397,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -435,6 +439,7 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load simple_text.json");
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("hello").await;
|
||||
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
|
||||
@@ -95,7 +99,11 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load file_write_read.json");
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Please write a greeting to a file and read it back.")
|
||||
.await;
|
||||
|
||||
@@ -47,15 +47,14 @@ mod comprehensive_import_tests {
|
||||
}
|
||||
|
||||
/// Helper to create a synthetic SQLite database with memory chunks
|
||||
fn create_synthetic_memory_db(
|
||||
async fn create_synthetic_memory_db(
|
||||
agents_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
use rusqlite::Connection;
|
||||
|
||||
std::fs::create_dir_all(agents_dir)?;
|
||||
let db_path = agents_dir.join("test_agent.sqlite");
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let db = libsql::Builder::new_local(&db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Create chunks table (simplified schema)
|
||||
conn.execute(
|
||||
@@ -66,33 +65,36 @@ mod comprehensive_import_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test chunks
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test/doc.md",
|
||||
"This is test chunk 1 content.",
|
||||
None::<Vec<u8>>,
|
||||
0
|
||||
libsql::Value::Null,
|
||||
0i64
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test/doc.md",
|
||||
"This is test chunk 2 content.",
|
||||
None::<Vec<u8>>,
|
||||
1
|
||||
libsql::Value::Null,
|
||||
1i64
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create conversation table
|
||||
conn.execute(
|
||||
@@ -101,8 +103,9 @@ mod comprehensive_import_tests {
|
||||
channel TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create messages table
|
||||
conn.execute(
|
||||
@@ -114,40 +117,44 @@ mod comprehensive_import_tests {
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test conversation
|
||||
let conv_id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
|
||||
rusqlite::params![&conv_id, "telegram", "2024-01-15T10:30:00Z"],
|
||||
)?;
|
||||
libsql::params![conv_id.clone(), "telegram", "2024-01-15T10:30:00Z"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test messages
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
&conv_id,
|
||||
conv_id.clone(),
|
||||
"user",
|
||||
"Hello, how are you?",
|
||||
"2024-01-15T10:30:00Z"
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
&conv_id,
|
||||
conv_id.clone(),
|
||||
"assistant",
|
||||
"I'm doing well, thank you for asking!",
|
||||
"2024-01-15T10:31:00Z"
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
@@ -211,13 +218,15 @@ mod comprehensive_import_tests {
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_lists_agent_dbs() {
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_lists_agent_dbs() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let _db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB");
|
||||
let _db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
@@ -230,18 +239,21 @@ mod comprehensive_import_tests {
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_reads_memory_chunks() {
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_reads_memory_chunks() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("failed to read memory chunks");
|
||||
|
||||
// Should find 2 chunks
|
||||
@@ -260,18 +272,21 @@ mod comprehensive_import_tests {
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_reads_conversations() {
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_reads_conversations() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let conversations = reader
|
||||
.read_conversations(&db_path)
|
||||
.await
|
||||
.expect("failed to read conversations");
|
||||
|
||||
// Should find 1 conversation
|
||||
|
||||
@@ -16,7 +16,8 @@ mod e2e_import_tests {
|
||||
use ironclaw::import::{ImportOptions, ImportStats};
|
||||
|
||||
/// Helper: Create a synthetic OpenClaw with full structure
|
||||
fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>>
|
||||
{
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -60,17 +61,16 @@ mod e2e_import_tests {
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
create_full_agent_db(&agents_dir.join("primary_agent.sqlite"))?;
|
||||
create_full_agent_db(&agents_dir.join("secondary_agent.sqlite"))?;
|
||||
create_full_agent_db(&agents_dir.join("primary_agent.sqlite")).await?;
|
||||
create_full_agent_db(&agents_dir.join("secondary_agent.sqlite")).await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
/// Helper: Create a full agent SQLite database with chunks and conversations
|
||||
fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rusqlite::Connection;
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = libsql::Builder::new_local(db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Chunks table
|
||||
conn.execute(
|
||||
@@ -81,22 +81,24 @@ mod e2e_import_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert 5 chunks
|
||||
for i in 0..5 {
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
format!("notes/section_{}.md", i),
|
||||
format!("Content for section {}. This is important information.", i),
|
||||
None::<Vec<u8>>,
|
||||
i
|
||||
libsql::Value::Null,
|
||||
i as i64
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Conversations table
|
||||
@@ -106,8 +108,9 @@ mod e2e_import_tests {
|
||||
channel TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Messages table
|
||||
conn.execute(
|
||||
@@ -119,8 +122,9 @@ mod e2e_import_tests {
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert 3 conversations with messages
|
||||
for conv_num in 0..3 {
|
||||
@@ -133,12 +137,13 @@ mod e2e_import_tests {
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
|
||||
rusqlite::params![
|
||||
&conv_id,
|
||||
libsql::params![
|
||||
conv_id.clone(),
|
||||
channel,
|
||||
format!("2024-01-{:02}T10:00:00Z", 10 + conv_num)
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add 3 messages per conversation
|
||||
for msg_num in 0..3 {
|
||||
@@ -150,9 +155,9 @@ mod e2e_import_tests {
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
&conv_id,
|
||||
conv_id.clone(),
|
||||
role,
|
||||
format!(
|
||||
"{} message {} from conversation {}",
|
||||
@@ -160,7 +165,8 @@ mod e2e_import_tests {
|
||||
),
|
||||
format!("2024-01-{:02}T10:{:02}:00Z", 10 + conv_num, msg_num * 10)
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +177,9 @@ mod e2e_import_tests {
|
||||
// Configuration & Settings Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_full_config_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_full_config_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
@@ -198,9 +204,9 @@ mod e2e_import_tests {
|
||||
assert!(config.other_settings.contains_key("custom_setting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_mapping_to_ironclaw_format() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_settings_mapping_to_ironclaw_format() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
@@ -224,9 +230,9 @@ mod e2e_import_tests {
|
||||
// Credential Extraction Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_credentials_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_credentials_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
@@ -249,9 +255,9 @@ mod e2e_import_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credentials_never_logged() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_credentials_never_logged() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
@@ -271,9 +277,9 @@ mod e2e_import_tests {
|
||||
// Data Volume Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_full_workspace_import_counts() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_full_workspace_import_counts() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -288,9 +294,9 @@ mod e2e_import_tests {
|
||||
assert_eq!(agent_dbs.len(), 2); // primary + secondary
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_memory_chunks_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_full_memory_chunks_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -299,6 +305,7 @@ mod e2e_import_tests {
|
||||
for (_name, db_path) in agent_dbs {
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read memory chunks failed");
|
||||
assert_eq!(chunks.len(), 5);
|
||||
|
||||
@@ -314,9 +321,9 @@ mod e2e_import_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_conversations_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_full_conversations_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -325,6 +332,7 @@ mod e2e_import_tests {
|
||||
for (_name, db_path) in agent_dbs {
|
||||
let conversations = reader
|
||||
.read_conversations(&db_path)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 3);
|
||||
|
||||
@@ -387,8 +395,8 @@ mod e2e_import_tests {
|
||||
// Error Handling Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_on_corrupt_sqlite() {
|
||||
#[tokio::test]
|
||||
async fn test_error_on_corrupt_sqlite() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -410,7 +418,7 @@ mod e2e_import_tests {
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// But reading should fail
|
||||
let result = reader.read_memory_chunks(&dbs[0].1);
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -437,9 +445,9 @@ mod e2e_import_tests {
|
||||
// Extensibility Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_multiple_agents_independent_data() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_multiple_agents_independent_data() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -453,14 +461,15 @@ mod e2e_import_tests {
|
||||
for (_name, db_path) in &agent_dbs {
|
||||
let chunks = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_diversity_in_conversations() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_channel_diversity_in_conversations() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -468,6 +477,7 @@ mod e2e_import_tests {
|
||||
// Get conversations from first agent
|
||||
let conversations = reader
|
||||
.read_conversations(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
|
||||
// Should have different channels
|
||||
|
||||
@@ -112,8 +112,8 @@ mod error_handling_tests {
|
||||
// SQLite Database Errors
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_corrupt_sqlite_file() {
|
||||
#[tokio::test]
|
||||
async fn test_error_corrupt_sqlite_file() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -133,12 +133,12 @@ mod error_handling_tests {
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// But reading should fail
|
||||
let result = reader.read_memory_chunks(&dbs[0].1);
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_missing_chunks_table() {
|
||||
#[tokio::test]
|
||||
async fn test_error_missing_chunks_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -148,14 +148,17 @@ mod error_handling_tests {
|
||||
let db_path = agents_dir.join("no_chunks.sqlite");
|
||||
|
||||
// Create valid SQLite but without chunks table
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -163,12 +166,12 @@ mod error_handling_tests {
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// Should fail: chunks table doesn't exist
|
||||
let result = reader.read_memory_chunks(&dbs[0].1);
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_missing_conversations_table() {
|
||||
#[tokio::test]
|
||||
async fn test_error_missing_conversations_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -177,15 +180,18 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("no_conversations.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
// Only create chunks table, not conversations
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -193,7 +199,7 @@ mod error_handling_tests {
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// Should fail: conversations table doesn't exist
|
||||
let result = reader.read_conversations(&dbs[0].1);
|
||||
let result = reader.read_conversations(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -201,8 +207,8 @@ mod error_handling_tests {
|
||||
// Edge Cases
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_empty_chunks_table() {
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_empty_chunks_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -211,14 +217,17 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("empty.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -227,12 +236,13 @@ mod error_handling_tests {
|
||||
// Should succeed but return empty list
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_empty_conversations_table() {
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_empty_conversations_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -241,19 +251,23 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("empty_conv.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -262,12 +276,13 @@ mod error_handling_tests {
|
||||
// Should succeed but return empty list
|
||||
let conversations = reader
|
||||
.read_conversations(&dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_very_large_content() {
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_very_large_content() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -276,22 +291,26 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("large.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert very large content (1MB)
|
||||
let large_content = "x".repeat(1024 * 1024);
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params!["id1", "path", large_content, None::<Vec<u8>>, 0],
|
||||
libsql::params!["id1", "path", large_content, libsql::Value::Null, 0i64],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -300,13 +319,14 @@ mod error_handling_tests {
|
||||
// Should still succeed
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].content.len(), 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_special_characters_in_content() {
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_special_characters_in_content() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -315,22 +335,26 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("special.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert content with special characters
|
||||
let special_content = "Content with emoji 🚀 and UTF-8: 中文, العربية, ελληνικά";
|
||||
let special_content = "Content with emoji \u{1f680} and UTF-8: \u{4e2d}\u{6587}, \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064a}\u{0629}, \u{03b5}\u{03bb}\u{03bb}\u{03b7}\u{03bd}\u{03b9}\u{03ba}\u{03ac}";
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params!["id1", "path", special_content, None::<Vec<u8>>, 0],
|
||||
libsql::params!["id1", "path", special_content, libsql::Value::Null, 0i64],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -339,14 +363,15 @@ mod error_handling_tests {
|
||||
// Should handle special characters
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].content.contains("🚀"));
|
||||
assert!(chunks[0].content.contains("中文"));
|
||||
assert!(chunks[0].content.contains("\u{1f680}"));
|
||||
assert!(chunks[0].content.contains("\u{4e2d}\u{6587}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_null_values_in_fields() {
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_null_values_in_fields() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -355,33 +380,39 @@ mod error_handling_tests {
|
||||
|
||||
let db_path = agents_dir.join("nulls.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db creation failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert conversation with NULL created_at
|
||||
conn.execute(
|
||||
"INSERT INTO conversations VALUES (?, ?, ?)",
|
||||
rusqlite::params!["conv1", "telegram", None::<String>],
|
||||
libsql::params!["conv1", "telegram", libsql::Value::Null],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
// Insert message with NULL created_at
|
||||
conn.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params!["msg1", "conv1", "user", "hello", None::<String>],
|
||||
libsql::params!["msg1", "conv1", "user", "hello", libsql::Value::Null],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
drop(conn);
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -390,6 +421,7 @@ mod error_handling_tests {
|
||||
// Should handle NULL timestamps gracefully
|
||||
let conversations = reader
|
||||
.read_conversations(&dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 1);
|
||||
assert!(conversations[0].created_at.is_none());
|
||||
|
||||
@@ -17,7 +17,7 @@ mod idempotency_tests {
|
||||
use ironclaw::import::{ImportOptions, ImportStats};
|
||||
|
||||
/// Helper: Create minimal test OpenClaw
|
||||
fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -40,8 +40,8 @@ mod idempotency_tests {
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let db_path = agents_dir.join("agent.sqlite");
|
||||
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let db = libsql::Builder::new_local(&db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
@@ -51,24 +51,27 @@ mod idempotency_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Test content",
|
||||
None::<Vec<u8>>,
|
||||
0
|
||||
libsql::Value::Null,
|
||||
0i64
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
@@ -78,8 +81,9 @@ mod idempotency_tests {
|
||||
content TEXT,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
@@ -88,9 +92,9 @@ mod idempotency_tests {
|
||||
// Idempotency Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_reader_idempotent_config_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_config_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -109,9 +113,9 @@ mod idempotency_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reader_idempotent_workspace_file_listing() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_workspace_file_listing() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -123,9 +127,9 @@ mod idempotency_tests {
|
||||
assert_eq!(count1, 1); // MEMORY.md
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reader_idempotent_memory_chunk_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_memory_chunk_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -134,9 +138,11 @@ mod idempotency_tests {
|
||||
// Read chunks twice
|
||||
let chunks1 = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("first read failed");
|
||||
let chunks2 = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("second read failed");
|
||||
|
||||
// Same number of chunks
|
||||
@@ -197,10 +203,10 @@ mod idempotency_tests {
|
||||
assert!(!normal_opts.dry_run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dry_run_stats_would_be_same() {
|
||||
#[tokio::test]
|
||||
async fn test_dry_run_stats_would_be_same() {
|
||||
// Simulating what import stats would be in dry-run vs real run
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed");
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
@@ -235,9 +241,9 @@ mod idempotency_tests {
|
||||
// Duplicate Prevention Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_chunk_deduplication_by_path() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed");
|
||||
#[tokio::test]
|
||||
async fn test_chunk_deduplication_by_path() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
@@ -245,6 +251,7 @@ mod idempotency_tests {
|
||||
|
||||
let chunks = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
// All chunks should have unique (path, chunk_index) pairs
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
//! verifying that data is correctly stored, idempotent, and that dry-run mode
|
||||
//! prevents modifications.
|
||||
|
||||
#![cfg(all(feature = "import", feature = "libsql"))]
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(all(feature = "import", feature = "libsql"))]
|
||||
#[cfg(feature = "import")]
|
||||
mod import_integration_tests {
|
||||
use ironclaw::db::Database;
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
use ironclaw::import::ImportStats;
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
use ironclaw::import::{ImportOptions, ImportStats};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
@@ -29,7 +29,7 @@ mod import_integration_tests {
|
||||
}
|
||||
|
||||
/// Helper: Create a test OpenClaw directory with full structure
|
||||
fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
@@ -63,17 +63,16 @@ mod import_integration_tests {
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
create_test_agent_db(&agents_dir.join("agent1.sqlite"))?;
|
||||
create_test_agent_db(&agents_dir.join("agent2.sqlite"))?;
|
||||
create_test_agent_db(&agents_dir.join("agent1.sqlite")).await?;
|
||||
create_test_agent_db(&agents_dir.join("agent2.sqlite")).await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
/// Helper: Create a test agent SQLite database
|
||||
fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rusqlite::Connection;
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
/// Helper: Create a test agent SQLite database using libsql
|
||||
async fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = libsql::Builder::new_local(db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Chunks table
|
||||
conn.execute(
|
||||
@@ -84,27 +83,30 @@ mod import_integration_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
format!("doc/section_{}.md", i),
|
||||
format!("Chunk {} content", i),
|
||||
None::<Vec<u8>>,
|
||||
i
|
||||
libsql::Value::Null,
|
||||
i as i64
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Conversations
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
@@ -114,26 +116,29 @@ mod import_integration_tests {
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conv_id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO conversations VALUES (?, ?, ?)",
|
||||
rusqlite::params![&conv_id, "slack", "2024-01-15T10:00:00Z"],
|
||||
)?;
|
||||
"INSERT INTO conversations VALUES (?1, ?2, ?3)",
|
||||
libsql::params![conv_id.as_str(), "slack", "2024-01-15T10:00:00Z"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
for j in 0..2 {
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
&conv_id,
|
||||
conv_id.as_str(),
|
||||
if j % 2 == 0 { "user" } else { "assistant" },
|
||||
format!("Message {}", j),
|
||||
format!("2024-01-15T10:{:02}:00Z", j)
|
||||
],
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -146,8 +151,9 @@ mod import_integration_tests {
|
||||
#[tokio::test]
|
||||
async fn test_full_import_with_database_writes() {
|
||||
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) =
|
||||
create_test_openclaw().expect("OpenClaw creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Verify DB starts empty
|
||||
let before_docs = db
|
||||
@@ -175,12 +181,14 @@ mod import_integration_tests {
|
||||
// Read chunks from first agent
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 3); // 3 chunks created
|
||||
|
||||
// Read conversations from first agent
|
||||
let conversations = reader
|
||||
.read_conversations(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 1); // 1 conversation created
|
||||
assert_eq!(conversations[0].messages.len(), 2); // 2 messages
|
||||
@@ -192,12 +200,13 @@ mod import_integration_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_command_execution() {
|
||||
let (_openclaw_temp, openclaw_path) =
|
||||
create_test_openclaw().expect("OpenClaw creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
|
||||
// Create import options
|
||||
let opts = ImportOptions {
|
||||
let opts = ironclaw::import::ImportOptions {
|
||||
openclaw_path: openclaw_path.clone(),
|
||||
dry_run: false,
|
||||
re_embed: false,
|
||||
@@ -222,8 +231,9 @@ mod import_integration_tests {
|
||||
#[tokio::test]
|
||||
async fn test_dry_run_prevents_database_writes() {
|
||||
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) =
|
||||
create_test_openclaw().expect("OpenClaw creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
let user_id = "test_user";
|
||||
|
||||
@@ -235,7 +245,7 @@ mod import_integration_tests {
|
||||
let before_count = before_import.len();
|
||||
|
||||
// Create import options in DRY-RUN mode
|
||||
let opts = ImportOptions {
|
||||
let opts = ironclaw::import::ImportOptions {
|
||||
openclaw_path: openclaw_path.clone(),
|
||||
dry_run: true, // ← KEY: dry_run is enabled
|
||||
re_embed: false,
|
||||
@@ -266,8 +276,9 @@ mod import_integration_tests {
|
||||
#[tokio::test]
|
||||
async fn test_import_idempotency_no_duplicates_on_reimport() {
|
||||
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) =
|
||||
create_test_openclaw().expect("OpenClaw creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Simulate first import: count what would be imported
|
||||
let reader1 = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
@@ -282,11 +293,13 @@ mod import_integration_tests {
|
||||
for (_, db_path) in &agent_dbs1 {
|
||||
let chunks = reader1
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
total_chunks_first += chunks.len();
|
||||
|
||||
let conversations = reader1
|
||||
.read_conversations(db_path)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
total_conversations_first += conversations.len();
|
||||
}
|
||||
@@ -330,8 +343,9 @@ mod import_integration_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_embedding_dimension_mismatch_queues_reembedding() {
|
||||
let (_openclaw_temp, openclaw_path) =
|
||||
create_test_openclaw().expect("OpenClaw creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Create an agent DB with embeddings (1536-dim)
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
@@ -339,8 +353,11 @@ mod import_integration_tests {
|
||||
let db_path = agents_dir.join("with_embeddings.sqlite");
|
||||
|
||||
{
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db open failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db build failed");
|
||||
let conn = db.connect().expect("db connect failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
@@ -350,33 +367,36 @@ mod import_integration_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Create a 1536-dimensional embedding (ada-002 size)
|
||||
// Each f32 is 4 bytes, so 1536 * 4 = 6144 bytes
|
||||
let embedding_1536_bytes = vec![0.1f32; 1536]
|
||||
let embedding_1536_bytes: Vec<u8> = vec![0.1f32; 1536]
|
||||
.iter()
|
||||
.flat_map(|f| f.to_le_bytes().to_vec())
|
||||
.collect::<Vec<u8>>();
|
||||
.collect();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Chunk with embedding",
|
||||
&embedding_1536_bytes,
|
||||
0
|
||||
embedding_1536_bytes,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create conv table failed");
|
||||
|
||||
conn.execute(
|
||||
@@ -387,8 +407,9 @@ mod import_integration_tests {
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create messages table failed");
|
||||
}
|
||||
|
||||
@@ -396,6 +417,7 @@ mod import_integration_tests {
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
assert_eq!(chunks.len(), 1);
|
||||
@@ -417,16 +439,10 @@ mod import_integration_tests {
|
||||
}
|
||||
|
||||
// Simulate dimension mismatch scenario:
|
||||
// - Source: 1536-dim (ada-002)
|
||||
// - Target: 3072-dim (text-embedding-3-large)
|
||||
// This would trigger re-embedding logic
|
||||
|
||||
let source_dim = embedding.len();
|
||||
let target_dim = 3072; // text-embedding-3-large
|
||||
|
||||
if source_dim != target_dim {
|
||||
// In real import, this would queue the chunk for re-embedding
|
||||
// Verify the logic: dimensions don't match, so chunk needs re-embedding
|
||||
assert!(
|
||||
source_dim != target_dim,
|
||||
"Dimension mismatch detected: {} -> {}",
|
||||
@@ -434,7 +450,6 @@ mod import_integration_tests {
|
||||
target_dim
|
||||
);
|
||||
|
||||
// Track that this chunk would need re-embedding
|
||||
let mut re_embed_queued = 0;
|
||||
if source_dim != target_dim {
|
||||
re_embed_queued += 1;
|
||||
@@ -466,8 +481,11 @@ mod import_integration_tests {
|
||||
let db_path = agents_dir.join("same_dim.sqlite");
|
||||
|
||||
{
|
||||
use rusqlite::Connection;
|
||||
let conn = Connection::open(&db_path).expect("db open failed");
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db build failed");
|
||||
let conn = db.connect().expect("db connect failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
@@ -477,32 +495,35 @@ mod import_integration_tests {
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// 1536-dimensional embedding (text-embedding-3-small)
|
||||
let embedding_bytes = vec![0.5f32; 1536]
|
||||
let embedding_bytes: Vec<u8> = vec![0.5f32; 1536]
|
||||
.iter()
|
||||
.flat_map(|f| f.to_le_bytes().to_vec())
|
||||
.collect::<Vec<u8>>();
|
||||
.collect();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Chunk",
|
||||
&embedding_bytes,
|
||||
0
|
||||
embedding_bytes,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create conv table failed");
|
||||
|
||||
conn.execute(
|
||||
@@ -513,14 +534,16 @@ mod import_integration_tests {
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
[],
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create messages table failed");
|
||||
}
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
let embedding = chunks[0].embedding.as_ref().unwrap();
|
||||
|
||||
@@ -183,7 +183,15 @@ pub fn verify_expects(
|
||||
|
||||
// all_tools_succeeded
|
||||
if expects.all_tools_succeeded == Some(true) {
|
||||
assert_all_tools_succeeded(completed);
|
||||
let failed: Vec<&str> = completed
|
||||
.iter()
|
||||
.filter(|(_, success)| !*success)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// max_tool_calls
|
||||
|
||||
@@ -312,7 +312,23 @@ impl TestRig {
|
||||
.collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let results = self.tool_results();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&all_response_strings,
|
||||
@@ -339,7 +355,23 @@ impl TestRig {
|
||||
let response_strings: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let results = self.tool_results();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&response_strings,
|
||||
@@ -394,7 +426,7 @@ impl TestRigBuilder {
|
||||
llm: None,
|
||||
max_tool_iterations: 10,
|
||||
injection_check: false,
|
||||
auto_approve_tools: None,
|
||||
auto_approve_tools: Some(true),
|
||||
enable_skills: false,
|
||||
enable_routines: false,
|
||||
http_exchanges: Vec::new(),
|
||||
@@ -567,11 +599,20 @@ impl TestRigBuilder {
|
||||
.await
|
||||
.expect("AppBuilder::build_all() failed in test rig");
|
||||
|
||||
// AppBuilder may re-resolve config from env/TOML and override test defaults.
|
||||
// Force test-rig agent flags to the requested deterministic values.
|
||||
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
|
||||
components.config.agent.allow_local_tools = true;
|
||||
|
||||
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// 6. Register job tools, routine tools, and extra tools.
|
||||
{
|
||||
// Ensure filesystem/shell dev tools are always available in the
|
||||
// test rig, even if upstream builder flags/config disable local tools.
|
||||
components.tools.register_dev_tools();
|
||||
|
||||
components.tools.register_job_tools(
|
||||
Arc::clone(&components.context_manager),
|
||||
Some(scheduler_slot.clone()),
|
||||
|
||||
Reference in New Issue
Block a user