mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 00:29:24 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca8b1bf68 | ||
|
|
d887309208 | ||
|
|
0c119b5c1e |
+1
-1
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# === MiniMax ===
|
# === MiniMax ===
|
||||||
# LLM_BACKEND=minimax
|
# LLM_BACKEND=minimax
|
||||||
# MINIMAX_API_KEY=...
|
# MINIMAX_API_KEY=...
|
||||||
# MINIMAX_MODEL=MiniMax-M2.7
|
# MINIMAX_MODEL=MiniMax-M2.5
|
||||||
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
|
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
|
||||||
|
|
||||||
# === Anthropic Direct ===
|
# === Anthropic Direct ===
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
run: |
|
run: |
|
||||||
pytest tests/e2e/ -v --timeout=120
|
pytest tests/e2e/ -v -x --timeout=120
|
||||||
env:
|
env:
|
||||||
RUST_LOG: ironclaw=info
|
RUST_LOG: ironclaw=info
|
||||||
RUST_BACKTRACE: "1"
|
RUST_BACKTRACE: "1"
|
||||||
|
|||||||
@@ -43,42 +43,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 1b. Does this PR touch high-risk state machine or resilience code? ---
|
if [ "$IS_FIX" = false ]; then
|
||||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
|
echo "Not a fix PR — skipping regression test check."
|
||||||
|
|
||||||
TOUCHES_HIGH_RISK=false
|
|
||||||
HIGH_RISK_PATTERNS=(
|
|
||||||
"src/context/state.rs"
|
|
||||||
"src/agent/session.rs"
|
|
||||||
"src/llm/circuit_breaker.rs"
|
|
||||||
"src/llm/retry.rs"
|
|
||||||
"src/llm/failover.rs"
|
|
||||||
"src/agent/self_repair.rs"
|
|
||||||
"src/agent/agentic_loop.rs"
|
|
||||||
"src/tools/execute.rs"
|
|
||||||
"crates/ironclaw_safety/src/"
|
|
||||||
)
|
|
||||||
|
|
||||||
for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
|
|
||||||
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
|
|
||||||
TOUCHES_HIGH_RISK=true
|
|
||||||
echo "High-risk file matched: $pattern"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Skip only if NEITHER condition holds — no double-firing on fix PRs
|
|
||||||
if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
|
|
||||||
echo "Not a fix PR and no high-risk files changed — skipping."
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$IS_FIX" = true ]; then
|
echo "Fix PR detected."
|
||||||
echo "Fix PR detected."
|
|
||||||
fi
|
|
||||||
if [ "$TOUCHES_HIGH_RISK" = true ]; then
|
|
||||||
echo "High-risk state machine or resilience code modified."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. Skip label or commit message marker ---
|
# --- 2. Skip label or commit message marker ---
|
||||||
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
||||||
@@ -93,6 +63,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3. Exempt static-only / docs-only changes ---
|
# --- 3. Exempt static-only / docs-only changes ---
|
||||||
|
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
echo "No changed files — skipping."
|
echo "No changed files — skipping."
|
||||||
exit 0
|
exit 0
|
||||||
@@ -138,12 +110,5 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 5. No tests found ---
|
# --- 5. No tests found ---
|
||||||
if [ "$IS_FIX" = true ]; then
|
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
|
||||||
echo "::warning::This PR looks like a bug fix but contains no test changes."
|
|
||||||
fi
|
|
||||||
if [ "$TOUCHES_HIGH_RISK" = true ]; then
|
|
||||||
echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
|
|
||||||
fi
|
|
||||||
echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
|
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
|||||||
-199
@@ -7,205 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236))
|
|
||||||
- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423))
|
|
||||||
- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- bump Feishu channel version for promotion
|
|
||||||
- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257))
|
|
||||||
- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375))
|
|
||||||
|
|
||||||
## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712))
|
|
||||||
- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233))
|
|
||||||
- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191))
|
|
||||||
- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408))
|
|
||||||
- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400))
|
|
||||||
- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385))
|
|
||||||
- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380))
|
|
||||||
- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374))
|
|
||||||
- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372))
|
|
||||||
- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360))
|
|
||||||
- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312))
|
|
||||||
- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355))
|
|
||||||
- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353))
|
|
||||||
- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351))
|
|
||||||
- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349))
|
|
||||||
- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410))
|
|
||||||
- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304))
|
|
||||||
- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275))
|
|
||||||
- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306))
|
|
||||||
- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291))
|
|
||||||
- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147))
|
|
||||||
|
|
||||||
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
|
|
||||||
- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
|
|
||||||
- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
|
|
||||||
- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
|
|
||||||
- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
|
|
||||||
- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
|
|
||||||
- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
|
|
||||||
- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
|
|
||||||
- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
|
|
||||||
- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
|
|
||||||
- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
|
|
||||||
- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
|
|
||||||
- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
|
|
||||||
- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
|
|
||||||
- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
|
|
||||||
- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
|
|
||||||
- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
|
|
||||||
- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
|
|
||||||
- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
|
|
||||||
- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
|
|
||||||
- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
|
|
||||||
- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
|
|
||||||
- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
|
|
||||||
- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
|
|
||||||
- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
|
|
||||||
- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
|
|
||||||
- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
|
|
||||||
- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
|
|
||||||
- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
|
|
||||||
- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
|
|
||||||
- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
|
|
||||||
- resolve merge conflict fallout and missing config fields
|
|
||||||
- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
|
|
||||||
- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
|
|
||||||
- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
|
|
||||||
- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
|
|
||||||
- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
|
|
||||||
- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
|
|
||||||
- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
|
|
||||||
- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
|
|
||||||
- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
|
|
||||||
- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
|
|
||||||
- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
|
|
||||||
- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
|
|
||||||
- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
|
|
||||||
- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
|
|
||||||
- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
|
|
||||||
- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
|
|
||||||
- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
|
|
||||||
- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
|
|
||||||
- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
|
|
||||||
- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
|
|
||||||
- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
|
|
||||||
- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
|
|
||||||
- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
|
|
||||||
- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
|
|
||||||
- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
|
|
||||||
- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
|
|
||||||
- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
|
|
||||||
- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
|
|
||||||
- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
|
|
||||||
- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
|
|
||||||
- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
|
|
||||||
- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
|
|
||||||
- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
|
|
||||||
- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
|
|
||||||
- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
|
|
||||||
- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
|
|
||||||
- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
|
|
||||||
- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
|
|
||||||
- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
|
|
||||||
- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
|
|
||||||
- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
|
|
||||||
- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
|
|
||||||
- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
|
|
||||||
- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
|
|
||||||
- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
|
|
||||||
- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
|
|
||||||
- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
|
|
||||||
- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
|
|
||||||
- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
|
|
||||||
- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
|
|
||||||
- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
|
|
||||||
- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
|
|
||||||
- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
|
|
||||||
- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
|
|
||||||
- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
|
|
||||||
- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
|
|
||||||
- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
|
|
||||||
- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
|
|
||||||
- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
|
|
||||||
- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
|
|
||||||
- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
|
|
||||||
- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
|
|
||||||
- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
|
|
||||||
- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
|
|
||||||
- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
|
|
||||||
- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
|
|
||||||
- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
|
|
||||||
- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
|
|
||||||
- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
|
|
||||||
- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
|
|
||||||
- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
|
|
||||||
- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
|
|
||||||
- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
|
|
||||||
- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
|
|
||||||
- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
|
|
||||||
- Merge branch 'main' into fix/resolve-conflicts
|
|
||||||
- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
|
|
||||||
- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
|
|
||||||
- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
|
|
||||||
- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
|
|
||||||
- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
|
|
||||||
- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
|
|
||||||
- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
|
|
||||||
- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
|
|
||||||
- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
|
|
||||||
- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
|
|
||||||
- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
|
|
||||||
- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
|
|
||||||
- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
|
|
||||||
- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
|
|
||||||
- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
|
|
||||||
- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
|
|
||||||
- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
|
|
||||||
- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
|
|
||||||
- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
|
|
||||||
- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
|
|
||||||
- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
|
|
||||||
- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
|
|
||||||
- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
|
|
||||||
- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
|
|
||||||
- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
|
|
||||||
- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
|
|
||||||
|
|
||||||
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
|
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
|
||||||
|
|
||||||
### Other
|
### Other
|
||||||
|
|||||||
Generated
+1
-1
@@ -3436,7 +3436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.21.0"
|
version = "0.18.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.21.0"
|
version = "0.18.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
|||||||
+1
-1
@@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Device pairing | ✅ | ❌ | |
|
| Device pairing | ✅ | ❌ | |
|
||||||
| Tailscale identity | ✅ | ❌ | |
|
| Tailscale identity | ✅ | ❌ | |
|
||||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||||
| Per-group tool policies | ✅ | ❌ | |
|
| Per-group tool policies | ✅ | ❌ | |
|
||||||
|
|||||||
-330
@@ -1,330 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 align="center">IronClaw</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
|
||||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
|
||||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="README.md">English</a> |
|
|
||||||
<a href="README.zh-CN.md">简体中文</a> |
|
|
||||||
<a href="README.ru.md">Русский</a> |
|
|
||||||
<a href="README.ja.md">日本語</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#フィロソフィー">フィロソフィー</a> •
|
|
||||||
<a href="#機能">機能</a> •
|
|
||||||
<a href="#インストール">インストール</a> •
|
|
||||||
<a href="#設定">設定</a> •
|
|
||||||
<a href="#セキュリティ">セキュリティ</a> •
|
|
||||||
<a href="#アーキテクチャ">アーキテクチャ</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## フィロソフィー
|
|
||||||
|
|
||||||
IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
|
|
||||||
|
|
||||||
AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
|
|
||||||
|
|
||||||
- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
|
|
||||||
- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
|
|
||||||
- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
|
|
||||||
- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
|
|
||||||
|
|
||||||
IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
|
|
||||||
|
|
||||||
## 機能
|
|
||||||
|
|
||||||
### セキュリティファースト
|
|
||||||
|
|
||||||
- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
|
|
||||||
- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
|
|
||||||
- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
|
|
||||||
- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
|
|
||||||
|
|
||||||
### 常時利用可能
|
|
||||||
|
|
||||||
- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
|
|
||||||
- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
|
|
||||||
- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
|
|
||||||
- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
|
|
||||||
- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
|
|
||||||
- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
|
|
||||||
- **自己修復** - スタックした操作の自動検出と復旧
|
|
||||||
|
|
||||||
### 自己拡張
|
|
||||||
|
|
||||||
- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
|
|
||||||
- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
|
|
||||||
- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
|
|
||||||
|
|
||||||
### 永続メモリ
|
|
||||||
|
|
||||||
- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
|
|
||||||
- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
|
|
||||||
- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
|
|
||||||
|
|
||||||
## インストール
|
|
||||||
|
|
||||||
### 前提条件
|
|
||||||
|
|
||||||
- Rust 1.85+
|
|
||||||
- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
|
|
||||||
- NEAR AIアカウント(セットアップウィザードで認証を処理)
|
|
||||||
|
|
||||||
## ダウンロードまたはビルド
|
|
||||||
|
|
||||||
最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Windowsインストーラーでインストール(Windows)</summary>
|
|
||||||
|
|
||||||
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>PowerShellスクリプトでインストール(Windows)</summary>
|
|
||||||
|
|
||||||
```sh
|
|
||||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL)</summary>
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Homebrewでインストール(macOS/Linux)</summary>
|
|
||||||
|
|
||||||
```sh
|
|
||||||
brew install ironclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo)</summary>
|
|
||||||
|
|
||||||
`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# リポジトリをクローン
|
|
||||||
git clone https://github.com/nearai/ironclaw.git
|
|
||||||
cd ironclaw
|
|
||||||
|
|
||||||
# ビルド
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# テストを実行
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### データベースのセットアップ
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# データベースを作成
|
|
||||||
createdb ironclaw
|
|
||||||
|
|
||||||
# pgvectorを有効化
|
|
||||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 設定
|
|
||||||
|
|
||||||
セットアップウィザードを実行してIronClawを設定します:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ironclaw onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL`、`LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
|
|
||||||
|
|
||||||
### 代替LLMプロバイダー
|
|
||||||
|
|
||||||
IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**(300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
|
|
||||||
|
|
||||||
ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# 例:MiniMax(組み込み、204Kコンテキスト)
|
|
||||||
LLM_BACKEND=minimax
|
|
||||||
MINIMAX_API_KEY=...
|
|
||||||
|
|
||||||
# 例:OpenAI互換エンドポイント
|
|
||||||
LLM_BACKEND=openai_compatible
|
|
||||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
|
||||||
LLM_API_KEY=sk-or-...
|
|
||||||
LLM_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
|
|
||||||
|
|
||||||
## セキュリティ
|
|
||||||
|
|
||||||
IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
|
|
||||||
|
|
||||||
### WASMサンドボックス
|
|
||||||
|
|
||||||
すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
|
|
||||||
|
|
||||||
- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
|
|
||||||
- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
|
|
||||||
- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
|
|
||||||
- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
|
|
||||||
- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
|
|
||||||
- **リソース制限** - メモリ、CPU、実行時間の制約
|
|
||||||
|
|
||||||
```
|
|
||||||
WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
|
|
||||||
バリデーター スキャン 注入 実行 スキャン
|
|
||||||
(リクエスト) (レスポンス)
|
|
||||||
```
|
|
||||||
|
|
||||||
### プロンプトインジェクション防御
|
|
||||||
|
|
||||||
外部コンテンツは複数のセキュリティレイヤーを通過します:
|
|
||||||
|
|
||||||
- パターンベースのインジェクション試行検出
|
|
||||||
- コンテンツのサニタイズとエスケープ
|
|
||||||
- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
|
|
||||||
- 安全なLLMコンテキスト注入のためのツール出力ラッピング
|
|
||||||
|
|
||||||
### データ保護
|
|
||||||
|
|
||||||
- すべてのデータはローカルのPostgreSQLデータベースに保存
|
|
||||||
- AES-256-GCMでシークレットを暗号化
|
|
||||||
- テレメトリ、分析、データ共有なし
|
|
||||||
- すべてのツール実行の完全な監査ログ
|
|
||||||
|
|
||||||
## アーキテクチャ
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────────────────────┐
|
|
||||||
│ チャネル │
|
|
||||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
|
|
||||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
|
|
||||||
│ │ │ │ │(SSE + WS) │ │
|
|
||||||
│ │ │ │ └──────┬──────┘ │
|
|
||||||
│ └─────────┴──────────────┴────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────────▼─────────┐ │
|
|
||||||
│ │ エージェントループ │ インテントルーティング│
|
|
||||||
│ └────┬──────────┬───┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
|
||||||
│ │ スケジューラー │ │ ルーティン │ │
|
|
||||||
│ │ (並列ジョブ) │ │ エンジン │ │
|
|
||||||
│ └──────┬────────┘ │(cron,event,wh) │ │
|
|
||||||
│ │ └────────┬─────────┘ │
|
|
||||||
│ ┌─────────────┼────────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
|
||||||
│ │ ローカル │ │ オーケストレーター │ │
|
|
||||||
│ │ ワーカー │ │ ┌───────────────┐ │ │
|
|
||||||
│ │(プロセス │ │ │ Docker │ │ │
|
|
||||||
│ │ 内) │ │ │ サンドボックス│ │ │
|
|
||||||
│ └───┬─────┘ │ │ コンテナ │ │ │
|
|
||||||
│ │ │ │ ┌───────────┐ │ │ │
|
|
||||||
│ │ │ │ │Worker / CC│ │ │ │
|
|
||||||
│ │ │ │ └───────────┘ │ │ │
|
|
||||||
│ │ │ └───────────────┘ │ │
|
|
||||||
│ │ └─────────┬───────────┘ │
|
|
||||||
│ └──────────────────┤ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────▼──────────┐ │
|
|
||||||
│ │ ツールレジストリ │ │
|
|
||||||
│ │ 組み込み, MCP, WASM │ │
|
|
||||||
│ └──────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### コアコンポーネント
|
|
||||||
|
|
||||||
| コンポーネント | 目的 |
|
|
||||||
|---------------|------|
|
|
||||||
| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
|
|
||||||
| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
|
|
||||||
| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
|
|
||||||
| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
|
|
||||||
| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
|
|
||||||
| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
|
|
||||||
| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
|
|
||||||
| **ワークスペース** | ハイブリッド検索付き永続メモリ |
|
|
||||||
| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
|
|
||||||
|
|
||||||
## 使い方
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 初回セットアップ(データベース、認証などを設定)
|
|
||||||
ironclaw onboard
|
|
||||||
|
|
||||||
# インタラクティブREPLを起動
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# デバッグログ付き
|
|
||||||
RUST_LOG=ironclaw=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 開発
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# コードフォーマット
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# リント
|
|
||||||
cargo clippy --all --benches --tests --examples --all-features
|
|
||||||
|
|
||||||
# テスト実行
|
|
||||||
createdb ironclaw_test
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# 特定のテストを実行
|
|
||||||
cargo test test_name
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
|
|
||||||
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
|
|
||||||
|
|
||||||
## OpenClawの系譜
|
|
||||||
|
|
||||||
IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
|
|
||||||
|
|
||||||
主な違い:
|
|
||||||
|
|
||||||
- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
|
|
||||||
- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
|
|
||||||
- **PostgreSQL vs SQLite** - 本番環境対応の永続化
|
|
||||||
- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
|
|
||||||
|
|
||||||
## ライセンス
|
|
||||||
|
|
||||||
以下のいずれかのライセンスの下で提供されています:
|
|
||||||
|
|
||||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
|
||||||
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
|
||||||
|
|
||||||
お好みに応じて選択してください。
|
|
||||||
@@ -17,8 +17,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> |
|
<a href="README.md">English</a> |
|
||||||
<a href="README.zh-CN.md">简体中文</a> |
|
<a href="README.zh-CN.md">简体中文</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a>
|
||||||
<a href="README.ja.md">日本語</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+1
-2
@@ -17,8 +17,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> |
|
<a href="README.md">English</a> |
|
||||||
<a href="README.zh-CN.md">简体中文</a> |
|
<a href="README.zh-CN.md">简体中文</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a>
|
||||||
<a href="README.ja.md">日本語</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
+1
-2
@@ -17,8 +17,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">English</a> |
|
<a href="README.md">English</a> |
|
||||||
<a href="README.zh-CN.md">简体中文</a> |
|
<a href="README.zh-CN.md">简体中文</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a>
|
||||||
<a href="README.ja.md">日本語</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -206,17 +206,9 @@ struct FeishuApiResponse<T> {
|
|||||||
data: Option<T>,
|
data: Option<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenant access token response (flat format).
|
/// Tenant access token response.
|
||||||
///
|
#[derive(Debug, Default, Deserialize)]
|
||||||
/// Unlike most Feishu APIs that nest results under `data`, the
|
struct TenantAccessTokenData {
|
||||||
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
|
|
||||||
/// `tenant_access_token`, and `expire` at the top level.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TenantAccessTokenResponse {
|
|
||||||
#[serde(default)]
|
|
||||||
code: i32,
|
|
||||||
#[serde(default)]
|
|
||||||
msg: String,
|
|
||||||
tenant_access_token: String,
|
tenant_access_token: String,
|
||||||
expire: i64,
|
expire: i64,
|
||||||
}
|
}
|
||||||
@@ -778,8 +770,9 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
|
let token_resp: FeishuApiResponse<TenantAccessTokenData> =
|
||||||
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
serde_json::from_slice(&response.body)
|
||||||
|
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
||||||
|
|
||||||
if token_resp.code != 0 {
|
if token_resp.code != 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -788,33 +781,23 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if token_resp.tenant_access_token.is_empty() {
|
let data = token_resp
|
||||||
return Err("Token response missing tenant_access_token".to_string());
|
.data
|
||||||
}
|
.ok_or_else(|| "Token response missing data".to_string())?;
|
||||||
|
|
||||||
if token_resp.expire <= 0 {
|
|
||||||
return Err(format!(
|
|
||||||
"Token response has invalid expire value: {}",
|
|
||||||
token_resp.expire
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the token with expiry.
|
// Cache the token with expiry.
|
||||||
let now = channel_host::now_millis();
|
let now = channel_host::now_millis();
|
||||||
let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
|
let expiry = now + (data.expire as u64) * 1000;
|
||||||
|
|
||||||
let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
|
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token);
|
||||||
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
|
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
|
||||||
|
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Debug,
|
channel_host::LogLevel::Debug,
|
||||||
&format!(
|
&format!("Tenant access token refreshed, expires in {}s", data.expire),
|
||||||
"Tenant access token refreshed, expires in {}s",
|
|
||||||
token_resp.expire
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(token_resp.tenant_access_token)
|
Ok(data.tenant_access_token)
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Token exchange request failed: {}", e)),
|
Err(e) => Err(format!("Token exchange request failed: {}", e)),
|
||||||
}
|
}
|
||||||
@@ -836,60 +819,3 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
|
|||||||
body: body_bytes,
|
body: body_bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_flat_token_response() {
|
|
||||||
let json = r#"{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"tenant_access_token": "t-abc123",
|
|
||||||
"expire": 7200
|
|
||||||
}"#;
|
|
||||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(resp.code, 0);
|
|
||||||
assert_eq!(resp.msg, "ok");
|
|
||||||
assert_eq!(resp.tenant_access_token, "t-abc123");
|
|
||||||
assert_eq!(resp.expire, 7200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_token_response_rejects_missing_token() {
|
|
||||||
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
|
|
||||||
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
|
||||||
assert!(result.is_err(), "should fail when tenant_access_token is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_token_response_rejects_missing_expire() {
|
|
||||||
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
|
|
||||||
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
|
||||||
assert!(result.is_err(), "should fail when expire is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_token_response_defaults_code_and_msg() {
|
|
||||||
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
|
|
||||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(resp.code, 0);
|
|
||||||
assert_eq!(resp.msg, "");
|
|
||||||
assert_eq!(resp.tenant_access_token, "t-abc");
|
|
||||||
assert_eq!(resp.expire, 3600);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_token_error_response() {
|
|
||||||
let json = r#"{
|
|
||||||
"code": 10003,
|
|
||||||
"msg": "invalid app_id",
|
|
||||||
"tenant_access_token": "",
|
|
||||||
"expire": 0
|
|
||||||
}"#;
|
|
||||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(resp.code, 10003);
|
|
||||||
assert!(resp.tenant_access_token.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -360,8 +360,6 @@ enum TelegramStatusAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
|
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
|
||||||
/// Telegram's hard limit for message text length.
|
|
||||||
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
|
|
||||||
|
|
||||||
fn truncate_status_message(input: &str, max_chars: usize) -> String {
|
fn truncate_status_message(input: &str, max_chars: usize) -> String {
|
||||||
let mut iter = input.chars();
|
let mut iter = input.chars();
|
||||||
@@ -373,73 +371,6 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a long message into chunks that fit within Telegram's 4096-char limit.
|
|
||||||
///
|
|
||||||
/// Tries to split at the most natural boundary available (in priority order):
|
|
||||||
/// 1. Double newline (paragraph break)
|
|
||||||
/// 2. Single newline
|
|
||||||
/// 3. Sentence end (`. `, `! `, `? `)
|
|
||||||
/// 4. Word boundary (space)
|
|
||||||
/// 5. Hard cut at the limit (last resort for pathological input)
|
|
||||||
fn split_message(text: &str) -> Vec<String> {
|
|
||||||
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
|
|
||||||
return vec![text.to_string()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunks: Vec<String> = Vec::new();
|
|
||||||
let mut remaining = text;
|
|
||||||
|
|
||||||
while !remaining.is_empty() {
|
|
||||||
// Count chars to find the byte offset for our window.
|
|
||||||
let window_bytes = remaining
|
|
||||||
.char_indices()
|
|
||||||
.take(TELEGRAM_MAX_MESSAGE_LEN)
|
|
||||||
.last()
|
|
||||||
.map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
|
|
||||||
.unwrap_or(remaining.len());
|
|
||||||
|
|
||||||
if window_bytes >= remaining.len() {
|
|
||||||
// Remainder fits entirely.
|
|
||||||
chunks.push(remaining.to_string());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let window = &remaining[..window_bytes];
|
|
||||||
|
|
||||||
// 1. Double newline — best paragraph boundary
|
|
||||||
let split_at = window.rfind("\n\n")
|
|
||||||
// 2. Single newline
|
|
||||||
.or_else(|| window.rfind('\n'))
|
|
||||||
// 3. Sentence-ending punctuation followed by space.
|
|
||||||
// Note: this only detects ASCII punctuation (. ! ?), not CJK
|
|
||||||
// sentence-ending marks (。!?). CJK text falls through to
|
|
||||||
// word-boundary or hard-cut splitting.
|
|
||||||
.or_else(|| {
|
|
||||||
let bytes = window.as_bytes();
|
|
||||||
// Search backwards for '. ', '! ', '? '
|
|
||||||
(1..bytes.len()).rev().find(|&i| {
|
|
||||||
matches!(bytes[i - 1], b'.' | b'!' | b'?') && bytes[i] == b' '
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// 4. Word boundary (last space)
|
|
||||||
.or_else(|| window.rfind(' '))
|
|
||||||
// 5. Hard cut
|
|
||||||
.unwrap_or(window_bytes);
|
|
||||||
|
|
||||||
// Avoid empty chunks (e.g. text starting with \n\n).
|
|
||||||
let split_at = if split_at == 0 { window_bytes } else { split_at };
|
|
||||||
|
|
||||||
// Trim whitespace at chunk boundaries for clean Telegram display.
|
|
||||||
// Note: this drops leading/trailing spaces at split points, which is
|
|
||||||
// acceptable for chat messages but means the concatenation of chunks
|
|
||||||
// may not exactly equal the original text when split at spaces.
|
|
||||||
chunks.push(remaining[..split_at].trim_end().to_string());
|
|
||||||
remaining = remaining[split_at..].trim_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
|
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
|
||||||
let message = update.message.trim();
|
let message = update.message.trim();
|
||||||
if message.is_empty() {
|
if message.is_empty() {
|
||||||
@@ -1311,64 +1242,26 @@ fn send_response(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split large messages into chunks that fit Telegram's limit.
|
// Try Markdown, fall back to plain text on parse errors
|
||||||
let chunks = split_message(&response.content);
|
match send_message(
|
||||||
let total = chunks.len();
|
chat_id,
|
||||||
|
&response.content,
|
||||||
// The first chunk replies to the original message; subsequent chunks
|
reply_to_message_id,
|
||||||
// reply to the previously sent chunk so they form a visual thread.
|
Some("Markdown"),
|
||||||
let mut reply_to = reply_to_message_id;
|
message_thread_id,
|
||||||
|
) {
|
||||||
for (i, chunk) in chunks.into_iter().enumerate() {
|
Ok(_) => Ok(()),
|
||||||
// Try Markdown, fall back to plain text on parse errors
|
Err(SendError::ParseEntities(_)) => send_message(
|
||||||
let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
|
chat_id,
|
||||||
|
&response.content,
|
||||||
let msg_id = match result {
|
reply_to_message_id,
|
||||||
Ok(id) => {
|
None,
|
||||||
channel_host::log(
|
message_thread_id,
|
||||||
channel_host::LogLevel::Debug,
|
)
|
||||||
&format!(
|
.map(|_| ())
|
||||||
"Sent message chunk {}/{} to chat {}: message_id={}",
|
.map_err(|e| format!("Plain-text retry also failed: {}", e)),
|
||||||
i + 1,
|
Err(e) => Err(e.to_string()),
|
||||||
total,
|
|
||||||
chat_id,
|
|
||||||
id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
Err(SendError::ParseEntities(detail)) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!(
|
|
||||||
"Markdown parse failed on chunk {}/{} ({}), retrying as plain text",
|
|
||||||
i + 1,
|
|
||||||
total,
|
|
||||||
detail
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let id = send_message(chat_id, &chunk, reply_to, None, message_thread_id)
|
|
||||||
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
&format!(
|
|
||||||
"Sent plain-text chunk {}/{} to chat {}: message_id={}",
|
|
||||||
i + 1,
|
|
||||||
total,
|
|
||||||
chat_id,
|
|
||||||
id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each subsequent chunk threads off the previous sent message.
|
|
||||||
reply_to = Some(msg_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
|
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
|
||||||
@@ -2150,102 +2043,6 @@ export!(TelegramChannel);
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_short() {
|
|
||||||
let text = "Hello, world!";
|
|
||||||
let chunks = split_message(text);
|
|
||||||
assert_eq!(chunks, vec![text]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_paragraph_boundary() {
|
|
||||||
let para_a = "A".repeat(3000);
|
|
||||||
let para_b = "B".repeat(3000);
|
|
||||||
let text = format!("{}\n\n{}", para_a, para_b);
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
assert_eq!(chunks.len(), 2);
|
|
||||||
assert_eq!(chunks[0], para_a);
|
|
||||||
assert_eq!(chunks[1], para_b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_word_boundary() {
|
|
||||||
// Build a string well over the limit with no newlines.
|
|
||||||
let words: Vec<String> = (0..1000).map(|i| format!("word{:04}", i)).collect();
|
|
||||||
let text = words.join(" ");
|
|
||||||
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
assert!(chunks.len() > 1, "expected multiple chunks");
|
|
||||||
for chunk in &chunks {
|
|
||||||
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
}
|
|
||||||
// Rejoined chunks must equal the original text exactly.
|
|
||||||
let rejoined = chunks.join(" ");
|
|
||||||
assert_eq!(rejoined, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_each_chunk_fits() {
|
|
||||||
// Stress-test: 20 000 chars of mixed text.
|
|
||||||
let text: String = (0..500)
|
|
||||||
.map(|i| format!("Sentence number {}. ", i))
|
|
||||||
.collect();
|
|
||||||
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
for chunk in &chunks {
|
|
||||||
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_sentence_boundary() {
|
|
||||||
// Build text that exceeds the limit, with sentence boundaries inside.
|
|
||||||
let sentence = "This is a test sentence. ";
|
|
||||||
let repeat_count = TELEGRAM_MAX_MESSAGE_LEN / sentence.len() + 5;
|
|
||||||
let text: String = sentence.repeat(repeat_count);
|
|
||||||
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
assert!(chunks.len() > 1);
|
|
||||||
// First chunk should end at a sentence boundary (trimmed)
|
|
||||||
let first = &chunks[0];
|
|
||||||
assert!(
|
|
||||||
first.ends_with('.'),
|
|
||||||
"First chunk should end at a sentence boundary, got: ...{}",
|
|
||||||
&first[first.len().saturating_sub(20)..]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_hard_cut_no_spaces() {
|
|
||||||
// Pathological input: a single huge "word" with no spaces or newlines.
|
|
||||||
let text = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN * 2 + 100);
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
assert!(chunks.len() >= 2);
|
|
||||||
for chunk in &chunks {
|
|
||||||
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
}
|
|
||||||
// Rejoined must preserve all characters
|
|
||||||
let rejoined: String = chunks.concat();
|
|
||||||
assert_eq!(rejoined, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_split_message_multibyte_chars() {
|
|
||||||
// Emoji are 4 bytes each. Ensure we don't panic or split mid-character.
|
|
||||||
let emoji = "\u{1F600}"; // 😀
|
|
||||||
let text: String = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN + 100);
|
|
||||||
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
|
|
||||||
let chunks = split_message(&text);
|
|
||||||
assert!(chunks.len() >= 2);
|
|
||||||
for chunk in &chunks {
|
|
||||||
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
|
|
||||||
// Every char should be a complete emoji
|
|
||||||
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clean_message_text() {
|
fn test_clean_message_text() {
|
||||||
// Without bot_username: strips any leading @mention
|
// Without bot_username: strips any leading @mention
|
||||||
|
|||||||
+4
-8
@@ -2,13 +2,9 @@ coverage:
|
|||||||
status:
|
status:
|
||||||
project:
|
project:
|
||||||
default:
|
default:
|
||||||
target: 80%
|
target: auto
|
||||||
threshold: 2%
|
threshold: 1%
|
||||||
patch:
|
patch:
|
||||||
default:
|
default:
|
||||||
target: 90%
|
target: 80%
|
||||||
|
threshold: 5%
|
||||||
comment:
|
|
||||||
layout: "reach,diff,flags"
|
|
||||||
behavior: default
|
|
||||||
require_changes: true
|
|
||||||
@@ -15,7 +15,7 @@ configurations.
|
|||||||
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
||||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
||||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||||
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
|
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
|
||||||
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
||||||
| Ollama | `ollama` | No | Local inference |
|
| Ollama | `ollama` | No | Local inference |
|
||||||
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
||||||
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
|
|||||||
MINIMAX_API_KEY=...
|
MINIMAX_API_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
|
Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
|
||||||
|
|
||||||
To use the China mainland endpoint, set:
|
To use the China mainland endpoint, set:
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -393,8 +393,8 @@
|
|||||||
"api_key_required": true,
|
"api_key_required": true,
|
||||||
"base_url_env": "MINIMAX_BASE_URL",
|
"base_url_env": "MINIMAX_BASE_URL",
|
||||||
"model_env": "MINIMAX_MODEL",
|
"model_env": "MINIMAX_MODEL",
|
||||||
"default_model": "MiniMax-M2.7",
|
"default_model": "MiniMax-M2.5",
|
||||||
"description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
|
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
|
||||||
"setup": {
|
"setup": {
|
||||||
"kind": "api_key",
|
"kind": "api_key",
|
||||||
"secret_name": "llm_minimax_api_key",
|
"secret_name": "llm_minimax_api_key",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
|
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "feishu",
|
"name": "feishu",
|
||||||
"display_name": "Feishu / Lark Channel",
|
"display_name": "Feishu / Lark Channel",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.2",
|
"version": "0.1.1",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Talk to your agent through a Feishu or Lark bot",
|
"description": "Talk to your agent through a Feishu or Lark bot",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,12 +17,7 @@
|
|||||||
"capabilities": "feishu.capabilities.json",
|
"capabilities": "feishu.capabilities.json",
|
||||||
"crate_name": "feishu-channel"
|
"crate_name": "feishu-channel"
|
||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {},
|
||||||
"wasm32-wasip2": {
|
|
||||||
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
|
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
"method": "manual",
|
"method": "manual",
|
||||||
"provider": "Feishu / Lark",
|
"provider": "Feishu / Lark",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "telegram",
|
"name": "telegram",
|
||||||
"display_name": "Telegram Channel",
|
"display_name": "Telegram Channel",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.2.5",
|
"version": "0.2.4",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Talk to your agent through a Telegram bot",
|
"description": "Talk to your agent through a Telegram bot",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
|
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
|
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
|
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
|
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
|
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
|
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ git_release_enable = false
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw_safety"
|
name = "ironclaw_safety"
|
||||||
publish = false
|
|
||||||
release = false
|
release = false
|
||||||
|
|||||||
@@ -8,21 +8,15 @@ Replace `{{...}}` placeholders before use.
|
|||||||
{
|
{
|
||||||
"name": "wf-issue-plan",
|
"name": "wf-issue-plan",
|
||||||
"description": "Create implementation plan when a new issue arrives",
|
"description": "Create implementation plan when a new issue arrives",
|
||||||
|
"trigger_type": "system_event",
|
||||||
|
"event_source": "github",
|
||||||
|
"event_type": "issue.opened",
|
||||||
|
"event_filters": {
|
||||||
|
"repository_name": "{{repository}}"
|
||||||
|
},
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
|
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
|
||||||
"request": {
|
"cooldown_secs": 30
|
||||||
"kind": "system_event",
|
|
||||||
"source": "github",
|
|
||||||
"event_type": "issue.opened",
|
|
||||||
"filters": {
|
|
||||||
"repository_name": "{{repository}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 30
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,22 +28,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
{
|
{
|
||||||
"name": "wf-maintainer-comment-gate-{{maintainer}}",
|
"name": "wf-maintainer-comment-gate-{{maintainer}}",
|
||||||
"description": "React to maintainer guidance comments on issues/PRs",
|
"description": "React to maintainer guidance comments on issues/PRs",
|
||||||
|
"trigger_type": "system_event",
|
||||||
|
"event_source": "github",
|
||||||
|
"event_type": "pr.comment.created",
|
||||||
|
"event_filters": {
|
||||||
|
"repository_name": "{{repository}}",
|
||||||
|
"comment_author": "{{maintainer}}"
|
||||||
|
},
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
|
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
|
||||||
"request": {
|
"cooldown_secs": 20
|
||||||
"kind": "system_event",
|
|
||||||
"source": "github",
|
|
||||||
"event_type": "pr.comment.created",
|
|
||||||
"filters": {
|
|
||||||
"repository_name": "{{repository}}",
|
|
||||||
"comment_author": "{{maintainer}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 20
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,21 +47,15 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
{
|
{
|
||||||
"name": "wf-pr-monitor-loop",
|
"name": "wf-pr-monitor-loop",
|
||||||
"description": "Keep PR healthy: address review comments and refresh branch",
|
"description": "Keep PR healthy: address review comments and refresh branch",
|
||||||
|
"trigger_type": "system_event",
|
||||||
|
"event_source": "github",
|
||||||
|
"event_type": "pr.synchronize",
|
||||||
|
"event_filters": {
|
||||||
|
"repository_name": "{{repository}}"
|
||||||
|
},
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
|
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
|
||||||
"request": {
|
"cooldown_secs": 20
|
||||||
"kind": "system_event",
|
|
||||||
"source": "github",
|
|
||||||
"event_type": "pr.synchronize",
|
|
||||||
"filters": {
|
|
||||||
"repository_name": "{{repository}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 20
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,22 +65,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
{
|
{
|
||||||
"name": "wf-ci-fix-loop",
|
"name": "wf-ci-fix-loop",
|
||||||
"description": "Fix failing CI checks on active PRs",
|
"description": "Fix failing CI checks on active PRs",
|
||||||
|
"trigger_type": "system_event",
|
||||||
|
"event_source": "github",
|
||||||
|
"event_type": "ci.check_run.completed",
|
||||||
|
"event_filters": {
|
||||||
|
"repository_name": "{{repository}}",
|
||||||
|
"ci_conclusion": "failure"
|
||||||
|
},
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
|
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
|
||||||
"request": {
|
"cooldown_secs": 20
|
||||||
"kind": "system_event",
|
|
||||||
"source": "github",
|
|
||||||
"event_type": "ci.check_run.completed",
|
|
||||||
"filters": {
|
|
||||||
"repository_name": "{{repository}}",
|
|
||||||
"ci_conclusion": "failure"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 20
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,17 +84,11 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
{
|
{
|
||||||
"name": "wf-staging-batch-review",
|
"name": "wf-staging-batch-review",
|
||||||
"description": "Batch correctness review through staging, then merge to main",
|
"description": "Batch correctness review through staging, then merge to main",
|
||||||
|
"trigger_type": "cron",
|
||||||
|
"schedule": "0 0 */{{batch_interval_hours}} * * *",
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
|
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
|
||||||
"request": {
|
"cooldown_secs": 120
|
||||||
"kind": "cron",
|
|
||||||
"schedule": "0 0 */{{batch_interval_hours}} * * *"
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 120
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -128,22 +98,16 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
{
|
{
|
||||||
"name": "wf-learning-memory",
|
"name": "wf-learning-memory",
|
||||||
"description": "Capture merge learnings into shared memory",
|
"description": "Capture merge learnings into shared memory",
|
||||||
|
"trigger_type": "system_event",
|
||||||
|
"event_source": "github",
|
||||||
|
"event_type": "pr.closed",
|
||||||
|
"event_filters": {
|
||||||
|
"repository_name": "{{repository}}",
|
||||||
|
"pr_merged": "true"
|
||||||
|
},
|
||||||
|
"action_type": "full_job",
|
||||||
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
|
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
|
||||||
"request": {
|
"cooldown_secs": 30
|
||||||
"kind": "system_event",
|
|
||||||
"source": "github",
|
|
||||||
"event_type": "pr.closed",
|
|
||||||
"filters": {
|
|
||||||
"repository_name": "{{repository}}",
|
|
||||||
"pr_merged": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"execution": {
|
|
||||||
"mode": "full_job"
|
|
||||||
},
|
|
||||||
"advanced": {
|
|
||||||
"cooldown_secs": 30
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -151,7 +115,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"event_source": "github",
|
"source": "github",
|
||||||
"event_type": "issue.opened",
|
"event_type": "issue.opened",
|
||||||
"payload": {
|
"payload": {
|
||||||
"repository_name": "{{repository}}",
|
"repository_name": "{{repository}}",
|
||||||
|
|||||||
+36
-41
@@ -146,8 +146,6 @@ pub struct AgentDeps {
|
|||||||
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
||||||
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
||||||
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
||||||
/// Software builder for self-repair tool rebuilding.
|
|
||||||
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main agent that coordinates all components.
|
/// The main agent that coordinates all components.
|
||||||
@@ -163,10 +161,9 @@ pub struct Agent {
|
|||||||
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
||||||
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
|
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
|
||||||
pub(super) routine_config: Option<RoutineConfig>,
|
pub(super) routine_config: Option<RoutineConfig>,
|
||||||
/// Shared routine-engine slot used for internal event matching and for exposing
|
/// Optional slot to expose the routine engine to the gateway for manual triggering.
|
||||||
/// the engine to gateway/manual trigger entry points.
|
|
||||||
pub(super) routine_engine_slot:
|
pub(super) routine_engine_slot:
|
||||||
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
|
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
@@ -231,21 +228,16 @@ impl Agent {
|
|||||||
heartbeat_config,
|
heartbeat_config,
|
||||||
hygiene_config,
|
hygiene_config,
|
||||||
routine_config,
|
routine_config,
|
||||||
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine_slot: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the routine-engine slot with a shared one so the gateway and
|
/// Set the routine engine slot for exposing the engine to the gateway.
|
||||||
/// agent reference the same engine.
|
|
||||||
pub fn set_routine_engine_slot(
|
pub fn set_routine_engine_slot(
|
||||||
&mut self,
|
&mut self,
|
||||||
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
|
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
|
||||||
) {
|
) {
|
||||||
self.routine_engine_slot = slot;
|
self.routine_engine_slot = Some(slot);
|
||||||
}
|
|
||||||
|
|
||||||
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
|
|
||||||
self.routine_engine_slot.read().await.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience accessors
|
// Convenience accessors
|
||||||
@@ -342,18 +334,11 @@ impl Agent {
|
|||||||
let mut message_stream = self.channels.start_all().await?;
|
let mut message_stream = self.channels.start_all().await?;
|
||||||
|
|
||||||
// Start self-repair task with notification forwarding
|
// Start self-repair task with notification forwarding
|
||||||
let mut self_repair = DefaultSelfRepair::new(
|
let repair = Arc::new(DefaultSelfRepair::new(
|
||||||
self.context_manager.clone(),
|
self.context_manager.clone(),
|
||||||
self.config.stuck_threshold,
|
self.config.stuck_threshold,
|
||||||
self.config.max_repair_attempts,
|
self.config.max_repair_attempts,
|
||||||
);
|
));
|
||||||
if let Some(ref store) = self.deps.store {
|
|
||||||
self_repair = self_repair.with_store(Arc::clone(store));
|
|
||||||
}
|
|
||||||
if let Some(ref builder) = self.deps.builder {
|
|
||||||
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
|
|
||||||
}
|
|
||||||
let repair = Arc::new(self_repair);
|
|
||||||
let repair_interval = self.config.repair_check_interval;
|
let repair_interval = self.config.repair_check_interval;
|
||||||
let repair_channels = self.channels.clone();
|
let repair_channels = self.channels.clone();
|
||||||
let repair_owner_id = self.owner_id().to_string();
|
let repair_owner_id = self.owner_id().to_string();
|
||||||
@@ -648,7 +633,9 @@ impl Agent {
|
|||||||
// via a local to use in the message loop below.
|
// via a local to use in the message loop below.
|
||||||
|
|
||||||
// Expose engine to gateway for manual triggering
|
// Expose engine to gateway for manual triggering
|
||||||
*self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
|
if let Some(ref slot) = self.routine_engine_slot {
|
||||||
|
*slot.write().await = Some(Arc::clone(&engine));
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Routines enabled: cron ticker every {}s, max {} concurrent",
|
"Routines enabled: cron ticker every {}s, max {} concurrent",
|
||||||
@@ -668,6 +655,9 @@ impl Agent {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract engine ref for use in message loop
|
||||||
|
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
|
||||||
|
|
||||||
// Main message loop
|
// Main message loop
|
||||||
tracing::debug!("Agent {} ready and listening", self.config.name);
|
tracing::debug!("Agent {} ready and listening", self.config.name);
|
||||||
|
|
||||||
@@ -703,6 +693,29 @@ impl Agent {
|
|||||||
// Store successfully extracted document text in workspace for indexing
|
// Store successfully extracted document text in workspace for indexing
|
||||||
self.store_extracted_documents(&message).await;
|
self.store_extracted_documents(&message).await;
|
||||||
|
|
||||||
|
// Event-triggered routines consume plain user input before it enters
|
||||||
|
// the normal chat/tool pipeline. This avoids a duplicate turn where
|
||||||
|
// the main agent responds and the routine also fires on the same
|
||||||
|
// inbound message.
|
||||||
|
if !message.is_internal
|
||||||
|
&& matches!(
|
||||||
|
SubmissionParser::parse(&message.content),
|
||||||
|
Submission::UserInput { .. }
|
||||||
|
)
|
||||||
|
&& let Some(ref engine) = routine_engine_for_loop
|
||||||
|
{
|
||||||
|
let fired = engine.check_event_triggers(&message).await;
|
||||||
|
if fired > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
channel = %message.channel,
|
||||||
|
user = %message.user_id,
|
||||||
|
fired,
|
||||||
|
"Consumed inbound user message with matching event-triggered routine(s)"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match self.handle_message(&message).await {
|
match self.handle_message(&message).await {
|
||||||
Ok(Some(response)) if !response.is_empty() => {
|
Ok(Some(response)) if !response.is_empty() => {
|
||||||
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
|
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
|
||||||
@@ -1019,24 +1032,6 @@ impl Agent {
|
|||||||
message.content.len()
|
message.content.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
if !message.is_internal
|
|
||||||
&& let Submission::UserInput { ref content } = submission
|
|
||||||
&& let Some(engine) = self.routine_engine().await
|
|
||||||
{
|
|
||||||
let fired = engine
|
|
||||||
.check_event_triggers(&message.user_id, &message.channel, content)
|
|
||||||
.await;
|
|
||||||
if fired > 0 {
|
|
||||||
tracing::debug!(
|
|
||||||
channel = %message.channel,
|
|
||||||
user = %message.user_id,
|
|
||||||
fired,
|
|
||||||
"Consumed inbound user message with matching event-triggered routine(s)"
|
|
||||||
);
|
|
||||||
return Ok(Some(String::new()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process based on submission type
|
// Process based on submission type
|
||||||
let result = match submission {
|
let result = match submission {
|
||||||
Submission::UserInput { content } => {
|
Submission::UserInput { content } => {
|
||||||
|
|||||||
+7
-42
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
|
|||||||
/// A tool requires approval before continuing.
|
/// A tool requires approval before continuing.
|
||||||
NeedApproval {
|
NeedApproval {
|
||||||
/// The pending approval request to store.
|
/// The pending approval request to store.
|
||||||
pending: Box<PendingApproval>,
|
pending: PendingApproval,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +217,9 @@ impl Agent {
|
|||||||
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
|
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
|
||||||
}
|
}
|
||||||
.into()),
|
.into()),
|
||||||
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
|
LoopOutcome::NeedApproval(pending) => {
|
||||||
|
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +482,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
usize,
|
usize,
|
||||||
crate::llm::ToolCall,
|
crate::llm::ToolCall,
|
||||||
Arc<dyn crate::tools::Tool>,
|
Arc<dyn crate::tools::Tool>,
|
||||||
bool, // allow_always
|
|
||||||
)> = None;
|
)> = None;
|
||||||
|
|
||||||
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
||||||
@@ -550,8 +551,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
&& let Some(tool) = tool_opt
|
&& let Some(tool) = tool_opt
|
||||||
{
|
{
|
||||||
use crate::tools::ApprovalRequirement;
|
use crate::tools::ApprovalRequirement;
|
||||||
let requirement = tool.requires_approval(&tc.arguments);
|
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||||
let needs_approval = match requirement {
|
|
||||||
ApprovalRequirement::Never => false,
|
ApprovalRequirement::Never => false,
|
||||||
ApprovalRequirement::UnlessAutoApproved => {
|
ApprovalRequirement::UnlessAutoApproved => {
|
||||||
let sess = self.session.lock().await;
|
let sess = self.session.lock().await;
|
||||||
@@ -586,8 +586,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let allow_always = !matches!(requirement, ApprovalRequirement::Always);
|
approval_needed = Some((idx, tc, tool));
|
||||||
approval_needed = Some((idx, tc, tool, allow_always));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,7 +887,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle approval if a tool needed it
|
// Handle approval if a tool needed it
|
||||||
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
|
if let Some((approval_idx, tc, tool)) = approval_needed {
|
||||||
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
|
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
|
||||||
let pending = PendingApproval {
|
let pending = PendingApproval {
|
||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
@@ -900,7 +899,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
context_messages: reason_ctx.messages.clone(),
|
context_messages: reason_ctx.messages.clone(),
|
||||||
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
|
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
|
||||||
user_timezone: Some(self.user_tz.name().to_string()),
|
user_timezone: Some(self.user_tz.name().to_string()),
|
||||||
allow_always,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
|
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
|
||||||
@@ -1199,7 +1197,6 @@ mod tests {
|
|||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
transcription: None,
|
transcription: None,
|
||||||
document_extraction: None,
|
document_extraction: None,
|
||||||
builder: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -1367,35 +1364,6 @@ mod tests {
|
|||||||
assert!(always_needs, "Always must always require approval");
|
assert!(always_needs, "Always must always require approval");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test: `allow_always` must be `false` for `Always` and
|
|
||||||
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
|
|
||||||
/// for tools that truly cannot be auto-approved.
|
|
||||||
#[test]
|
|
||||||
fn test_allow_always_matches_approval_requirement() {
|
|
||||||
use crate::tools::ApprovalRequirement;
|
|
||||||
|
|
||||||
// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
|
|
||||||
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);
|
|
||||||
|
|
||||||
// UnlessAutoApproved → allow_always = true
|
|
||||||
let req = ApprovalRequirement::UnlessAutoApproved;
|
|
||||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
|
||||||
assert!(
|
|
||||||
allow_always,
|
|
||||||
"UnlessAutoApproved should set allow_always = true"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always → allow_always = false
|
|
||||||
let req = ApprovalRequirement::Always;
|
|
||||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
|
||||||
assert!(!allow_always, "Always should set allow_always = false");
|
|
||||||
|
|
||||||
// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
|
|
||||||
let req = ApprovalRequirement::Never;
|
|
||||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
|
||||||
assert!(allow_always, "Never should set allow_always = true");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
||||||
// PendingApproval from before the deferred_tool_calls field was added
|
// PendingApproval from before the deferred_tool_calls field was added
|
||||||
@@ -1441,7 +1409,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
user_timezone: None,
|
user_timezone: None,
|
||||||
allow_always: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&pending).expect("serialize");
|
let json = serde_json::to_string(&pending).expect("serialize");
|
||||||
@@ -2070,7 +2037,6 @@ mod tests {
|
|||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
transcription: None,
|
transcription: None,
|
||||||
document_extraction: None,
|
document_extraction: None,
|
||||||
builder: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -2189,7 +2155,6 @@ mod tests {
|
|||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
transcription: None,
|
transcription: None,
|
||||||
document_extraction: None,
|
document_extraction: None,
|
||||||
builder: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ mod tests {
|
|||||||
job_id: job_id.to_string(),
|
job_id: job_id.to_string(),
|
||||||
status: "completed".to_string(),
|
status: "completed".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
fallback_deliverable: None,
|
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+74
-615
@@ -10,7 +10,6 @@
|
|||||||
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
|
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
|
||||||
//! Full-job routines are delegated to the existing `Scheduler`.
|
//! Full-job routines are delegated to the existing `Scheduler`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -24,19 +23,19 @@ use crate::agent::Scheduler;
|
|||||||
use crate::agent::routine::{
|
use crate::agent::routine::{
|
||||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||||
};
|
};
|
||||||
use crate::channels::OutgoingResponse;
|
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||||
use crate::config::RoutineConfig;
|
use crate::config::RoutineConfig;
|
||||||
use crate::context::{JobContext, JobState};
|
use crate::context::JobContext;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||||
};
|
};
|
||||||
|
use crate::safety::SafetyLayer;
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
|
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
|
||||||
};
|
};
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
use ironclaw_safety::SafetyLayer;
|
|
||||||
|
|
||||||
enum EventMatcher {
|
enum EventMatcher {
|
||||||
Message { routine: Routine, regex: Regex },
|
Message { routine: Routine, regex: Regex },
|
||||||
@@ -61,10 +60,6 @@ pub struct RoutineEngine {
|
|||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
/// Safety layer for tool output sanitization.
|
/// Safety layer for tool output sanitization.
|
||||||
safety: Arc<SafetyLayer>,
|
safety: Arc<SafetyLayer>,
|
||||||
/// Timestamp when this engine instance was created. Used by
|
|
||||||
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
|
|
||||||
/// process) from actively-watched runs (from this process).
|
|
||||||
boot_time: chrono::DateTime<Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoutineEngine {
|
impl RoutineEngine {
|
||||||
@@ -90,16 +85,9 @@ impl RoutineEngine {
|
|||||||
scheduler,
|
scheduler,
|
||||||
tools,
|
tools,
|
||||||
safety,
|
safety,
|
||||||
boot_time: Utc::now(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expose the running count for integration tests.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn running_count_for_test(&self) -> &Arc<AtomicUsize> {
|
|
||||||
&self.running_count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the in-memory event trigger cache from DB.
|
/// Refresh the in-memory event trigger cache from DB.
|
||||||
pub async fn refresh_event_cache(&self) {
|
pub async fn refresh_event_cache(&self) {
|
||||||
match self.store.list_event_routines().await {
|
match self.store.list_event_routines().await {
|
||||||
@@ -147,19 +135,10 @@ impl RoutineEngine {
|
|||||||
|
|
||||||
/// Check incoming message against event triggers. Returns number of routines fired.
|
/// Check incoming message against event triggers. Returns number of routines fired.
|
||||||
///
|
///
|
||||||
/// Accepts only the three fields needed for matching (user scope, channel,
|
/// Called synchronously from the main loop after handle_message(). The actual
|
||||||
/// message content) so callers never need to clone a full `IncomingMessage`.
|
/// execution is spawned async so this returns quickly.
|
||||||
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
|
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
|
||||||
let cache = self.event_cache.read().await;
|
let cache = self.event_cache.read().await;
|
||||||
|
|
||||||
// Early return if there are no message matchers at all.
|
|
||||||
if !cache
|
|
||||||
.iter()
|
|
||||||
.any(|m| matches!(m, EventMatcher::Message { .. }))
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut fired = 0;
|
let mut fired = 0;
|
||||||
|
|
||||||
// Collect routine IDs for batch query
|
// Collect routine IDs for batch query
|
||||||
@@ -176,9 +155,16 @@ impl RoutineEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single batch query instead of N queries
|
// Single batch query instead of N queries
|
||||||
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
|
let concurrent_counts = match self
|
||||||
Some(counts) => counts,
|
.store
|
||||||
None => return 0,
|
.count_running_routine_runs_batch(&routine_ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(counts) => counts,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to batch-load concurrent counts: {}", e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for matcher in cache.iter() {
|
for matcher in cache.iter() {
|
||||||
@@ -187,7 +173,7 @@ impl RoutineEngine {
|
|||||||
EventMatcher::System { .. } => continue,
|
EventMatcher::System { .. } => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if routine.user_id != user_id {
|
if routine.user_id != message.user_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +181,13 @@ impl RoutineEngine {
|
|||||||
if let Trigger::Event {
|
if let Trigger::Event {
|
||||||
channel: Some(ch), ..
|
channel: Some(ch), ..
|
||||||
} = &routine.trigger
|
} = &routine.trigger
|
||||||
&& ch != channel
|
&& ch != &message.channel
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regex match
|
// Regex match
|
||||||
if !re.is_match(content) {
|
if !re.is_match(&message.content) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +210,7 @@ impl RoutineEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = truncate(content, 200);
|
let detail = truncate(&message.content, 200);
|
||||||
self.spawn_fire(routine.clone(), "event", Some(detail));
|
self.spawn_fire(routine.clone(), "event", Some(detail));
|
||||||
fired += 1;
|
fired += 1;
|
||||||
}
|
}
|
||||||
@@ -243,15 +229,6 @@ impl RoutineEngine {
|
|||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let cache = self.event_cache.read().await;
|
let cache = self.event_cache.read().await;
|
||||||
|
|
||||||
// Early return if there are no system-event matchers at all.
|
|
||||||
if !cache
|
|
||||||
.iter()
|
|
||||||
.any(|m| matches!(m, EventMatcher::System { .. }))
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut fired = 0;
|
let mut fired = 0;
|
||||||
|
|
||||||
// Collect routine IDs for batch query
|
// Collect routine IDs for batch query
|
||||||
@@ -268,9 +245,19 @@ impl RoutineEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single batch query instead of N queries
|
// Single batch query instead of N queries
|
||||||
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
|
let concurrent_counts = match self
|
||||||
Some(counts) => counts,
|
.store
|
||||||
None => return 0,
|
.count_running_routine_runs_batch(&routine_ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(counts) => counts,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to batch-load concurrent counts for system events: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for matcher in cache.iter() {
|
for matcher in cache.iter() {
|
||||||
@@ -344,23 +331,6 @@ impl RoutineEngine {
|
|||||||
fired
|
fired
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch-load concurrent run counts for a set of routine IDs.
|
|
||||||
///
|
|
||||||
/// Returns `None` on database error (already logged).
|
|
||||||
async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option<HashMap<Uuid, i64>> {
|
|
||||||
match self
|
|
||||||
.store
|
|
||||||
.count_running_routine_runs_batch(routine_ids)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(counts) => Some(counts),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to batch-load concurrent counts: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check all due cron routines and fire them. Called by the cron ticker.
|
/// Check all due cron routines and fire them. Called by the cron ticker.
|
||||||
pub async fn check_cron_triggers(&self) {
|
pub async fn check_cron_triggers(&self) {
|
||||||
let routines = match self.store.list_due_cron_routines().await {
|
let routines = match self.store.list_due_cron_routines().await {
|
||||||
@@ -395,230 +365,6 @@ impl RoutineEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile orphaned full_job routine runs with their linked job outcomes.
|
|
||||||
///
|
|
||||||
/// Called on each cron tick. Finds routine runs that are still `running`
|
|
||||||
/// with a linked `job_id`, checks the job state, and finalizes the run
|
|
||||||
/// when the job reaches a completed or terminal state.
|
|
||||||
///
|
|
||||||
/// Only processes runs started **before** this engine's boot time, so it
|
|
||||||
/// never races with `FullJobWatcher` instances from the current process.
|
|
||||||
/// This makes it safe to call on every tick as a crash-recovery mechanism.
|
|
||||||
pub async fn sync_dispatched_runs(&self) {
|
|
||||||
let runs = match self.store.list_dispatched_routine_runs().await {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to list dispatched routine runs: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only process runs from a previous process instance. Runs started
|
|
||||||
// after boot_time are actively watched by a FullJobWatcher in this
|
|
||||||
// process and should not be finalized here.
|
|
||||||
let orphaned: Vec<_> = runs
|
|
||||||
.into_iter()
|
|
||||||
.filter(|r| r.started_at < self.boot_time)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if orphaned.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"Recovering {} orphaned dispatched routine runs",
|
|
||||||
orphaned.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
for run in orphaned {
|
|
||||||
let job_id = match run.job_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => continue, // Should not happen (query filters), but guard anyway
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the linked job
|
|
||||||
let job = match self.store.get_job(job_id).await {
|
|
||||||
Ok(Some(j)) => j,
|
|
||||||
Ok(None) => {
|
|
||||||
// Orphaned: job record was deleted or never persisted
|
|
||||||
tracing::warn!(
|
|
||||||
run_id = %run.id,
|
|
||||||
job_id = %job_id,
|
|
||||||
"Linked job not found, marking routine run as failed"
|
|
||||||
);
|
|
||||||
self.complete_dispatched_run(
|
|
||||||
&run,
|
|
||||||
RunStatus::Failed,
|
|
||||||
&format!("Linked job {job_id} not found (orphaned)"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
run_id = %run.id,
|
|
||||||
job_id = %job_id,
|
|
||||||
"Failed to fetch linked job: {}", e
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map job state to final run status
|
|
||||||
let final_status = match job.state {
|
|
||||||
JobState::Completed | JobState::Submitted | JobState::Accepted => {
|
|
||||||
Some(RunStatus::Ok)
|
|
||||||
}
|
|
||||||
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
|
|
||||||
// Pending, InProgress, Stuck — still running
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = match final_status {
|
|
||||||
Some(s) => s,
|
|
||||||
None => continue, // Job still active, check again next tick
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build summary
|
|
||||||
let summary = if status == RunStatus::Failed {
|
|
||||||
match self.store.get_agent_job_failure_reason(job_id).await {
|
|
||||||
Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
|
|
||||||
_ => format!("Job {job_id} {}", job.state),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
format!("Job {job_id} completed successfully")
|
|
||||||
};
|
|
||||||
|
|
||||||
self.complete_dispatched_run(&run, status, &summary).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finalize a dispatched routine run: update DB, update routine runtime,
|
|
||||||
/// persist to conversation thread, and send notification.
|
|
||||||
async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
|
|
||||||
// Complete the run record in DB
|
|
||||||
if let Err(e) = self
|
|
||||||
.store
|
|
||||||
.complete_routine_run(run.id, status, Some(summary), None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
run_id = %run.id,
|
|
||||||
"Failed to complete dispatched routine run: {}", e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
run_id = %run.id,
|
|
||||||
status = %status,
|
|
||||||
"Finalized dispatched routine run"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load the routine to update consecutive_failures and send notification
|
|
||||||
let routine = match self.store.get_routine(run.routine_id).await {
|
|
||||||
Ok(Some(r)) => r,
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::warn!(
|
|
||||||
run_id = %run.id,
|
|
||||||
routine_id = %run.routine_id,
|
|
||||||
"Routine not found for dispatched run finalization"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
run_id = %run.id,
|
|
||||||
"Failed to load routine for dispatched run: {}", e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update runtime fields. In crash recovery, execute_routine() never
|
|
||||||
// reached its normal runtime update, so we must advance all fields here.
|
|
||||||
let new_failures = if status == RunStatus::Failed {
|
|
||||||
routine.consecutive_failures + 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = Utc::now();
|
|
||||||
let next_fire = if let Trigger::Cron {
|
|
||||||
ref schedule,
|
|
||||||
ref timezone,
|
|
||||||
} = routine.trigger
|
|
||||||
{
|
|
||||||
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = self
|
|
||||||
.store
|
|
||||||
.update_routine_runtime(
|
|
||||||
routine.id,
|
|
||||||
now,
|
|
||||||
next_fire,
|
|
||||||
routine.run_count + 1,
|
|
||||||
new_failures,
|
|
||||||
&routine.state,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
routine = %routine.name,
|
|
||||||
"Failed to update routine runtime after dispatched run: {}", e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist result to the routine's conversation thread
|
|
||||||
let thread_id = match self
|
|
||||||
.store
|
|
||||||
.get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(conv_id) => {
|
|
||||||
let msg = format!("[dispatched] {}: {}", status, summary);
|
|
||||||
if let Err(e) = self
|
|
||||||
.store
|
|
||||||
.add_conversation_message(conv_id, "assistant", &msg)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
routine = %routine.name,
|
|
||||||
"Failed to persist dispatched run message: {}", e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(conv_id.to_string())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
routine = %routine.name,
|
|
||||||
"Failed to get routine conversation: {}", e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send notification
|
|
||||||
send_notification(
|
|
||||||
&self.notify_tx,
|
|
||||||
&routine.notify,
|
|
||||||
&routine.user_id,
|
|
||||||
&routine.name,
|
|
||||||
status,
|
|
||||||
Some(summary),
|
|
||||||
thread_id.as_deref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Note: we do NOT decrement running_count here. In normal flow,
|
|
||||||
// execute_routine() handles that after FullJobWatcher returns.
|
|
||||||
// This sync path only runs for crash recovery (process restarted),
|
|
||||||
// where running_count was already reset to 0.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire a routine manually (from tool call or CLI).
|
/// Fire a routine manually (from tool call or CLI).
|
||||||
///
|
///
|
||||||
/// Bypasses cooldown checks (those only apply to cron/event triggers).
|
/// Bypasses cooldown checks (those only apply to cron/event triggers).
|
||||||
@@ -762,92 +508,6 @@ impl RoutineEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watches a dispatched full_job until the linked scheduler job completes.
|
|
||||||
///
|
|
||||||
/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
|
|
||||||
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
|
|
||||||
/// a `RunStatus` for the routine run.
|
|
||||||
struct FullJobWatcher {
|
|
||||||
store: Arc<dyn Database>,
|
|
||||||
job_id: Uuid,
|
|
||||||
routine_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FullJobWatcher {
|
|
||||||
/// Poll interval between DB checks.
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
|
||||||
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
|
|
||||||
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
|
|
||||||
|
|
||||||
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
|
|
||||||
Self {
|
|
||||||
store,
|
|
||||||
job_id,
|
|
||||||
routine_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Block until the linked job finishes and return the mapped status + summary.
|
|
||||||
async fn wait_for_completion(&self) -> (RunStatus, Option<String>) {
|
|
||||||
let mut polls = 0u32;
|
|
||||||
|
|
||||||
let final_status = loop {
|
|
||||||
// Check job state before sleeping so we finalize promptly
|
|
||||||
// if the job is already done (e.g. fast-failing jobs).
|
|
||||||
match self.store.get_job(self.job_id).await {
|
|
||||||
Ok(Some(job_ctx)) => {
|
|
||||||
// Use is_parallel_blocking (Pending/InProgress/Stuck) instead
|
|
||||||
// of is_active (!is_terminal) because routine jobs typically
|
|
||||||
// stop at Completed — which is NOT terminal but IS finished
|
|
||||||
// from an execution standpoint.
|
|
||||||
if !job_ctx.state.is_parallel_blocking() {
|
|
||||||
break Self::map_job_state(&job_ctx.state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::warn!(
|
|
||||||
routine = %self.routine_name,
|
|
||||||
job_id = %self.job_id,
|
|
||||||
"full_job disappeared from DB while polling"
|
|
||||||
);
|
|
||||||
break RunStatus::Failed;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
routine = %self.routine_name,
|
|
||||||
job_id = %self.job_id,
|
|
||||||
"Error polling full_job state: {}", e
|
|
||||||
);
|
|
||||||
break RunStatus::Failed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
polls += 1;
|
|
||||||
if polls >= Self::MAX_POLLS {
|
|
||||||
tracing::error!(
|
|
||||||
routine = %self.routine_name,
|
|
||||||
job_id = %self.job_id,
|
|
||||||
"full_job timed out after 24 hours, treating as failed"
|
|
||||||
);
|
|
||||||
break RunStatus::Failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(Self::POLL_INTERVAL).await;
|
|
||||||
};
|
|
||||||
|
|
||||||
let summary = format!("Job {} finished ({})", self.job_id, final_status);
|
|
||||||
(final_status, Some(summary))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_job_state(state: &crate::context::JobState) -> RunStatus {
|
|
||||||
use crate::context::JobState;
|
|
||||||
match state {
|
|
||||||
JobState::Failed | JobState::Cancelled => RunStatus::Failed,
|
|
||||||
_ => RunStatus::Ok, // Completed / Submitted / Accepted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared context passed to the execution function.
|
/// Shared context passed to the execution function.
|
||||||
struct EngineContext {
|
struct EngineContext {
|
||||||
config: RoutineConfig,
|
config: RoutineConfig,
|
||||||
@@ -1022,10 +682,8 @@ fn sanitize_routine_name(name: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
|
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
|
||||||
/// creation, metadata, persistence, and scheduling), links the routine run to
|
/// creation, metadata, persistence, and scheduling), links the routine run to
|
||||||
/// the job, then watches it via `FullJobWatcher` until it reaches a
|
/// the job, and returns immediately. The job runs independently via the
|
||||||
/// non-active state (not Pending/InProgress/Stuck). Returns the final
|
/// existing Worker/Scheduler with full tool access.
|
||||||
/// `RunStatus` mapped from the job outcome. This keeps the routine run
|
|
||||||
/// active for the full job lifetime so concurrency guardrails apply.
|
|
||||||
async fn execute_full_job(
|
async fn execute_full_job(
|
||||||
ctx: &EngineContext,
|
ctx: &EngineContext,
|
||||||
routine: &Routine,
|
routine: &Routine,
|
||||||
@@ -1068,30 +726,25 @@ async fn execute_full_job(
|
|||||||
reason: format!("failed to dispatch job: {e}"),
|
reason: format!("failed to dispatch job: {e}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Link the routine run to the dispatched job.
|
// Link the routine run to the dispatched job
|
||||||
// This MUST succeed — if it fails, sync_dispatched_runs() will never find
|
if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
|
||||||
// this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
|
tracing::error!(
|
||||||
// with running_count permanently elevated.
|
routine = %routine.name,
|
||||||
ctx.store
|
"Failed to link run to job: {}", e
|
||||||
.link_routine_run_to_job(run.id, job_id)
|
);
|
||||||
.await
|
}
|
||||||
.map_err(|e| RoutineError::Database {
|
|
||||||
reason: format!("failed to link run to job: {e}"),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
routine = %routine.name,
|
routine = %routine.name,
|
||||||
job_id = %job_id,
|
job_id = %job_id,
|
||||||
max_iterations = max_iterations,
|
max_iterations = max_iterations,
|
||||||
"Dispatched full job for routine, watching for completion"
|
"Dispatched full job for routine"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch the job until it finishes — keeps the routine run active
|
let summary = format!(
|
||||||
// so concurrency guardrails (running_count, routine_runs status)
|
"Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
|
||||||
// remain enforced for the full job lifetime.
|
);
|
||||||
let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
|
Ok((RunStatus::Ok, Some(summary), None))
|
||||||
let (status, summary) = watcher.wait_for_completion().await;
|
|
||||||
Ok((status, summary, None))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a lightweight routine with optional tool support.
|
/// Execute a lightweight routine with optional tool support.
|
||||||
@@ -1131,12 +784,23 @@ async fn execute_lightweight(
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let full_prompt = build_lightweight_prompt(
|
// Build the user-facing prompt
|
||||||
prompt,
|
let mut full_prompt = String::new();
|
||||||
&context_parts,
|
full_prompt.push_str(prompt);
|
||||||
state_content.as_deref(),
|
|
||||||
&routine.notify,
|
if !context_parts.is_empty() {
|
||||||
use_tools,
|
full_prompt.push_str("\n\n---\n\n# Context\n\n");
|
||||||
|
full_prompt.push_str(&context_parts.join("\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(state) = &state_content {
|
||||||
|
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
|
||||||
|
full_prompt.push_str(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
full_prompt.push_str(
|
||||||
|
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
|
||||||
|
If something needs attention, provide a concise summary.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get system prompt
|
// Get system prompt
|
||||||
@@ -1180,65 +844,6 @@ async fn execute_lightweight(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_lightweight_prompt(
|
|
||||||
prompt: &str,
|
|
||||||
context_parts: &[String],
|
|
||||||
state_content: Option<&str>,
|
|
||||||
notify: &NotifyConfig,
|
|
||||||
use_tools: bool,
|
|
||||||
) -> String {
|
|
||||||
let mut full_prompt = String::new();
|
|
||||||
full_prompt.push_str(prompt);
|
|
||||||
|
|
||||||
if notify.on_attention {
|
|
||||||
full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
|
|
||||||
full_prompt.push_str(
|
|
||||||
"If you reply with anything other than ROUTINE_OK, the host will deliver your \
|
|
||||||
reply as the routine notification. Return the message exactly as it should be sent.\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(channel) = notify.channel.as_deref() {
|
|
||||||
full_prompt.push_str(&format!(
|
|
||||||
"The configured delivery channel for this routine is `{channel}`.\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(user) = notify.user.as_deref() {
|
|
||||||
full_prompt.push_str(&format!(
|
|
||||||
"The configured delivery target for this routine is `{user}`.\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
full_prompt.push_str(
|
|
||||||
"Do not claim you lack messaging integrations or ask the user to set one up when \
|
|
||||||
a plain reply is sufficient.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !use_tools {
|
|
||||||
full_prompt.push_str(
|
|
||||||
"\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !context_parts.is_empty() {
|
|
||||||
full_prompt.push_str("\n\n---\n\n# Context\n\n");
|
|
||||||
full_prompt.push_str(&context_parts.join("\n\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(state) = state_content {
|
|
||||||
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
|
|
||||||
full_prompt.push_str(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
full_prompt.push_str(
|
|
||||||
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
|
|
||||||
If something needs attention, provide a concise summary.",
|
|
||||||
);
|
|
||||||
|
|
||||||
full_prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a lightweight routine without tool support (original single-call behavior).
|
/// Execute a lightweight routine without tool support (original single-call behavior).
|
||||||
async fn execute_lightweight_no_tools(
|
async fn execute_lightweight_no_tools(
|
||||||
ctx: &EngineContext,
|
ctx: &EngineContext,
|
||||||
@@ -1296,8 +901,8 @@ fn handle_text_response(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for the "nothing to do" sentinel (exact match on trimmed content).
|
// Check for the "nothing to do" sentinel
|
||||||
if content == "ROUTINE_OK" {
|
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
||||||
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
||||||
return Ok((RunStatus::Ok, None, total_tokens));
|
return Ok((RunStatus::Ok, None, total_tokens));
|
||||||
}
|
}
|
||||||
@@ -1663,22 +1268,14 @@ pub fn spawn_cron_ticker(
|
|||||||
interval: Duration,
|
interval: Duration,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Recover orphaned runs from a previous process crash before
|
// Run one check immediately so routines due at startup don't wait
|
||||||
// dispatching any new work, so we don't confuse fresh dispatches
|
// an extra full polling interval.
|
||||||
// with crash orphans.
|
|
||||||
engine.sync_dispatched_runs().await;
|
|
||||||
|
|
||||||
// Run one cron check immediately so routines due at startup don't
|
|
||||||
// wait an extra full polling interval.
|
|
||||||
engine.check_cron_triggers().await;
|
engine.check_cron_triggers().await;
|
||||||
|
|
||||||
let mut ticker = tokio::time::interval(interval);
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
// Sync first: only processes runs from before boot_time, so it
|
|
||||||
// never races with FullJobWatcher instances from this process.
|
|
||||||
engine.sync_dispatched_runs().await;
|
|
||||||
engine.check_cron_triggers().await;
|
engine.check_cron_triggers().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1788,78 +1385,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_lightweight_prompt_explains_delivery_and_disabled_tools() {
|
|
||||||
let notify = NotifyConfig {
|
|
||||||
channel: Some("telegram".to_string()),
|
|
||||||
user: Some("default".to_string()),
|
|
||||||
on_attention: true,
|
|
||||||
on_failure: true,
|
|
||||||
on_success: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let prompt = super::build_lightweight_prompt(
|
|
||||||
"Send a Telegram reminder message to the user.",
|
|
||||||
&[],
|
|
||||||
None,
|
|
||||||
¬ify,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
prompt.contains("the host will deliver your reply as the routine notification"),
|
|
||||||
"delivery guidance should explain host delivery: {prompt}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prompt.contains("configured delivery channel for this routine is `telegram`"),
|
|
||||||
"delivery guidance should mention telegram channel: {prompt}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prompt.contains("Do not claim you lack messaging integrations"),
|
|
||||||
"delivery guidance should suppress fake setup chatter: {prompt}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
prompt.contains("Tools are disabled for this routine run"),
|
|
||||||
"prompt should explain that tools are disabled: {prompt}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_lightweight_prompt_skips_delivery_block_when_attention_notifications_disabled() {
|
|
||||||
let notify = NotifyConfig {
|
|
||||||
on_attention: false,
|
|
||||||
..NotifyConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let prompt = super::build_lightweight_prompt("Check inbox.", &[], None, ¬ify, true);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!prompt.contains("# Delivery"),
|
|
||||||
"prompt should not include delivery guidance when attention notifications are off: {prompt}",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!prompt.contains("Tools are disabled for this routine run"),
|
|
||||||
"prompt should not claim tools are disabled when they are enabled: {prompt}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_routine_sentinel_detection_exact_match() {
|
fn test_routine_sentinel_detection_exact_match() {
|
||||||
// Sentinel detection uses exact match on trimmed content to avoid
|
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
|
||||||
// false positives from substrings like "NOT_ROUTINE_OK".
|
// After trim(), whitespace is removed
|
||||||
let test_cases = vec![
|
let test_cases = vec![
|
||||||
("ROUTINE_OK", true),
|
("ROUTINE_OK", true),
|
||||||
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
|
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
|
||||||
("something ROUTINE_OK something", false), // substring no longer matches
|
("something ROUTINE_OK something", true),
|
||||||
("ROUTINE_OK is done", false), // substring no longer matches
|
("ROUTINE_OK is done", true),
|
||||||
("done ROUTINE_OK", false), // substring no longer matches
|
("done ROUTINE_OK", true),
|
||||||
("NOT_ROUTINE_OK", false), // exact match prevents this
|
|
||||||
("no sentinel here", false),
|
("no sentinel here", false),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (content, should_match) in test_cases {
|
for (content, should_match) in test_cases {
|
||||||
let trimmed = content.trim();
|
let trimmed = content.trim();
|
||||||
let matches = trimmed == "ROUTINE_OK";
|
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
matches, should_match,
|
matches, should_match,
|
||||||
"Content '{}' sentinel detection should be {}, got {}",
|
"Content '{}' sentinel detection should be {}, got {}",
|
||||||
@@ -1973,86 +1514,4 @@ mod tests {
|
|||||||
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
|
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
|
||||||
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
|
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
|
|
||||||
#[test]
|
|
||||||
fn test_full_job_watcher_state_mapping() {
|
|
||||||
use crate::context::JobState;
|
|
||||||
|
|
||||||
// Failed/Cancelled → RunStatus::Failed
|
|
||||||
assert_eq!(
|
|
||||||
super::FullJobWatcher::map_job_state(&JobState::Failed),
|
|
||||||
RunStatus::Failed
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::FullJobWatcher::map_job_state(&JobState::Cancelled),
|
|
||||||
RunStatus::Failed
|
|
||||||
);
|
|
||||||
|
|
||||||
// All other non-active states → RunStatus::Ok
|
|
||||||
assert_eq!(
|
|
||||||
super::FullJobWatcher::map_job_state(&JobState::Completed),
|
|
||||||
RunStatus::Ok
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::FullJobWatcher::map_job_state(&JobState::Accepted),
|
|
||||||
RunStatus::Ok
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that job state to run status mapping covers all expected cases.
|
|
||||||
#[test]
|
|
||||||
fn test_job_state_to_run_status_mapping() {
|
|
||||||
use crate::context::JobState;
|
|
||||||
|
|
||||||
// Success states
|
|
||||||
for state in [JobState::Completed, JobState::Submitted, JobState::Accepted] {
|
|
||||||
let status = match state {
|
|
||||||
JobState::Completed | JobState::Submitted | JobState::Accepted => {
|
|
||||||
Some(RunStatus::Ok)
|
|
||||||
}
|
|
||||||
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
Some(RunStatus::Ok),
|
|
||||||
"{:?} should map to RunStatus::Ok",
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failure states
|
|
||||||
for state in [JobState::Failed, JobState::Cancelled] {
|
|
||||||
let status = match state {
|
|
||||||
JobState::Completed | JobState::Submitted | JobState::Accepted => {
|
|
||||||
Some(RunStatus::Ok)
|
|
||||||
}
|
|
||||||
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
Some(RunStatus::Failed),
|
|
||||||
"{:?} should map to RunStatus::Failed",
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active states (should not finalize)
|
|
||||||
for state in [JobState::Pending, JobState::InProgress, JobState::Stuck] {
|
|
||||||
let status = match state {
|
|
||||||
JobState::Completed | JobState::Submitted | JobState::Accepted => {
|
|
||||||
Some(RunStatus::Ok)
|
|
||||||
}
|
|
||||||
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
status, None,
|
|
||||||
"{:?} should not finalize the routine run",
|
|
||||||
state
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-259
@@ -66,10 +66,14 @@ pub trait SelfRepair: Send + Sync {
|
|||||||
/// Default self-repair implementation.
|
/// Default self-repair implementation.
|
||||||
pub struct DefaultSelfRepair {
|
pub struct DefaultSelfRepair {
|
||||||
context_manager: Arc<ContextManager>,
|
context_manager: Arc<ContextManager>,
|
||||||
|
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
|
||||||
|
#[allow(dead_code)]
|
||||||
stuck_threshold: Duration,
|
stuck_threshold: Duration,
|
||||||
max_repair_attempts: u32,
|
max_repair_attempts: u32,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<dyn Database>>,
|
||||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||||
|
// TODO: use for tool hot-reload after repair
|
||||||
|
#[allow(dead_code)]
|
||||||
tools: Option<Arc<ToolRegistry>>,
|
tools: Option<Arc<ToolRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +95,15 @@ impl DefaultSelfRepair {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a Store for tool failure tracking.
|
/// Add a Store for tool failure tracking.
|
||||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
|
||||||
|
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a Builder and ToolRegistry for automatic tool repair.
|
/// Add a Builder and ToolRegistry for automatic tool repair.
|
||||||
pub fn with_builder(
|
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
|
||||||
|
pub(crate) fn with_builder(
|
||||||
mut self,
|
mut self,
|
||||||
builder: Arc<dyn SoftwareBuilder>,
|
builder: Arc<dyn SoftwareBuilder>,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
@@ -118,30 +124,18 @@ impl SelfRepair for DefaultSelfRepair {
|
|||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||||
&& ctx.state == JobState::Stuck
|
&& ctx.state == JobState::Stuck
|
||||||
{
|
{
|
||||||
// Measure stuck_duration from the most recent Stuck transition,
|
let stuck_duration = ctx
|
||||||
// not from started_at (which reflects when the job first ran).
|
.started_at
|
||||||
let stuck_since = ctx
|
.map(|start| {
|
||||||
.transitions
|
let now = Utc::now();
|
||||||
.iter()
|
let duration = now.signed_duration_since(start);
|
||||||
.rev()
|
|
||||||
.find(|t| t.to == JobState::Stuck)
|
|
||||||
.map(|t| t.timestamp);
|
|
||||||
|
|
||||||
let stuck_duration = stuck_since
|
|
||||||
.map(|ts| {
|
|
||||||
let duration = Utc::now().signed_duration_since(ts);
|
|
||||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Only report jobs that have been stuck long enough
|
|
||||||
if stuck_duration < self.stuck_threshold {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
stuck_jobs.push(StuckJob {
|
stuck_jobs.push(StuckJob {
|
||||||
job_id,
|
job_id,
|
||||||
last_activity: stuck_since.unwrap_or(ctx.created_at),
|
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||||
stuck_duration,
|
stuck_duration,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
repair_attempts: ctx.repair_attempts,
|
repair_attempts: ctx.repair_attempts,
|
||||||
@@ -279,8 +273,9 @@ impl SelfRepair for DefaultSelfRepair {
|
|||||||
tracing::warn!("Failed to mark tool as repaired: {}", e);
|
tracing::warn!("Failed to mark tool as repaired: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log if the tool was auto-registered
|
||||||
if result.registered {
|
if result.registered {
|
||||||
tracing::info!("Repaired tool '{}' auto-registered by builder", tool.name);
|
tracing::info!("Repaired tool '{}' auto-registered", tool.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(RepairResult::Success {
|
Ok(RepairResult::Success {
|
||||||
@@ -422,8 +417,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Use zero threshold so the just-stuck job is detected immediately.
|
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
|
|
||||||
let stuck = repair.detect_stuck_jobs().await;
|
let stuck = repair.detect_stuck_jobs().await;
|
||||||
assert_eq!(stuck.len(), 1);
|
assert_eq!(stuck.len(), 1);
|
||||||
assert_eq!(stuck[0].job_id, job_id);
|
assert_eq!(stuck[0].job_id, job_id);
|
||||||
@@ -489,98 +483,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn detect_stuck_jobs_filters_by_threshold() {
|
|
||||||
let cm = Arc::new(ContextManager::new(10));
|
|
||||||
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
|
|
||||||
|
|
||||||
// Transition to InProgress, then to Stuck.
|
|
||||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
cm.update_context(job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Use a very large threshold (1 hour). Job just became stuck, so
|
|
||||||
// stuck_duration < threshold. It should be filtered out.
|
|
||||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(3600), 3);
|
|
||||||
let stuck = repair.detect_stuck_jobs().await;
|
|
||||||
assert!(
|
|
||||||
stuck.is_empty(),
|
|
||||||
"Job stuck for <1s should be filtered by 1h threshold"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn detect_stuck_jobs_includes_when_over_threshold() {
|
|
||||||
let cm = Arc::new(ContextManager::new(10));
|
|
||||||
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
|
|
||||||
|
|
||||||
// Transition to InProgress, then to Stuck.
|
|
||||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
cm.update_context(job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Use a zero threshold -- any stuck duration should be included.
|
|
||||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
|
|
||||||
let stuck = repair.detect_stuck_jobs().await;
|
|
||||||
assert_eq!(stuck.len(), 1, "Job should be detected with zero threshold");
|
|
||||||
assert_eq!(stuck[0].job_id, job_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: stuck_duration must be measured from the Stuck transition,
|
|
||||||
/// not from started_at. A job that ran for 2 hours before becoming stuck
|
|
||||||
/// should NOT immediately exceed a 5-minute threshold.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stuck_duration_measured_from_stuck_transition_not_started_at() {
|
|
||||||
let cm = Arc::new(ContextManager::new(10));
|
|
||||||
let job_id = cm.create_job("Long runner", "desc").await.unwrap();
|
|
||||||
|
|
||||||
// Transition to InProgress (sets started_at to now).
|
|
||||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Backdate started_at to 2 hours ago to simulate a long-running job.
|
|
||||||
cm.update_context(job_id, |ctx| {
|
|
||||||
ctx.started_at = Some(Utc::now() - chrono::Duration::hours(2));
|
|
||||||
Ok::<(), crate::error::Error>(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Now transition to Stuck (stuck transition timestamp is ~now).
|
|
||||||
cm.update_context(job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::Stuck, Some("wedged".into()))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// With a 5-minute threshold, the job JUST became stuck — should NOT be detected.
|
|
||||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(300), 3);
|
|
||||||
let stuck = repair.detect_stuck_jobs().await;
|
|
||||||
assert!(
|
|
||||||
stuck.is_empty(),
|
|
||||||
"Job stuck for <1s should not exceed 5min threshold, \
|
|
||||||
but stuck_duration was computed from started_at (2h ago)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn detect_broken_tools_returns_empty_without_store() {
|
async fn detect_broken_tools_returns_empty_without_store() {
|
||||||
let cm = Arc::new(ContextManager::new(10));
|
let cm = Arc::new(ContextManager::new(10));
|
||||||
@@ -613,148 +515,4 @@ mod tests {
|
|||||||
result
|
result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mock SoftwareBuilder that returns a successful build result.
|
|
||||||
struct MockBuilder {
|
|
||||||
build_count: std::sync::atomic::AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockBuilder {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
build_count: std::sync::atomic::AtomicU32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn builds(&self) -> u32 {
|
|
||||||
self.build_count.load(std::sync::atomic::Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl crate::tools::SoftwareBuilder for MockBuilder {
|
|
||||||
async fn analyze(
|
|
||||||
&self,
|
|
||||||
_description: &str,
|
|
||||||
) -> Result<crate::tools::BuildRequirement, crate::error::ToolError> {
|
|
||||||
Ok(crate::tools::BuildRequirement {
|
|
||||||
name: "mock-tool".to_string(),
|
|
||||||
description: "mock".to_string(),
|
|
||||||
software_type: crate::tools::SoftwareType::WasmTool,
|
|
||||||
language: crate::tools::Language::Rust,
|
|
||||||
input_spec: None,
|
|
||||||
output_spec: None,
|
|
||||||
dependencies: vec![],
|
|
||||||
capabilities: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build(
|
|
||||||
&self,
|
|
||||||
requirement: &crate::tools::BuildRequirement,
|
|
||||||
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
|
|
||||||
self.build_count
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
Ok(crate::tools::BuildResult {
|
|
||||||
build_id: Uuid::new_v4(),
|
|
||||||
requirement: requirement.clone(),
|
|
||||||
artifact_path: std::path::PathBuf::from("/tmp/mock.wasm"),
|
|
||||||
logs: vec![],
|
|
||||||
success: true,
|
|
||||||
error: None,
|
|
||||||
started_at: Utc::now(),
|
|
||||||
completed_at: Utc::now(),
|
|
||||||
iterations: 1,
|
|
||||||
validation_warnings: vec![],
|
|
||||||
tests_passed: 1,
|
|
||||||
tests_failed: 0,
|
|
||||||
registered: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn repair(
|
|
||||||
&self,
|
|
||||||
_result: &crate::tools::BuildResult,
|
|
||||||
_error: &str,
|
|
||||||
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
|
|
||||||
unimplemented!("not needed for this test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// E2E test: stuck job detected -> repaired -> transitions back to InProgress,
|
|
||||||
/// and broken tool detected -> builder invoked -> tool marked repaired.
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn e2e_stuck_job_repair_and_tool_rebuild() {
|
|
||||||
// --- Setup ---
|
|
||||||
let cm = Arc::new(ContextManager::new(10));
|
|
||||||
let job_id = cm.create_job("E2E stuck job", "desc").await.unwrap();
|
|
||||||
|
|
||||||
// Transition job: Pending -> InProgress -> Stuck
|
|
||||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
cm.update_context(job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::Stuck, Some("deadlocked".to_string()))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Create a mock builder and a real test database (for store)
|
|
||||||
let builder = Arc::new(MockBuilder::new());
|
|
||||||
let tools = Arc::new(ToolRegistry::new());
|
|
||||||
let (db, _tmp_dir) = crate::testing::test_db().await;
|
|
||||||
|
|
||||||
// Create self-repair with zero threshold (detect immediately),
|
|
||||||
// wired with store, builder, and tools.
|
|
||||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
|
|
||||||
.with_store(Arc::clone(&db))
|
|
||||||
.with_builder(
|
|
||||||
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
|
|
||||||
tools,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Phase 1: Detect and repair stuck job ---
|
|
||||||
let stuck_jobs = repair.detect_stuck_jobs().await;
|
|
||||||
assert_eq!(stuck_jobs.len(), 1, "Should detect the stuck job");
|
|
||||||
assert_eq!(stuck_jobs[0].job_id, job_id);
|
|
||||||
|
|
||||||
let result = repair.repair_stuck_job(&stuck_jobs[0]).await.unwrap();
|
|
||||||
assert!(
|
|
||||||
matches!(result, RepairResult::Success { .. }),
|
|
||||||
"Job repair should succeed: {:?}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify job transitioned back to InProgress
|
|
||||||
let ctx = cm.get_context(job_id).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
ctx.state,
|
|
||||||
JobState::InProgress,
|
|
||||||
"Job should be back to InProgress after repair"
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Phase 2: Repair a broken tool via builder ---
|
|
||||||
let broken = BrokenTool {
|
|
||||||
name: "broken-wasm-tool".to_string(),
|
|
||||||
failure_count: 10,
|
|
||||||
last_error: Some("panic in tool execution".to_string()),
|
|
||||||
first_failure: Utc::now() - chrono::Duration::hours(1),
|
|
||||||
last_failure: Utc::now(),
|
|
||||||
last_build_result: None,
|
|
||||||
repair_attempts: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool_result = repair.repair_broken_tool(&broken).await.unwrap();
|
|
||||||
assert!(
|
|
||||||
matches!(tool_result, RepairResult::Success { .. }),
|
|
||||||
"Tool repair should succeed with mock builder: {:?}",
|
|
||||||
tool_result
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify builder was actually invoked
|
|
||||||
assert_eq!(builder.builds(), 1, "Builder should have been called once");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,15 +188,6 @@ pub struct PendingApproval {
|
|||||||
/// through the approval flow even if the approval message lacks timezone.
|
/// through the approval flow even if the approval message lacks timezone.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub user_timezone: Option<String>,
|
pub user_timezone: Option<String>,
|
||||||
/// Whether the "always" auto-approve option should be offered to the user.
|
|
||||||
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
|
|
||||||
/// destructive shell commands), meaning every invocation must be confirmed.
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub allow_always: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A conversation thread within a session.
|
/// A conversation thread within a session.
|
||||||
@@ -1115,7 +1106,6 @@ mod tests {
|
|||||||
context_messages: vec![ChatMessage::user("do it")],
|
context_messages: vec![ChatMessage::user("do it")],
|
||||||
deferred_tool_calls: vec![],
|
deferred_tool_calls: vec![],
|
||||||
user_timezone: None,
|
user_timezone: None,
|
||||||
allow_always: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
thread.await_approval(approval);
|
thread.await_approval(approval);
|
||||||
@@ -1142,7 +1132,6 @@ mod tests {
|
|||||||
context_messages: vec![],
|
context_messages: vec![],
|
||||||
deferred_tool_calls: vec![],
|
deferred_tool_calls: vec![],
|
||||||
user_timezone: None,
|
user_timezone: None,
|
||||||
allow_always: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
thread.await_approval(approval);
|
thread.await_approval(approval);
|
||||||
|
|||||||
@@ -382,8 +382,6 @@ pub enum SubmissionResult {
|
|||||||
description: String,
|
description: String,
|
||||||
/// Parameters being passed.
|
/// Parameters being passed.
|
||||||
parameters: serde_json::Value,
|
parameters: serde_json::Value,
|
||||||
/// Whether "always" auto-approve should be offered to the user.
|
|
||||||
allow_always: bool,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Successfully processed (for control commands).
|
/// Successfully processed (for control commands).
|
||||||
|
|||||||
+8
-21
@@ -506,8 +506,7 @@ impl Agent {
|
|||||||
let tool_name = pending.tool_name.clone();
|
let tool_name = pending.tool_name.clone();
|
||||||
let description = pending.description.clone();
|
let description = pending.description.clone();
|
||||||
let parameters = pending.display_parameters.clone();
|
let parameters = pending.display_parameters.clone();
|
||||||
let allow_always = pending.allow_always;
|
thread.await_approval(pending);
|
||||||
thread.await_approval(*pending);
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -517,7 +516,6 @@ impl Agent {
|
|||||||
tool_name: tool_name.clone(),
|
tool_name: tool_name.clone(),
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
parameters: parameters.clone(),
|
parameters: parameters.clone(),
|
||||||
allow_always,
|
|
||||||
},
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
@@ -527,7 +525,6 @@ impl Agent {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1072,31 +1069,28 @@ impl Agent {
|
|||||||
usize,
|
usize,
|
||||||
crate::llm::ToolCall,
|
crate::llm::ToolCall,
|
||||||
Arc<dyn crate::tools::Tool>,
|
Arc<dyn crate::tools::Tool>,
|
||||||
bool, // allow_always
|
|
||||||
)> = None;
|
)> = None;
|
||||||
|
|
||||||
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
||||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||||
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
||||||
// all approval checks (including ApprovalRequirement::Always).
|
// all approval checks (including ApprovalRequirement::Always).
|
||||||
let (needs_approval, allow_always) = if self.config.auto_approve_tools {
|
let needs_approval = if self.config.auto_approve_tools {
|
||||||
(false, true)
|
false
|
||||||
} else {
|
} else {
|
||||||
use crate::tools::ApprovalRequirement;
|
use crate::tools::ApprovalRequirement;
|
||||||
let requirement = tool.requires_approval(&tc.arguments);
|
match tool.requires_approval(&tc.arguments) {
|
||||||
let needs = match requirement {
|
|
||||||
ApprovalRequirement::Never => false,
|
ApprovalRequirement::Never => false,
|
||||||
ApprovalRequirement::UnlessAutoApproved => {
|
ApprovalRequirement::UnlessAutoApproved => {
|
||||||
let sess = session.lock().await;
|
let sess = session.lock().await;
|
||||||
!sess.is_tool_auto_approved(&tc.name)
|
!sess.is_tool_auto_approved(&tc.name)
|
||||||
}
|
}
|
||||||
ApprovalRequirement::Always => true,
|
ApprovalRequirement::Always => true,
|
||||||
};
|
}
|
||||||
(needs, !matches!(requirement, ApprovalRequirement::Always))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if needs_approval {
|
if needs_approval {
|
||||||
approval_needed = Some((idx, tc.clone(), tool, allow_always));
|
approval_needed = Some((idx, tc.clone(), tool));
|
||||||
break; // remaining tools stay deferred
|
break; // remaining tools stay deferred
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1298,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle approval if a tool needed it
|
// Handle approval if a tool needed it
|
||||||
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
|
if let Some((approval_idx, tc, tool)) = approval_needed {
|
||||||
let new_pending = PendingApproval {
|
let new_pending = PendingApproval {
|
||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
tool_name: tc.name.clone(),
|
tool_name: tc.name.clone(),
|
||||||
@@ -1316,7 +1310,6 @@ impl Agent {
|
|||||||
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
|
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
|
||||||
// Carry forward the resolved timezone from the original pending approval
|
// Carry forward the resolved timezone from the original pending approval
|
||||||
user_timezone: pending.user_timezone.clone(),
|
user_timezone: pending.user_timezone.clone(),
|
||||||
allow_always,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_id = new_pending.request_id;
|
let request_id = new_pending.request_id;
|
||||||
@@ -1340,7 +1333,6 @@ impl Agent {
|
|||||||
tool_name: tool_name.clone(),
|
tool_name: tool_name.clone(),
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
parameters: parameters.clone(),
|
parameters: parameters.clone(),
|
||||||
allow_always,
|
|
||||||
},
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
@@ -1351,7 +1343,6 @@ impl Agent {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1420,8 +1411,7 @@ impl Agent {
|
|||||||
let tool_name = new_pending.tool_name.clone();
|
let tool_name = new_pending.tool_name.clone();
|
||||||
let description = new_pending.description.clone();
|
let description = new_pending.description.clone();
|
||||||
let parameters = new_pending.display_parameters.clone();
|
let parameters = new_pending.display_parameters.clone();
|
||||||
let allow_always = new_pending.allow_always;
|
thread.await_approval(new_pending);
|
||||||
thread.await_approval(*new_pending);
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -1431,7 +1421,6 @@ impl Agent {
|
|||||||
tool_name: tool_name.clone(),
|
tool_name: tool_name.clone(),
|
||||||
description: description.clone(),
|
description: description.clone(),
|
||||||
parameters: parameters.clone(),
|
parameters: parameters.clone(),
|
||||||
allow_always,
|
|
||||||
},
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
@@ -1441,7 +1430,6 @@ impl Agent {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1961,7 +1949,6 @@ mod tests {
|
|||||||
context_messages: vec![],
|
context_messages: vec![],
|
||||||
deferred_tool_calls: vec![],
|
deferred_tool_calls: vec![],
|
||||||
user_timezone: None,
|
user_timezone: None,
|
||||||
allow_always: false,
|
|
||||||
};
|
};
|
||||||
thread.await_approval(pending);
|
thread.await_approval(pending);
|
||||||
|
|
||||||
|
|||||||
+8
-17
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
|
|||||||
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
|
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
|
||||||
use crate::tools::wasm::SharedCredentialRegistry;
|
use crate::tools::wasm::SharedCredentialRegistry;
|
||||||
use crate::tools::wasm::WasmToolRuntime;
|
use crate::tools::wasm::WasmToolRuntime;
|
||||||
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
|
use crate::workspace::{EmbeddingProvider, Workspace};
|
||||||
|
|
||||||
/// Fully initialized application components, ready for channel wiring
|
/// Fully initialized application components, ready for channel wiring
|
||||||
/// and agent construction.
|
/// and agent construction.
|
||||||
@@ -56,7 +56,6 @@ pub struct AppComponents {
|
|||||||
pub session: Arc<SessionManager>,
|
pub session: Arc<SessionManager>,
|
||||||
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
|
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
|
||||||
pub dev_loaded_tool_names: Vec<String>,
|
pub dev_loaded_tool_names: Vec<String>,
|
||||||
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options that control optional init phases.
|
/// Options that control optional init phases.
|
||||||
@@ -281,7 +280,6 @@ impl AppBuilder {
|
|||||||
Arc<ToolRegistry>,
|
Arc<ToolRegistry>,
|
||||||
Option<Arc<dyn EmbeddingProvider>>,
|
Option<Arc<dyn EmbeddingProvider>>,
|
||||||
Option<Arc<Workspace>>,
|
Option<Arc<Workspace>>,
|
||||||
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
|
||||||
),
|
),
|
||||||
anyhow::Error,
|
anyhow::Error,
|
||||||
> {
|
> {
|
||||||
@@ -313,13 +311,10 @@ impl AppBuilder {
|
|||||||
|
|
||||||
// Register memory tools if database is available
|
// Register memory tools if database is available
|
||||||
let workspace = if let Some(ref db) = self.db {
|
let workspace = if let Some(ref db) = self.db {
|
||||||
let emb_cache_config = EmbeddingCacheConfig {
|
|
||||||
max_entries: self.config.embeddings.cache_size,
|
|
||||||
};
|
|
||||||
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
|
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
|
||||||
.with_search_config(&self.config.search);
|
.with_search_config(&self.config.search);
|
||||||
if let Some(ref emb) = embeddings {
|
if let Some(ref emb) = embeddings {
|
||||||
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
|
ws = ws.with_embeddings(emb.clone());
|
||||||
}
|
}
|
||||||
let ws = Arc::new(ws);
|
let ws = Arc::new(ws);
|
||||||
tools.register_memory_tools(Arc::clone(&ws));
|
tools.register_memory_tools(Arc::clone(&ws));
|
||||||
@@ -372,19 +367,16 @@ impl AppBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register builder tool if enabled
|
// Register builder tool if enabled
|
||||||
let builder = if self.config.builder.enabled
|
if self.config.builder.enabled
|
||||||
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
|
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
|
||||||
{
|
{
|
||||||
let b = tools
|
tools
|
||||||
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
||||||
.await;
|
.await;
|
||||||
tracing::info!("Builder mode enabled");
|
tracing::debug!("Builder mode enabled");
|
||||||
Some(b)
|
}
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((safety, tools, embeddings, workspace, builder))
|
Ok((safety, tools, embeddings, workspace))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
|
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
|
||||||
@@ -707,7 +699,7 @@ impl AppBuilder {
|
|||||||
} else {
|
} else {
|
||||||
self.init_llm().await?
|
self.init_llm().await?
|
||||||
};
|
};
|
||||||
let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
|
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
|
||||||
|
|
||||||
// Create hook registry early so runtime extension activation can register hooks.
|
// Create hook registry early so runtime extension activation can register hooks.
|
||||||
let hooks = Arc::new(HookRegistry::new());
|
let hooks = Arc::new(HookRegistry::new());
|
||||||
@@ -827,7 +819,6 @@ impl AppBuilder {
|
|||||||
session: self.session,
|
session: self.session,
|
||||||
catalog_entries,
|
catalog_entries,
|
||||||
dev_loaded_tool_names,
|
dev_loaded_tool_names,
|
||||||
builder,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,11 +305,6 @@ pub enum StatusUpdate {
|
|||||||
tool_name: String,
|
tool_name: String,
|
||||||
description: String,
|
description: String,
|
||||||
parameters: serde_json::Value,
|
parameters: serde_json::Value,
|
||||||
/// When `true`, the UI should offer an "always" option that auto-approves
|
|
||||||
/// future calls to this tool for the rest of the session. When `false`
|
|
||||||
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
|
|
||||||
/// time and the "always" button should be hidden.
|
|
||||||
allow_always: bool,
|
|
||||||
},
|
},
|
||||||
/// Extension needs user authentication (token or OAuth).
|
/// Extension needs user authentication (token or OAuth).
|
||||||
AuthRequired {
|
AuthRequired {
|
||||||
|
|||||||
@@ -239,11 +239,6 @@ impl ChannelManager {
|
|||||||
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||||
self.channels.read().await.get(name).cloned()
|
self.channels.read().await.get(name).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a channel from the manager.
|
|
||||||
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
|
||||||
self.channels.write().await.remove(name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChannelManager {
|
impl Default for ChannelManager {
|
||||||
|
|||||||
+383
-193
@@ -1,16 +1,16 @@
|
|||||||
//! Channel trait implementation for channel-relay webhook callbacks.
|
//! Channel trait implementation for channel-relay SSE streams.
|
||||||
//!
|
//!
|
||||||
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
|
//! `RelayChannel` connects to a channel-relay service via SSE, converts
|
||||||
//! (pushed through an mpsc channel by the webhook handler), converts them
|
//! incoming events to `IncomingMessage`s, and sends responses via the
|
||||||
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
|
//! relay's provider-specific proxy API (Slack).
|
||||||
//! proxy API (Slack).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{RwLock, mpsc};
|
||||||
|
|
||||||
use crate::channels::relay::client::{ChannelEvent, RelayClient};
|
use crate::channels::relay::client::{RelayClient, RelayError};
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
@@ -39,34 +39,44 @@ impl RelayProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channel implementation that receives events from channel-relay via webhook callbacks.
|
/// Channel implementation that connects to a channel-relay SSE stream.
|
||||||
pub struct RelayChannel {
|
pub struct RelayChannel {
|
||||||
client: RelayClient,
|
client: RelayClient,
|
||||||
provider: RelayProvider,
|
provider: RelayProvider,
|
||||||
|
stream_token: Arc<RwLock<String>>,
|
||||||
team_id: String,
|
team_id: String,
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
/// Sender side of the event channel — shared with the webhook handler.
|
user_id: String,
|
||||||
event_tx: mpsc::Sender<ChannelEvent>,
|
/// SSE stream long-poll timeout in seconds.
|
||||||
/// Receiver side — taken once by `start()`.
|
stream_timeout_secs: u64,
|
||||||
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
|
/// Initial exponential backoff in milliseconds.
|
||||||
|
backoff_initial_ms: u64,
|
||||||
|
/// Maximum exponential backoff in milliseconds.
|
||||||
|
backoff_max_ms: u64,
|
||||||
|
/// Handle to the reconnect task for clean shutdown.
|
||||||
|
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
/// Handle to the SSE parser task for clean shutdown.
|
||||||
|
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||||
|
/// Maximum consecutive reconnect failures before giving up.
|
||||||
|
max_consecutive_failures: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RelayChannel {
|
impl RelayChannel {
|
||||||
/// Create a new relay channel for Slack (default provider).
|
/// Create a new relay channel for Slack (default provider).
|
||||||
pub fn new(
|
pub fn new(
|
||||||
client: RelayClient,
|
client: RelayClient,
|
||||||
|
stream_token: String,
|
||||||
team_id: String,
|
team_id: String,
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
event_tx: mpsc::Sender<ChannelEvent>,
|
user_id: String,
|
||||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new_with_provider(
|
Self::new_with_provider(
|
||||||
client,
|
client,
|
||||||
RelayProvider::Slack,
|
RelayProvider::Slack,
|
||||||
|
stream_token,
|
||||||
team_id,
|
team_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
event_tx,
|
user_id,
|
||||||
event_rx,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +84,44 @@ impl RelayChannel {
|
|||||||
pub fn new_with_provider(
|
pub fn new_with_provider(
|
||||||
client: RelayClient,
|
client: RelayClient,
|
||||||
provider: RelayProvider,
|
provider: RelayProvider,
|
||||||
|
stream_token: String,
|
||||||
team_id: String,
|
team_id: String,
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
event_tx: mpsc::Sender<ChannelEvent>,
|
user_id: String,
|
||||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
provider,
|
provider,
|
||||||
|
stream_token: Arc::new(RwLock::new(stream_token)),
|
||||||
team_id,
|
team_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
event_tx,
|
user_id,
|
||||||
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
|
stream_timeout_secs: 86400,
|
||||||
|
backoff_initial_ms: 1000,
|
||||||
|
backoff_max_ms: 60000,
|
||||||
|
reconnect_handle: RwLock::new(None),
|
||||||
|
parser_handle: Arc::new(RwLock::new(None)),
|
||||||
|
max_consecutive_failures: 50,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a clone of the event sender for wiring into the webhook endpoint.
|
/// Set backoff/timeout parameters from relay config values.
|
||||||
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
|
pub fn with_timeouts(
|
||||||
self.event_tx.clone()
|
mut self,
|
||||||
|
stream_timeout_secs: u64,
|
||||||
|
backoff_initial_ms: u64,
|
||||||
|
backoff_max_ms: u64,
|
||||||
|
) -> Self {
|
||||||
|
self.stream_timeout_secs = stream_timeout_secs;
|
||||||
|
self.backoff_initial_ms = backoff_initial_ms;
|
||||||
|
self.backoff_max_ms = backoff_max_ms;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the maximum number of consecutive reconnect failures before giving up.
|
||||||
|
pub fn with_max_failures(mut self, max: u64) -> Self {
|
||||||
|
self.max_consecutive_failures = max;
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a provider-appropriate proxy body for sending a message.
|
/// Build a provider-appropriate proxy body for sending a message.
|
||||||
@@ -121,9 +151,15 @@ impl RelayChannel {
|
|||||||
team_id: &str,
|
team_id: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
|
) -> Result<serde_json::Value, RelayError> {
|
||||||
self.client
|
self.client
|
||||||
.proxy_provider(self.provider.as_str(), team_id, method, body)
|
.proxy_provider(
|
||||||
|
self.provider.as_str(),
|
||||||
|
team_id,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
Some(&self.instance_id),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,83 +172,205 @@ impl Channel for RelayChannel {
|
|||||||
|
|
||||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
let channel_name = self.name().to_string();
|
let channel_name = self.name().to_string();
|
||||||
|
let token = self.stream_token.read().await.clone();
|
||||||
|
let (stream, initial_parser_handle) = self
|
||||||
|
.client
|
||||||
|
.connect_stream(&token, self.stream_timeout_secs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChannelError::StartupFailed {
|
||||||
|
name: channel_name.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
// Take the receiver (can only start once)
|
*self.parser_handle.write().await = Some(initial_parser_handle);
|
||||||
let mut event_rx =
|
|
||||||
self.event_rx
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| ChannelError::StartupFailed {
|
|
||||||
name: channel_name.clone(),
|
|
||||||
reason: "RelayChannel already started".to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel(64);
|
let (tx, rx) = mpsc::channel(64);
|
||||||
|
|
||||||
|
// Spawn the stream reader + reconnect task
|
||||||
|
let client = self.client.clone();
|
||||||
|
let stream_token = Arc::clone(&self.stream_token);
|
||||||
|
let instance_id = self.instance_id.clone();
|
||||||
|
let user_id = self.user_id.clone();
|
||||||
|
let team_id = self.team_id.clone();
|
||||||
|
let stream_timeout_secs = self.stream_timeout_secs;
|
||||||
|
let backoff_initial_ms = self.backoff_initial_ms;
|
||||||
|
let backoff_max_ms = self.backoff_max_ms;
|
||||||
|
let max_consecutive_failures = self.max_consecutive_failures;
|
||||||
|
let parser_handle = Arc::clone(&self.parser_handle);
|
||||||
let provider_str = self.provider.as_str().to_string();
|
let provider_str = self.provider.as_str().to_string();
|
||||||
let relay_name = channel_name.clone();
|
let relay_name = channel_name.clone();
|
||||||
|
|
||||||
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
|
let handle = tokio::spawn(async move {
|
||||||
tokio::spawn(async move {
|
use futures::StreamExt;
|
||||||
while let Some(event) = event_rx.recv().await {
|
|
||||||
// Validate required fields
|
let mut current_stream = stream;
|
||||||
if event.sender_id.is_empty()
|
let mut backoff_ms = backoff_initial_ms;
|
||||||
|| event.channel_id.is_empty()
|
let mut consecutive_failures: u64 = 0;
|
||||||
|| event.provider_scope.is_empty()
|
|
||||||
{
|
loop {
|
||||||
tracing::debug!(
|
// Read events from the current stream
|
||||||
|
while let Some(event) = current_stream.next().await {
|
||||||
|
// Reset backoff and failure count on successful event
|
||||||
|
backoff_ms = backoff_initial_ms;
|
||||||
|
consecutive_failures = 0;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if event.sender_id.is_empty()
|
||||||
|
|| event.channel_id.is_empty()
|
||||||
|
|| event.provider_scope.is_empty()
|
||||||
|
{
|
||||||
|
tracing::debug!(
|
||||||
|
event_type = %event.event_type,
|
||||||
|
sender_id = %event.sender_id,
|
||||||
|
channel_id = %event.channel_id,
|
||||||
|
"Relay: skipping event with missing required fields"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip non-message events
|
||||||
|
if !event.is_message() {
|
||||||
|
tracing::debug!(
|
||||||
|
event_type = %event.event_type,
|
||||||
|
"Relay: skipping non-message event"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
event_type = %event.event_type,
|
event_type = %event.event_type,
|
||||||
sender_id = %event.sender_id,
|
sender = %event.sender_id,
|
||||||
channel_id = %event.channel_id,
|
channel = %event.channel_id,
|
||||||
"Relay: skipping event with missing required fields"
|
provider = %provider_str,
|
||||||
|
"Relay: received message from {}", provider_str
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
|
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
|
||||||
|
.with_user_name(event.display_name())
|
||||||
|
.with_metadata(serde_json::json!({
|
||||||
|
"team_id": event.team_id(),
|
||||||
|
"channel_id": event.channel_id,
|
||||||
|
"sender_id": event.sender_id,
|
||||||
|
"sender_name": event.display_name(),
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"thread_id": event.thread_id,
|
||||||
|
"provider": event.provider,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let msg = if let Some(ref thread_id) = event.thread_id {
|
||||||
|
msg.with_thread(thread_id)
|
||||||
|
} else {
|
||||||
|
msg.with_thread(&event.channel_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if tx.send(msg).await.is_err() {
|
||||||
|
tracing::info!("Relay channel receiver dropped, stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip non-message events
|
// Stream ended, attempt reconnect with backoff
|
||||||
if !event.is_message() {
|
consecutive_failures += 1;
|
||||||
tracing::debug!(
|
if consecutive_failures >= max_consecutive_failures {
|
||||||
event_type = %event.event_type,
|
tracing::error!(
|
||||||
"Relay: skipping non-message event"
|
channel = %relay_name,
|
||||||
|
failures = consecutive_failures,
|
||||||
|
"Relay channel giving up after {} consecutive failures",
|
||||||
|
consecutive_failures
|
||||||
);
|
);
|
||||||
continue;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::warn!(
|
||||||
event_type = %event.event_type,
|
backoff_ms = backoff_ms,
|
||||||
sender = %event.sender_id,
|
failures = consecutive_failures,
|
||||||
channel = %event.channel_id,
|
"Relay SSE stream ended, reconnecting..."
|
||||||
provider = %provider_str,
|
|
||||||
"Relay: received message from {}", provider_str
|
|
||||||
);
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
|
||||||
|
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
|
||||||
|
|
||||||
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
|
// Try to reconnect
|
||||||
.with_user_name(event.display_name())
|
let token = stream_token.read().await.clone();
|
||||||
.with_metadata(serde_json::json!({
|
match client.connect_stream(&token, stream_timeout_secs).await {
|
||||||
"team_id": event.team_id(),
|
Ok((new_stream, new_parser)) => {
|
||||||
"channel_id": event.channel_id,
|
tracing::info!("Relay SSE stream reconnected");
|
||||||
"sender_id": event.sender_id,
|
consecutive_failures = 0;
|
||||||
"sender_name": event.display_name(),
|
backoff_ms = backoff_initial_ms;
|
||||||
"event_type": event.event_type,
|
current_stream = new_stream;
|
||||||
"thread_id": event.thread_id,
|
// Abort old parser before replacing
|
||||||
"provider": event.provider,
|
if let Some(old) = parser_handle.write().await.take() {
|
||||||
}));
|
old.abort();
|
||||||
|
}
|
||||||
|
*parser_handle.write().await = Some(new_parser);
|
||||||
|
}
|
||||||
|
Err(RelayError::TokenExpired) => {
|
||||||
|
// Attempt token renewal
|
||||||
|
tracing::info!("Relay stream token expired, renewing...");
|
||||||
|
match client.renew_token(&instance_id, &user_id).await {
|
||||||
|
Ok(new_token) => {
|
||||||
|
*stream_token.write().await = new_token.clone();
|
||||||
|
match client.connect_stream(&new_token, stream_timeout_secs).await {
|
||||||
|
Ok((new_stream, new_parser)) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Relay SSE stream reconnected with new token"
|
||||||
|
);
|
||||||
|
consecutive_failures = 0;
|
||||||
|
backoff_ms = backoff_initial_ms;
|
||||||
|
current_stream = new_stream;
|
||||||
|
if let Some(old) = parser_handle.write().await.take() {
|
||||||
|
old.abort();
|
||||||
|
}
|
||||||
|
*parser_handle.write().await = Some(new_parser);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"Failed to reconnect after token renewal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"Failed to renew relay stream token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let msg = if let Some(ref thread_id) = event.thread_id {
|
// Check if the team is still valid (skip when team_id is unknown,
|
||||||
msg.with_thread(thread_id)
|
// e.g. when no DB store was available at activation time)
|
||||||
} else {
|
if !team_id.is_empty() {
|
||||||
msg.with_thread(&event.channel_id)
|
match client.list_connections(&instance_id).await {
|
||||||
};
|
Ok(conns) => {
|
||||||
|
let has_team =
|
||||||
if tx.send(msg).await.is_err() {
|
conns.iter().any(|c| c.team_id == team_id && c.connected);
|
||||||
tracing::info!("Relay channel receiver dropped, stopping");
|
if !has_team {
|
||||||
return;
|
tracing::warn!(
|
||||||
|
team_id = %team_id,
|
||||||
|
"Team no longer connected, stopping relay channel"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"Could not verify team connection, will retry next iteration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Relay event channel closed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
*self.reconnect_handle.write().await = Some(handle);
|
||||||
|
|
||||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
Ok(Box::pin(stream))
|
Ok(Box::pin(stream))
|
||||||
}
|
}
|
||||||
@@ -265,7 +423,6 @@ impl Channel for RelayChannel {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always: _,
|
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -293,24 +450,28 @@ impl Channel for RelayChannel {
|
|||||||
name: self.name().to_string(),
|
name: self.name().to_string(),
|
||||||
reason: "Missing channel_id for approval buttons".into(),
|
reason: "Missing channel_id for approval buttons".into(),
|
||||||
})?;
|
})?;
|
||||||
|
let sender_id = metadata
|
||||||
|
.get("sender_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ChannelError::SendFailed {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
reason: "Missing sender_id for approval buttons".into(),
|
||||||
|
})?;
|
||||||
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
|
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
|
||||||
let team_id = metadata
|
let team_id = metadata
|
||||||
.get("team_id")
|
.get("team_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&self.team_id);
|
.unwrap_or(&self.team_id);
|
||||||
|
|
||||||
// Register server-side approval record and get opaque token.
|
// Button value payload (Slack limits button values to 2000 chars;
|
||||||
// The button value contains ONLY the token — no routing fields.
|
// safe with typical UUIDs but documented here as a constraint)
|
||||||
let approval_token = self
|
|
||||||
.client
|
|
||||||
.create_approval(team_id, channel_id, thread_id, &request_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ChannelError::SendFailed {
|
|
||||||
name: self.name().to_string(),
|
|
||||||
reason: format!("Failed to register approval: {e}"),
|
|
||||||
})?;
|
|
||||||
let value_payload = serde_json::json!({
|
let value_payload = serde_json::json!({
|
||||||
"approval_token": approval_token,
|
"instance_id": self.instance_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"thread_ts": thread_id,
|
||||||
|
"request_id": request_id,
|
||||||
|
"sender_id": sender_id,
|
||||||
});
|
});
|
||||||
let value_str = value_payload.to_string();
|
let value_str = value_payload.to_string();
|
||||||
|
|
||||||
@@ -421,8 +582,12 @@ impl Channel for RelayChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||||
// Relay cleanup is driven by the extension manager dropping the shared
|
if let Some(handle) = self.reconnect_handle.write().await.take() {
|
||||||
// sender and removing the channel from the channel manager.
|
handle.abort();
|
||||||
|
}
|
||||||
|
if let Some(handle) = self.parser_handle.write().await.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,20 +605,27 @@ mod tests {
|
|||||||
.expect("client")
|
.expect("client")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_channel() -> RelayChannel {
|
|
||||||
let (tx, rx) = mpsc::channel(64);
|
|
||||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relay_channel_name() {
|
fn relay_channel_name() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
|
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conversation_context_extracts_metadata() {
|
fn conversation_context_extracts_metadata() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
|
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"sender_name": "bob",
|
"sender_name": "bob",
|
||||||
@@ -468,6 +640,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metadata_shape_includes_event_type_and_sender_name() {
|
fn metadata_shape_includes_event_type_and_sender_name() {
|
||||||
|
// Regression: metadata JSON must include event_type and sender_name
|
||||||
|
// for downstream routing (DM vs channel) and conversation_context().
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"team_id": "T123",
|
"team_id": "T123",
|
||||||
"channel_id": "C456",
|
"channel_id": "C456",
|
||||||
@@ -477,19 +651,43 @@ mod tests {
|
|||||||
"thread_id": null,
|
"thread_id": null,
|
||||||
"provider": "slack",
|
"provider": "slack",
|
||||||
});
|
});
|
||||||
|
// event_type must be present for DM-vs-channel routing
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
metadata.get("event_type").and_then(|v| v.as_str()),
|
metadata.get("event_type").and_then(|v| v.as_str()),
|
||||||
Some("direct_message")
|
Some("direct_message")
|
||||||
);
|
);
|
||||||
|
// sender_name must be present for conversation_context
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
metadata.get("sender_name").and_then(|v| v.as_str()),
|
metadata.get("sender_name").and_then(|v| v.as_str()),
|
||||||
Some("alice")
|
Some("alice")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_timeouts_sets_values() {
|
||||||
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
)
|
||||||
|
.with_timeouts(43200, 2000, 120000);
|
||||||
|
|
||||||
|
assert_eq!(channel.stream_timeout_secs, 43200);
|
||||||
|
assert_eq!(channel.backoff_initial_ms, 2000);
|
||||||
|
assert_eq!(channel.backoff_max_ms, 120000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_send_body_slack() {
|
fn build_send_body_slack() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
|
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
|
||||||
assert_eq!(method, "chat.postMessage");
|
assert_eq!(method, "chat.postMessage");
|
||||||
assert_eq!(body["channel"], "C456");
|
assert_eq!(body["channel"], "C456");
|
||||||
@@ -497,95 +695,72 @@ mod tests {
|
|||||||
assert_eq!(body["thread_ts"], "1234567.890");
|
assert_eq!(body["thread_ts"], "1234567.890");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn start_processes_events() {
|
fn parser_handle_is_shared_arc() {
|
||||||
let (tx, rx) = mpsc::channel(64);
|
let channel = RelayChannel::new(
|
||||||
let channel =
|
test_client(),
|
||||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
let mut stream = channel.start().await.unwrap();
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
// Send an event
|
);
|
||||||
tx.send(ChannelEvent {
|
// parser_handle should be an Arc — cloning should give a second reference
|
||||||
id: "1".into(),
|
let handle_clone = Arc::clone(&channel.parser_handle);
|
||||||
event_type: "message".into(),
|
// Both point to the same allocation
|
||||||
provider: "slack".into(),
|
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
|
||||||
provider_scope: "T123".into(),
|
|
||||||
channel_id: "C456".into(),
|
|
||||||
sender_id: "U789".into(),
|
|
||||||
sender_name: Some("alice".into()),
|
|
||||||
content: Some("hello".into()),
|
|
||||||
thread_id: None,
|
|
||||||
raw: serde_json::Value::Null,
|
|
||||||
timestamp: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(msg.content, "hello");
|
|
||||||
assert_eq!(msg.user_id, "U789");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn start_skips_non_message_events() {
|
fn with_max_failures_sets_value() {
|
||||||
let (tx, rx) = mpsc::channel(64);
|
let channel = RelayChannel::new(
|
||||||
let channel =
|
test_client(),
|
||||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
)
|
||||||
|
.with_max_failures(10);
|
||||||
|
|
||||||
let mut stream = channel.start().await.unwrap();
|
assert_eq!(channel.max_consecutive_failures, 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Send a non-message event (should be skipped)
|
#[test]
|
||||||
tx.send(ChannelEvent {
|
fn default_max_failures_is_50() {
|
||||||
id: "1".into(),
|
let channel = RelayChannel::new(
|
||||||
event_type: "reaction".into(),
|
test_client(),
|
||||||
provider: "slack".into(),
|
"token".into(),
|
||||||
provider_scope: "T123".into(),
|
"T123".into(),
|
||||||
channel_id: "C456".into(),
|
"inst1".into(),
|
||||||
sender_id: "U789".into(),
|
"user1".into(),
|
||||||
sender_name: None,
|
);
|
||||||
content: None,
|
assert_eq!(channel.max_consecutive_failures, 50);
|
||||||
thread_id: None,
|
}
|
||||||
raw: serde_json::Value::Null,
|
|
||||||
timestamp: None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Send a real message
|
#[test]
|
||||||
tx.send(ChannelEvent {
|
fn empty_team_id_accepted_at_construction() {
|
||||||
id: "2".into(),
|
// Regression: empty team_id (when no DB store is available) must not
|
||||||
event_type: "message".into(),
|
// prevent channel construction or cause immediate shutdown.
|
||||||
provider: "slack".into(),
|
let channel = RelayChannel::new(
|
||||||
provider_scope: "T123".into(),
|
test_client(),
|
||||||
channel_id: "C456".into(),
|
"token".into(),
|
||||||
sender_id: "U789".into(),
|
String::new(), // empty team_id
|
||||||
sender_name: None,
|
"inst1".into(),
|
||||||
content: Some("real message".into()),
|
"user1".into(),
|
||||||
thread_id: None,
|
);
|
||||||
raw: serde_json::Value::Null,
|
assert_eq!(channel.team_id, "");
|
||||||
timestamp: None,
|
// The reconnect loop now skips team validation when team_id is empty,
|
||||||
})
|
// so the channel remains alive.
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(msg.content, "real message");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_send_status_non_approval_is_noop() {
|
async fn test_send_status_non_approval_is_noop() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
let metadata = serde_json::json!({});
|
let metadata = serde_json::json!({});
|
||||||
let result = channel
|
let result = channel
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -600,7 +775,13 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_send_status_approval_non_dm_skips() {
|
async fn test_send_status_approval_non_dm_skips() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"event_type": "message",
|
"event_type": "message",
|
||||||
"channel_id": "C456",
|
"channel_id": "C456",
|
||||||
@@ -613,7 +794,6 @@ mod tests {
|
|||||||
tool_name: "shell".into(),
|
tool_name: "shell".into(),
|
||||||
description: "run command".into(),
|
description: "run command".into(),
|
||||||
parameters: serde_json::json!({}),
|
parameters: serde_json::json!({}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -624,7 +804,13 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_send_status_approval_dm_missing_channel_id_errors() {
|
async fn test_send_status_approval_dm_missing_channel_id_errors() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"event_type": "direct_message",
|
"event_type": "direct_message",
|
||||||
"sender_id": "U789",
|
"sender_id": "U789",
|
||||||
@@ -636,7 +822,6 @@ mod tests {
|
|||||||
tool_name: "shell".into(),
|
tool_name: "shell".into(),
|
||||||
description: "run command".into(),
|
description: "run command".into(),
|
||||||
parameters: serde_json::json!({}),
|
parameters: serde_json::json!({}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -650,8 +835,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
|
async fn test_send_status_approval_dm_missing_sender_id_errors() {
|
||||||
let channel = make_channel();
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"event_type": "direct_message",
|
"event_type": "direct_message",
|
||||||
"channel_id": "C456",
|
"channel_id": "C456",
|
||||||
@@ -663,7 +854,6 @@ mod tests {
|
|||||||
tool_name: "shell".into(),
|
tool_name: "shell".into(),
|
||||||
description: "run command".into(),
|
description: "run command".into(),
|
||||||
parameters: serde_json::json!({}),
|
parameters: serde_json::json!({}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -671,8 +861,8 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
!err.contains("sender_id"),
|
err.contains("sender_id"),
|
||||||
"sender_id should not be required anymore, got: {err}"
|
"expected sender_id error, got: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-91
@@ -1,10 +1,15 @@
|
|||||||
//! HTTP client for the channel-relay service.
|
//! HTTP client for the channel-relay service.
|
||||||
//!
|
//!
|
||||||
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
|
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
|
||||||
//! approvals, signing-secret fetch, and Slack API proxy.
|
//! SSE streaming, token renewal, and Slack API proxy.
|
||||||
|
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
use futures::Stream;
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// Known relay event types.
|
/// Known relay event types.
|
||||||
pub mod event_types {
|
pub mod event_types {
|
||||||
@@ -13,7 +18,7 @@ pub mod event_types {
|
|||||||
pub const MENTION: &str = "mention";
|
pub const MENTION: &str = "mention";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed event from the channel-relay webhook callback.
|
/// A parsed SSE event from the channel-relay stream.
|
||||||
///
|
///
|
||||||
/// Field names match the channel-relay `ChannelEvent` struct exactly.
|
/// Field names match the channel-relay `ChannelEvent` struct exactly.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -118,19 +123,21 @@ impl RelayClient {
|
|||||||
///
|
///
|
||||||
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
|
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
|
||||||
/// returns the `Location` header (Slack OAuth URL) without following it.
|
/// returns the `Location` header (Slack OAuth URL) without following it.
|
||||||
/// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
|
pub async fn initiate_oauth(
|
||||||
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
&self,
|
||||||
/// for validating the callback — no URLs.
|
instance_id: &str,
|
||||||
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
user_id: &str,
|
||||||
let mut query: Vec<(&str, &str)> = vec![];
|
callback_url: &str,
|
||||||
if let Some(nonce) = state_nonce {
|
) -> Result<String, RelayError> {
|
||||||
query.push(("state_nonce", nonce));
|
|
||||||
}
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.get(format!("{}/oauth/slack/auth", self.base_url))
|
.get(format!("{}/oauth/slack/auth", self.base_url))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.header("X-API-Key", self.api_key.expose_secret())
|
||||||
.query(&query)
|
.query(&[
|
||||||
|
("instance_id", instance_id),
|
||||||
|
("user_id", user_id),
|
||||||
|
("callback", callback_url),
|
||||||
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
@@ -166,69 +173,104 @@ impl RelayClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a pending approval and return the opaque approval token.
|
/// Connect to the SSE event stream.
|
||||||
///
|
///
|
||||||
/// Calls `POST /approvals` with the target team/channel/request identifiers.
|
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
|
||||||
/// The returned token is embedded in Slack button values instead of routing fields.
|
/// background SSE parser task. The caller is responsible for reconnection
|
||||||
/// The relay derives the authorized approver from the connection's authed_user_id.
|
/// logic on stream end/error and for aborting the handle on shutdown.
|
||||||
pub async fn create_approval(
|
pub async fn connect_stream(
|
||||||
&self,
|
&self,
|
||||||
team_id: &str,
|
stream_token: &str,
|
||||||
channel_id: &str,
|
stream_timeout_secs: u64,
|
||||||
thread_ts: Option<&str>,
|
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
|
||||||
request_id: &str,
|
|
||||||
) -> Result<String, RelayError> {
|
|
||||||
let mut body = serde_json::json!({
|
|
||||||
"team_id": team_id,
|
|
||||||
"channel_id": channel_id,
|
|
||||||
"request_id": request_id,
|
|
||||||
});
|
|
||||||
if let Some(ts) = thread_ts {
|
|
||||||
body["thread_ts"] = serde_json::Value::String(ts.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.post(format!("{}/approvals", self.base_url))
|
.get(format!("{}/stream", self.base_url))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.query(&[("token", stream_token)])
|
||||||
.json(&body)
|
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
let status = resp.status();
|
||||||
let status = resp.status().as_u16();
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err(RelayError::TokenExpired);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return Err(RelayError::Api {
|
return Err(RelayError::Api {
|
||||||
status,
|
status: status.as_u16(),
|
||||||
message: body,
|
message: body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: serde_json::Value = resp
|
// Spawn a background task that reads the SSE stream and sends parsed events
|
||||||
|
let (tx, rx) = mpsc::channel(64);
|
||||||
|
let byte_stream = resp.bytes_stream();
|
||||||
|
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
|
||||||
|
|
||||||
|
Ok((ChannelEventStream { rx }, handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renew an expired stream token.
|
||||||
|
///
|
||||||
|
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
|
||||||
|
pub async fn renew_token(
|
||||||
|
&self,
|
||||||
|
instance_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<String, RelayError> {
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(format!("{}/stream/renew", self.base_url))
|
||||||
|
.header("X-API-Key", self.api_key.expose_secret())
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(RelayError::Api {
|
||||||
|
status: status.as_u16(),
|
||||||
|
message: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
||||||
|
body.get("stream_token")
|
||||||
result
|
.or_else(|| body.get("token"))
|
||||||
.get("approval_token")
|
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
|
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proxy an API call through channel-relay for any provider.
|
||||||
|
///
|
||||||
|
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
|
||||||
pub async fn proxy_provider(
|
pub async fn proxy_provider(
|
||||||
&self,
|
&self,
|
||||||
provider: &str,
|
provider: &str,
|
||||||
team_id: &str,
|
team_id: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
|
instance_id: Option<&str>,
|
||||||
) -> Result<serde_json::Value, RelayError> {
|
) -> Result<serde_json::Value, RelayError> {
|
||||||
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||||
|
if let Some(iid) = instance_id {
|
||||||
|
query.push(("instance_id", iid));
|
||||||
|
}
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.header("X-API-Key", self.api_key.expose_secret())
|
||||||
.query(&query)
|
.query(&query)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
@@ -249,58 +291,12 @@ impl RelayClient {
|
|||||||
.map_err(|e| RelayError::Protocol(e.to_string()))
|
.map_err(|e| RelayError::Protocol(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the per-instance callback signing secret from channel-relay.
|
|
||||||
///
|
|
||||||
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
|
|
||||||
/// 32-byte secret. Called once at activation time; the result is cached in the
|
|
||||||
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
|
||||||
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
|
||||||
let resp = self
|
|
||||||
.http
|
|
||||||
.get(format!("{}/relay/signing-secret", self.base_url))
|
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
|
||||||
.query(&[("team_id", team_id)])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(RelayError::Api {
|
|
||||||
status,
|
|
||||||
message: body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
|
||||||
|
|
||||||
body.get("signing_secret")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
|
|
||||||
.and_then(|raw| {
|
|
||||||
let decoded = hex::decode(raw).map_err(|e| {
|
|
||||||
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
|
|
||||||
})?;
|
|
||||||
if decoded.len() != 32 {
|
|
||||||
return Err(RelayError::Protocol(format!(
|
|
||||||
"invalid signing_secret length: expected 32 bytes, got {}",
|
|
||||||
decoded.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(decoded)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List active connections for an instance.
|
/// List active connections for an instance.
|
||||||
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
|
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.get(format!("{}/connections", self.base_url))
|
.get(format!("{}/connections", self.base_url))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.header("X-API-Key", self.api_key.expose_secret())
|
||||||
.query(&[("instance_id", instance_id)])
|
.query(&[("instance_id", instance_id)])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -321,6 +317,91 @@ impl RelayClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Async stream of parsed channel events from SSE.
|
||||||
|
pub struct ChannelEventStream {
|
||||||
|
rx: mpsc::Receiver<ChannelEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for ChannelEventStream {
|
||||||
|
type Item = ChannelEvent;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
self.rx.poll_recv(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SSE format from a reqwest bytes stream.
|
||||||
|
///
|
||||||
|
/// SSE format:
|
||||||
|
/// ```text
|
||||||
|
/// event: message
|
||||||
|
/// data: {"key": "value"}
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// Blank line terminates an event.
|
||||||
|
async fn parse_sse_stream(
|
||||||
|
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
|
||||||
|
tx: mpsc::Sender<ChannelEvent>,
|
||||||
|
) {
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
|
let mut buffer = Vec::<u8>::new();
|
||||||
|
let mut event_type = String::new();
|
||||||
|
let mut data_lines = Vec::new();
|
||||||
|
|
||||||
|
let mut byte_stream = std::pin::pin!(byte_stream);
|
||||||
|
while let Some(chunk_result) = byte_stream.next().await {
|
||||||
|
let chunk = match chunk_result {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %e, "SSE stream chunk error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buffer.extend_from_slice(&chunk);
|
||||||
|
|
||||||
|
// Process complete lines (decode UTF-8 only on full lines to avoid
|
||||||
|
// corruption when multi-byte characters span chunk boundaries)
|
||||||
|
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
|
||||||
|
let line = String::from_utf8_lossy(&buffer[..newline_pos])
|
||||||
|
.trim_end_matches('\r')
|
||||||
|
.to_string();
|
||||||
|
buffer.drain(..=newline_pos);
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
// Blank line = end of event
|
||||||
|
if !data_lines.is_empty() {
|
||||||
|
let data = data_lines.join("\n");
|
||||||
|
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
|
||||||
|
if event.event_type.is_empty() && !event_type.is_empty() {
|
||||||
|
event.event_type = event_type.clone();
|
||||||
|
}
|
||||||
|
if tx.send(event).await.is_err() {
|
||||||
|
return; // receiver dropped
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
event_type = %event_type,
|
||||||
|
data_len = data.len(),
|
||||||
|
"Failed to parse SSE event data as ChannelEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event_type.clear();
|
||||||
|
data_lines.clear();
|
||||||
|
} else if let Some(value) = line.strip_prefix("event:") {
|
||||||
|
event_type = value.trim().to_string();
|
||||||
|
} else if let Some(value) = line.strip_prefix("data:") {
|
||||||
|
data_lines.push(value.trim().to_string());
|
||||||
|
}
|
||||||
|
// Ignore other fields (id:, retry:, comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("SSE stream ended");
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors from relay client operations.
|
/// Errors from relay client operations.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RelayError {
|
pub enum RelayError {
|
||||||
@@ -332,6 +413,9 @@ pub enum RelayError {
|
|||||||
|
|
||||||
#[error("Protocol error: {0}")]
|
#[error("Protocol error: {0}")]
|
||||||
Protocol(String),
|
Protocol(String),
|
||||||
|
|
||||||
|
#[error("Stream token expired")]
|
||||||
|
TokenExpired,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -410,6 +494,9 @@ mod tests {
|
|||||||
message: "unauthorized".into(),
|
message: "unauthorized".into(),
|
||||||
};
|
};
|
||||||
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
|
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
|
||||||
|
|
||||||
|
let err = RelayError::TokenExpired;
|
||||||
|
assert_eq!(err.to_string(), "Stream token expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -431,4 +518,32 @@ mod tests {
|
|||||||
assert!(make(event_types::DIRECT_MESSAGE).is_message());
|
assert!(make(event_types::DIRECT_MESSAGE).is_message());
|
||||||
assert!(make(event_types::MENTION).is_message());
|
assert!(make(event_types::MENTION).is_message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
|
||||||
|
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
|
||||||
|
// Split it across two chunks to verify no U+FFFD corruption.
|
||||||
|
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
|
||||||
|
let full = format!("event: message\ndata: {}\n\n", event_json);
|
||||||
|
let bytes = full.as_bytes();
|
||||||
|
|
||||||
|
// Find the crab emoji and split mid-character
|
||||||
|
let crab_pos = bytes
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
|
||||||
|
.expect("crab emoji not found");
|
||||||
|
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
|
||||||
|
|
||||||
|
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
|
||||||
|
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
|
||||||
|
|
||||||
|
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
|
||||||
|
let stream = futures::stream::iter(chunks);
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(8);
|
||||||
|
parse_sse_stream(stream, tx).await;
|
||||||
|
|
||||||
|
let event = rx.recv().await.expect("should receive event");
|
||||||
|
assert_eq!(event.text(), "hello 🦀 world");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
//! Channel-relay integration for connecting to external messaging platforms
|
//! Channel-relay integration for connecting to external messaging platforms
|
||||||
//! (Slack) via the channel-relay service.
|
//! (Slack) via the channel-relay service.
|
||||||
//!
|
//!
|
||||||
//! The relay service handles OAuth, credential storage, and webhook ingestion.
|
//! The relay service handles OAuth, credential storage, webhook ingestion,
|
||||||
//! IronClaw receives events via webhook callbacks and sends messages via the
|
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
|
||||||
//! relay's proxy API.
|
//! messages via the relay's proxy API.
|
||||||
|
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod webhook;
|
|
||||||
|
|
||||||
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
|
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
|
||||||
pub use client::RelayClient;
|
pub use client::RelayClient;
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
//! Shared relay webhook signature verification helpers.
|
|
||||||
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
/// Verify a relay callback HMAC signature.
|
|
||||||
pub fn verify_relay_signature(
|
|
||||||
secret: &[u8],
|
|
||||||
timestamp: &str,
|
|
||||||
body: &[u8],
|
|
||||||
signature: &str,
|
|
||||||
) -> bool {
|
|
||||||
verify_signature(secret, timestamp, body, signature)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
|
|
||||||
let mut mac = match HmacSha256::new_from_slice(secret) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
mac.update(timestamp.as_bytes());
|
|
||||||
mac.update(b".");
|
|
||||||
mac.update(body);
|
|
||||||
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
|
||||||
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
|
|
||||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
|
||||||
mac.update(timestamp.as_bytes());
|
|
||||||
mac.update(b".");
|
|
||||||
mac.update(body);
|
|
||||||
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_valid_signature() {
|
|
||||||
let secret = b"test-secret";
|
|
||||||
let body = b"hello";
|
|
||||||
let ts = "1234567890";
|
|
||||||
let sig = make_signature(secret, ts, body);
|
|
||||||
assert!(verify_signature(secret, ts, body, &sig));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_wrong_secret_fails() {
|
|
||||||
let body = b"hello";
|
|
||||||
let ts = "1234567890";
|
|
||||||
let sig = make_signature(b"correct", ts, body);
|
|
||||||
assert!(!verify_signature(b"wrong", ts, body, &sig));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_tampered_body_fails() {
|
|
||||||
let secret = b"secret";
|
|
||||||
let ts = "1234567890";
|
|
||||||
let sig = make_signature(secret, ts, b"original");
|
|
||||||
assert!(!verify_signature(secret, ts, b"tampered", &sig));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -539,7 +539,6 @@ impl Channel for ReplChannel {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
} => {
|
} => {
|
||||||
let term_width = crossterm::terminal::size()
|
let term_width = crossterm::terminal::size()
|
||||||
.map(|(w, _)| w as usize)
|
.map(|(w, _)| w as usize)
|
||||||
@@ -583,13 +582,9 @@ impl Channel for ReplChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(" \u{2502}");
|
eprintln!(" \u{2502}");
|
||||||
if allow_always {
|
eprintln!(
|
||||||
eprintln!(
|
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
|
|
||||||
}
|
|
||||||
eprintln!(" {bot_border}");
|
eprintln!(" {bot_border}");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-11
@@ -915,28 +915,20 @@ impl Channel for SignalChannel {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description: _,
|
description: _,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
} = &status
|
} = &status
|
||||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||||
{
|
{
|
||||||
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
|
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
|
||||||
let always_line = if *allow_always {
|
|
||||||
format!(
|
|
||||||
"\n• `always` or `a` - Approve and auto-approve future {} requests",
|
|
||||||
tool_name
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let message = format!(
|
let message = format!(
|
||||||
"⚠️ *Approval Required*\n\n\
|
"⚠️ *Approval Required*\n\n\
|
||||||
*Request ID:* `{}`\n\
|
*Request ID:* `{}`\n\
|
||||||
*Tool:* {}\n\
|
*Tool:* {}\n\
|
||||||
*Parameters:*\n```\n{}\n```\n\n\
|
*Parameters:*\n```\n{}\n```\n\n\
|
||||||
Reply with:\n\
|
Reply with:\n\
|
||||||
• `yes` or `y` - Approve this request{}\n\
|
• `yes` or `y` - Approve this request\n\
|
||||||
|
• `always` or `a` - Approve and auto-approve future {} requests\n\
|
||||||
• `no` or `n` - Deny",
|
• `no` or `n` - Deny",
|
||||||
request_id, tool_name, params_json, always_line
|
request_id, tool_name, params_json, tool_name
|
||||||
);
|
);
|
||||||
self.send_status_message(target_str, &message).await;
|
self.send_status_message(target_str, &message).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,16 +492,8 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
tracing::debug!(body = %truncated, "Response body");
|
tracing::debug!(body = %truncated, "Response body");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leak detection on response body (best-effort).
|
// Leak detection on response body (best-effort)
|
||||||
//
|
if let Ok(body_str) = std::str::from_utf8(&body) {
|
||||||
// Telegram `getUpdates` is special: it is inbound polling data, so
|
|
||||||
// user-pasted secrets can legitimately appear in the response body.
|
|
||||||
// Those messages are still checked later by the inbound message
|
|
||||||
// safety layer before they reach the LLM, so we allow the polling
|
|
||||||
// response to continue here to avoid poisoning the offset state.
|
|
||||||
if let Ok(body_str) = std::str::from_utf8(&body)
|
|
||||||
&& !should_skip_response_leak_scan(&url)
|
|
||||||
{
|
|
||||||
leak_detector
|
leak_detector
|
||||||
.scan_and_clean(body_str)
|
.scan_and_clean(body_str)
|
||||||
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
|
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
|
||||||
@@ -2043,7 +2035,6 @@ impl WasmChannel {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// WASM channels (Telegram, Slack, etc.) cannot render
|
// WASM channels (Telegram, Slack, etc.) cannot render
|
||||||
@@ -2082,11 +2073,6 @@ impl WasmChannel {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let reply_hint = if *allow_always {
|
|
||||||
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
|
||||||
} else {
|
|
||||||
"Reply \"yes\" to approve or \"no\" to deny."
|
|
||||||
};
|
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"Approval needed: {tool_name}\n\
|
"Approval needed: {tool_name}\n\
|
||||||
{description}\n\
|
{description}\n\
|
||||||
@@ -2094,7 +2080,7 @@ impl WasmChannel {
|
|||||||
Parameters:\n\
|
Parameters:\n\
|
||||||
{params_preview}\n\
|
{params_preview}\n\
|
||||||
\n\
|
\n\
|
||||||
{reply_hint}"
|
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||||
);
|
);
|
||||||
|
|
||||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||||
@@ -2987,23 +2973,15 @@ fn status_to_wit(
|
|||||||
request_id,
|
request_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
allow_always,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => wit_channel::StatusUpdate {
|
||||||
let reply_hint = if *allow_always {
|
status: wit_channel::StatusType::ApprovalNeeded,
|
||||||
"yes (or /approve), no (or /deny), or always (or /always)"
|
message: format!(
|
||||||
} else {
|
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
|
||||||
"yes (or /approve) or no (or /deny)"
|
tool_name, description, request_id
|
||||||
};
|
),
|
||||||
wit_channel::StatusUpdate {
|
metadata_json,
|
||||||
status: wit_channel::StatusType::ApprovalNeeded,
|
},
|
||||||
message: format!(
|
|
||||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
|
|
||||||
tool_name, description, request_id, reply_hint
|
|
||||||
),
|
|
||||||
metadata_json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StatusUpdate::JobStarted {
|
StatusUpdate::JobStarted {
|
||||||
job_id,
|
job_id,
|
||||||
title,
|
title,
|
||||||
@@ -3144,19 +3122,6 @@ fn extract_host_from_url(url: &str) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_skip_response_leak_scan(url: &str) -> bool {
|
|
||||||
url::Url::parse(url).is_ok_and(|parsed| {
|
|
||||||
matches!(parsed.scheme(), "http" | "https")
|
|
||||||
&& parsed
|
|
||||||
.host_str()
|
|
||||||
.is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
|
|
||||||
&& parsed
|
|
||||||
.path_segments()
|
|
||||||
.and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
|
|
||||||
.is_some_and(|segment| segment == "getUpdates")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-resolve host credentials for all HTTP capability mappings.
|
/// Pre-resolve host credentials for all HTTP capability mappings.
|
||||||
///
|
///
|
||||||
/// Called once per callback (in async context, before spawn_blocking) so the
|
/// Called once per callback (in async context, before spawn_blocking) so the
|
||||||
@@ -3684,7 +3649,6 @@ mod tests {
|
|||||||
tool_name: "http_request".into(),
|
tool_name: "http_request".into(),
|
||||||
description: "Fetch weather".into(),
|
description: "Fetch weather".into(),
|
||||||
parameters: serde_json::json!({"url": "https://wttr.in"}),
|
parameters: serde_json::json!({"url": "https://wttr.in"}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -4146,7 +4110,6 @@ mod tests {
|
|||||||
tool_name: "http_request".to_string(),
|
tool_name: "http_request".to_string(),
|
||||||
description: "Fetch weather data".to_string(),
|
description: "Fetch weather data".to_string(),
|
||||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -4172,7 +4135,6 @@ mod tests {
|
|||||||
tool_name: "http_request".to_string(),
|
tool_name: "http_request".to_string(),
|
||||||
description: "Fetch weather data".to_string(),
|
description: "Fetch weather data".to_string(),
|
||||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||||
allow_always: true,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
@@ -4424,22 +4386,6 @@ mod tests {
|
|||||||
assert_eq!(store.redact_credentials(input), input);
|
assert_eq!(store.redact_credentials(input), input);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
|
|
||||||
use super::should_skip_response_leak_scan;
|
|
||||||
|
|
||||||
assert!(should_skip_response_leak_scan(
|
|
||||||
"https://api.telegram.org/bot123/getUpdates?offset=1"
|
|
||||||
));
|
|
||||||
assert!(!should_skip_response_leak_scan(
|
|
||||||
"https://api.telegram.org/bot123/sendMessage"
|
|
||||||
));
|
|
||||||
assert!(!should_skip_response_leak_scan(
|
|
||||||
"https://api.example.com/getUpdates"
|
|
||||||
));
|
|
||||||
assert!(!should_skip_response_leak_scan("not a url"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that WASM HTTP host functions work using a dedicated
|
/// Verify that WASM HTTP host functions work using a dedicated
|
||||||
/// current-thread runtime inside spawn_blocking.
|
/// current-thread runtime inside spawn_blocking.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ impl GatewayChannel {
|
|||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: server::ActiveConfigSnapshot::default(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -140,7 +139,6 @@ impl GatewayChannel {
|
|||||||
cost_guard: self.state.cost_guard.clone(),
|
cost_guard: self.state.cost_guard.clone(),
|
||||||
routine_engine: Arc::clone(&self.state.routine_engine),
|
routine_engine: Arc::clone(&self.state.routine_engine),
|
||||||
startup_time: self.state.startup_time,
|
startup_time: self.state.startup_time,
|
||||||
active_config: self.state.active_config.clone(),
|
|
||||||
};
|
};
|
||||||
mutate(&mut new_state);
|
mutate(&mut new_state);
|
||||||
self.state = Arc::new(new_state);
|
self.state = Arc::new(new_state);
|
||||||
@@ -252,12 +250,6 @@ impl GatewayChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject the active (resolved) configuration snapshot for the status endpoint.
|
|
||||||
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
|
|
||||||
self.rebuild_state(|s| s.active_config = config);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the auth token (for printing to console on startup).
|
/// Get the auth token (for printing to console on startup).
|
||||||
pub fn auth_token(&self) -> &str {
|
pub fn auth_token(&self) -> &str {
|
||||||
&self.auth_token
|
&self.auth_token
|
||||||
@@ -374,7 +366,6 @@ impl Channel for GatewayChannel {
|
|||||||
tool_name,
|
tool_name,
|
||||||
description,
|
description,
|
||||||
parameters,
|
parameters,
|
||||||
allow_always,
|
|
||||||
} => SseEvent::ApprovalNeeded {
|
} => SseEvent::ApprovalNeeded {
|
||||||
request_id,
|
request_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
@@ -382,7 +373,6 @@ impl Channel for GatewayChannel {
|
|||||||
parameters: serde_json::to_string_pretty(¶meters)
|
parameters: serde_json::to_string_pretty(¶meters)
|
||||||
.unwrap_or_else(|_| parameters.to_string()),
|
.unwrap_or_else(|_| parameters.to_string()),
|
||||||
thread_id,
|
thread_id,
|
||||||
allow_always,
|
|
||||||
},
|
},
|
||||||
StatusUpdate::AuthRequired {
|
StatusUpdate::AuthRequired {
|
||||||
extension_name,
|
extension_name,
|
||||||
|
|||||||
+81
-253
@@ -19,7 +19,6 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use tower_http::cors::{AllowHeaders, CorsLayer};
|
use tower_http::cors::{AllowHeaders, CorsLayer};
|
||||||
@@ -64,16 +63,6 @@ pub type PromptQueue = Arc<
|
|||||||
pub type RoutineEngineSlot =
|
pub type RoutineEngineSlot =
|
||||||
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
|
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
|
||||||
|
|
||||||
fn redact_oauth_state_for_logs(state: &str) -> String {
|
|
||||||
let digest = Sha256::digest(state.as_bytes());
|
|
||||||
let mut short_hash = String::with_capacity(12);
|
|
||||||
for byte in &digest[..6] {
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
let _ = write!(&mut short_hash, "{byte:02x}");
|
|
||||||
}
|
|
||||||
format!("sha256:{short_hash}:len={}", state.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple sliding-window rate limiter.
|
/// Simple sliding-window rate limiter.
|
||||||
///
|
///
|
||||||
/// Tracks the number of requests in the current window. Resets when the window expires.
|
/// Tracks the number of requests in the current window. Resets when the window expires.
|
||||||
@@ -137,14 +126,6 @@ impl RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of the active (resolved) configuration exposed to the frontend.
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
|
||||||
pub struct ActiveConfigSnapshot {
|
|
||||||
pub llm_backend: String,
|
|
||||||
pub llm_model: String,
|
|
||||||
pub enabled_channels: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared state for all gateway handlers.
|
/// Shared state for all gateway handlers.
|
||||||
pub struct GatewayState {
|
pub struct GatewayState {
|
||||||
/// Channel to send messages to the agent loop.
|
/// Channel to send messages to the agent loop.
|
||||||
@@ -196,8 +177,6 @@ pub struct GatewayState {
|
|||||||
pub routine_engine: RoutineEngineSlot,
|
pub routine_engine: RoutineEngineSlot,
|
||||||
/// Server startup time for uptime calculation.
|
/// Server startup time for uptime calculation.
|
||||||
pub startup_time: std::time::Instant,
|
pub startup_time: std::time::Instant,
|
||||||
/// Snapshot of active (resolved) configuration for the frontend.
|
|
||||||
pub active_config: ActiveConfigSnapshot,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the gateway HTTP server.
|
/// Start the gateway HTTP server.
|
||||||
@@ -229,8 +208,7 @@ pub async fn start_server(
|
|||||||
.route(
|
.route(
|
||||||
"/oauth/slack/callback",
|
"/oauth/slack/callback",
|
||||||
get(slack_relay_oauth_callback_handler),
|
get(slack_relay_oauth_callback_handler),
|
||||||
)
|
);
|
||||||
.route("/relay/events", post(relay_events_handler));
|
|
||||||
|
|
||||||
// Protected routes (require auth)
|
// Protected routes (require auth)
|
||||||
let auth_state = AuthState { token: auth_token };
|
let auth_state = AuthState { token: auth_token };
|
||||||
@@ -577,35 +555,22 @@ async fn oauth_callback_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
|
// Strip instance prefix from state for registry lookup.
|
||||||
Ok(decoded) => decoded,
|
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
|
||||||
Err(error) => {
|
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
|
||||||
let redacted_state = redact_oauth_state_for_logs(&state_param);
|
|
||||||
tracing::warn!(
|
|
||||||
state = %redacted_state,
|
|
||||||
error = %error,
|
|
||||||
"OAuth callback received with malformed state"
|
|
||||||
);
|
|
||||||
clear_auth_mode(&state).await;
|
|
||||||
return oauth_error_page("IronClaw");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let lookup_key = decoded_state.flow_id.clone();
|
|
||||||
|
|
||||||
let flow = ext_mgr
|
let flow = ext_mgr
|
||||||
.pending_oauth_flows()
|
.pending_oauth_flows()
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.remove(&lookup_key);
|
.remove(lookup_key);
|
||||||
|
|
||||||
let flow = match flow {
|
let flow = match flow {
|
||||||
Some(f) => f,
|
Some(f) => f,
|
||||||
None => {
|
None => {
|
||||||
let redacted_state = redact_oauth_state_for_logs(&state_param);
|
|
||||||
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
state = %redacted_state,
|
state = %state_param,
|
||||||
lookup_key = %redacted_lookup_key,
|
lookup_key = %lookup_key,
|
||||||
"OAuth callback received with unknown or expired state"
|
"OAuth callback received with unknown or expired state"
|
||||||
);
|
);
|
||||||
clear_auth_mode(&state).await;
|
clear_auth_mode(&state).await;
|
||||||
@@ -632,29 +597,33 @@ async fn oauth_callback_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Exchange the authorization code for tokens.
|
// Exchange the authorization code for tokens.
|
||||||
// Use the platform exchange proxy when configured, otherwise call the
|
// Use the platform exchange proxy when configured (keeps client_secret off container),
|
||||||
// provider's token URL directly.
|
// otherwise call the provider's token URL directly.
|
||||||
let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
|
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
||||||
|
|
||||||
let result: Result<(), String> = async {
|
let result: Result<(), String> = async {
|
||||||
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
|
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
|
||||||
|
{
|
||||||
|
// Use the platform exchange proxy when configured and no resource
|
||||||
|
// parameter is needed. The proxy holds client_secret server-side so
|
||||||
|
// the container never sees it. MCP flows (resource.is_some()) bypass
|
||||||
|
// the proxy because it doesn't forward the RFC 8707 resource param.
|
||||||
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
||||||
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
|
oauth_defaults::exchange_via_proxy(
|
||||||
proxy_url,
|
proxy_url,
|
||||||
gateway_token,
|
gateway_token,
|
||||||
token_url: &flow.token_url,
|
&code,
|
||||||
client_id: &flow.client_id,
|
&flow.redirect_uri,
|
||||||
client_secret: flow.client_secret.as_deref(),
|
flow.code_verifier.as_deref(),
|
||||||
code: &code,
|
&flow.access_token_field,
|
||||||
redirect_uri: &flow.redirect_uri,
|
)
|
||||||
code_verifier: flow.code_verifier.as_deref(),
|
|
||||||
access_token_field: &flow.access_token_field,
|
|
||||||
extra_token_params: &flow.token_exchange_extra_params,
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
} else {
|
} else {
|
||||||
oauth_defaults::exchange_oauth_code_with_params(
|
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
|
||||||
|
// flows can include the RFC 8707 `resource` parameter to scope the
|
||||||
|
// issued token to the specific MCP server.
|
||||||
|
oauth_defaults::exchange_oauth_code_with_resource(
|
||||||
&flow.token_url,
|
&flow.token_url,
|
||||||
&flow.client_id,
|
&flow.client_id,
|
||||||
flow.client_secret.as_deref(),
|
flow.client_secret.as_deref(),
|
||||||
@@ -662,7 +631,7 @@ async fn oauth_callback_handler(
|
|||||||
&flow.redirect_uri,
|
&flow.redirect_uri,
|
||||||
flow.code_verifier.as_deref(),
|
flow.code_verifier.as_deref(),
|
||||||
&flow.access_token_field,
|
&flow.access_token_field,
|
||||||
&flow.token_exchange_extra_params,
|
flow.resource.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -689,8 +658,10 @@ async fn oauth_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Persist the client_id for flows that need it after the session ends
|
// For MCP OAuth flows (identified by resource field), persist the
|
||||||
// (for example DCR-based MCP refresh).
|
// client_id so token refresh works without re-authentication.
|
||||||
|
// The CLI flow stores this in authorize_mcp_server(); the gateway
|
||||||
|
// callback must do the same.
|
||||||
if let Some(ref client_id_secret) = flow.client_id_secret_name {
|
if let Some(ref client_id_secret) = flow.client_id_secret_name {
|
||||||
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
|
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
|
||||||
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
|
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
|
||||||
@@ -771,103 +742,11 @@ async fn oauth_callback_handler(
|
|||||||
axum::response::Html(html).into_response()
|
axum::response::Html(html).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Webhook endpoint for receiving relay events from channel-relay.
|
|
||||||
///
|
|
||||||
/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
|
|
||||||
async fn relay_events_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
headers: axum::http::HeaderMap,
|
|
||||||
body: axum::body::Bytes,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let ext_mgr = match state.extension_manager.as_ref() {
|
|
||||||
Some(mgr) => mgr,
|
|
||||||
None => {
|
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let signing_secret = match ext_mgr.relay_signing_secret() {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify signature
|
|
||||||
let signature = match headers
|
|
||||||
.get("x-relay-signature")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
Some(s) => s.to_string(),
|
|
||||||
None => {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let timestamp = match headers
|
|
||||||
.get("x-relay-timestamp")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
Some(t) => t.to_string(),
|
|
||||||
None => {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check timestamp freshness (5 min window)
|
|
||||||
let ts: i64 = match timestamp.parse() {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(_) => {
|
|
||||||
return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
|
||||||
if (now - ts).abs() > 300 {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify HMAC: sha256(secret, timestamp + "." + body)
|
|
||||||
if !crate::channels::relay::webhook::verify_relay_signature(
|
|
||||||
&signing_secret,
|
|
||||||
×tamp,
|
|
||||||
&body,
|
|
||||||
&signature,
|
|
||||||
) {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse event
|
|
||||||
let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "relay callback invalid JSON");
|
|
||||||
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Push to relay channel
|
|
||||||
let event_tx_guard = ext_mgr.relay_event_tx();
|
|
||||||
let event_tx = event_tx_guard.lock().await;
|
|
||||||
match event_tx.as_ref() {
|
|
||||||
Some(tx) => {
|
|
||||||
if let Err(e) = tx.try_send(event) {
|
|
||||||
tracing::warn!(error = %e, "relay event channel full or closed");
|
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OAuth callback for Slack via channel-relay.
|
/// OAuth callback for Slack via channel-relay.
|
||||||
///
|
///
|
||||||
/// This is a PUBLIC route (no Bearer token required) because channel-relay
|
/// This is a PUBLIC route (no Bearer token required) because channel-relay
|
||||||
/// redirects the user's browser here after Slack OAuth completes.
|
/// redirects the user's browser here after Slack OAuth completes.
|
||||||
/// Query params: `provider`, `team_id`.
|
/// Query params: `stream_token`, `provider`, `team_id`.
|
||||||
async fn slack_relay_oauth_callback_handler(
|
async fn slack_relay_oauth_callback_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
@@ -884,6 +763,27 @@ async fn slack_relay_oauth_callback_handler(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate stream_token: required, non-empty, max 2048 bytes
|
||||||
|
let stream_token = match params.get("stream_token") {
|
||||||
|
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
|
||||||
|
Some(t) if t.len() > 2048 => {
|
||||||
|
return axum::response::Html(
|
||||||
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||||
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return axum::response::Html(
|
||||||
|
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||||
|
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
|
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
|
||||||
let team_id = params.get("team_id").cloned().unwrap_or_default();
|
let team_id = params.get("team_id").cloned().unwrap_or_default();
|
||||||
if !team_id.is_empty() {
|
if !team_id.is_empty() {
|
||||||
@@ -969,16 +869,30 @@ async fn slack_relay_oauth_callback_handler(
|
|||||||
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
|
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
|
||||||
|
|
||||||
let result: Result<(), String> = async {
|
let result: Result<(), String> = async {
|
||||||
let store = state.store.as_ref().ok_or_else(|| {
|
// Store the stream token as a secret
|
||||||
"Relay activation requires persistent settings storage; no-db mode is unsupported."
|
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
|
||||||
.to_string()
|
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
|
||||||
})?;
|
ext_mgr
|
||||||
|
.secrets()
|
||||||
|
.create(
|
||||||
|
&state.user_id,
|
||||||
|
crate::secrets::CreateSecretParams {
|
||||||
|
name: token_key,
|
||||||
|
value: secrecy::SecretString::from(stream_token),
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
expires_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to store stream token: {}", e))?;
|
||||||
|
|
||||||
// Store team_id in settings
|
// Store team_id in settings
|
||||||
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
if let Some(ref store) = state.store {
|
||||||
let _ = store
|
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
||||||
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
|
let _ = store
|
||||||
.await;
|
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
// Activate the relay channel
|
// Activate the relay channel
|
||||||
ext_mgr
|
ext_mgr
|
||||||
@@ -2755,9 +2669,6 @@ async fn gateway_status_handler(
|
|||||||
daily_cost,
|
daily_cost,
|
||||||
actions_this_hour,
|
actions_this_hour,
|
||||||
model_usage,
|
model_usage,
|
||||||
llm_backend: state.active_config.llm_backend.clone(),
|
|
||||||
llm_model: state.active_config.llm_model.clone(),
|
|
||||||
enabled_channels: state.active_config.enabled_channels.clone(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2783,9 +2694,6 @@ struct GatewayStatusResponse {
|
|||||||
actions_this_hour: Option<u64>,
|
actions_this_hour: Option<u64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
model_usage: Option<Vec<ModelUsageEntry>>,
|
model_usage: Option<Vec<ModelUsageEntry>>,
|
||||||
llm_backend: String,
|
|
||||||
llm_model: String,
|
|
||||||
enabled_channels: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -2982,7 +2890,6 @@ mod tests {
|
|||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ActiveConfigSnapshot::default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3329,7 +3236,7 @@ mod tests {
|
|||||||
secrets,
|
secrets,
|
||||||
sse_sender: None,
|
sse_sender: None,
|
||||||
gateway_token: None,
|
gateway_token: None,
|
||||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
resource: None,
|
||||||
client_id_secret_name: None,
|
client_id_secret_name: None,
|
||||||
created_at,
|
created_at,
|
||||||
};
|
};
|
||||||
@@ -3397,7 +3304,7 @@ mod tests {
|
|||||||
secrets,
|
secrets,
|
||||||
sse_sender: Some(sender),
|
sse_sender: Some(sender),
|
||||||
gateway_token: None,
|
gateway_token: None,
|
||||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
resource: None,
|
||||||
client_id_secret_name: None,
|
client_id_secret_name: None,
|
||||||
created_at,
|
created_at,
|
||||||
};
|
};
|
||||||
@@ -3500,7 +3407,7 @@ mod tests {
|
|||||||
secrets,
|
secrets,
|
||||||
sse_sender: None,
|
sse_sender: None,
|
||||||
gateway_token: None,
|
gateway_token: None,
|
||||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
resource: None,
|
||||||
client_id_secret_name: None,
|
client_id_secret_name: None,
|
||||||
// Expired — handler will reject after lookup (no network I/O)
|
// Expired — handler will reject after lookup (no network I/O)
|
||||||
created_at,
|
created_at,
|
||||||
@@ -3552,85 +3459,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_oauth_callback_accepts_versioned_hosted_state() {
|
|
||||||
use axum::body::Body;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
||||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
|
||||||
))
|
|
||||||
.expect("crypto"),
|
|
||||||
)));
|
|
||||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
|
||||||
|
|
||||||
let Some(created_at) = expired_flow_created_at() else {
|
|
||||||
eprintln!("Skipping versioned OAuth state test: monotonic uptime below expiry window");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
|
||||||
extension_name: "test_tool".to_string(),
|
|
||||||
display_name: "Test Tool".to_string(),
|
|
||||||
token_url: "https://example.com/token".to_string(),
|
|
||||||
client_id: "client123".to_string(),
|
|
||||||
client_secret: None,
|
|
||||||
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
|
||||||
code_verifier: None,
|
|
||||||
access_token_field: "access_token".to_string(),
|
|
||||||
secret_name: "test_token".to_string(),
|
|
||||||
provider: None,
|
|
||||||
validation_endpoint: None,
|
|
||||||
scopes: vec![],
|
|
||||||
user_id: "test".to_string(),
|
|
||||||
secrets,
|
|
||||||
sse_sender: None,
|
|
||||||
gateway_token: None,
|
|
||||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
|
||||||
client_id_secret_name: None,
|
|
||||||
created_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
ext_mgr
|
|
||||||
.pending_oauth_flows()
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert("test_nonce".to_string(), flow);
|
|
||||||
|
|
||||||
let state = test_gateway_state(Some(ext_mgr.clone()));
|
|
||||||
let app = test_oauth_router(state);
|
|
||||||
let versioned_state =
|
|
||||||
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
|
|
||||||
|
|
||||||
let req = axum::http::Request::builder()
|
|
||||||
.uri(format!(
|
|
||||||
"/oauth/callback?code=fake_code&state={}",
|
|
||||||
urlencoding::encode(&versioned_state)
|
|
||||||
))
|
|
||||||
.body(Body::empty())
|
|
||||||
.expect("request");
|
|
||||||
|
|
||||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
|
||||||
.await
|
|
||||||
.expect("response");
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
|
||||||
.await
|
|
||||||
.expect("body");
|
|
||||||
let html = String::from_utf8_lossy(&body);
|
|
||||||
assert!(html.contains("Authorization Failed"));
|
|
||||||
assert!(
|
|
||||||
ext_mgr
|
|
||||||
.pending_oauth_flows()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get("test_nonce")
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Slack relay OAuth CSRF tests ---
|
// --- Slack relay OAuth CSRF tests ---
|
||||||
|
|
||||||
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
||||||
@@ -3688,7 +3516,7 @@ mod tests {
|
|||||||
|
|
||||||
// Callback without state param should be rejected
|
// Callback without state param should be rejected
|
||||||
let req = axum::http::Request::builder()
|
let req = axum::http::Request::builder()
|
||||||
.uri("/oauth/slack/callback?team_id=T123&provider=slack")
|
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request");
|
.expect("request");
|
||||||
|
|
||||||
@@ -3732,7 +3560,7 @@ mod tests {
|
|||||||
|
|
||||||
// Callback with wrong state param
|
// Callback with wrong state param
|
||||||
let req = axum::http::Request::builder()
|
let req = axum::http::Request::builder()
|
||||||
.uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
|
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request");
|
.expect("request");
|
||||||
|
|
||||||
@@ -3780,7 +3608,7 @@ mod tests {
|
|||||||
// we just verify it doesn't return a CSRF error.
|
// we just verify it doesn't return a CSRF error.
|
||||||
let req = axum::http::Request::builder()
|
let req = axum::http::Request::builder()
|
||||||
.uri(format!(
|
.uri(format!(
|
||||||
"/oauth/slack/callback?team_id=T123&provider=slack&state={}",
|
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
|
||||||
nonce
|
nonce
|
||||||
))
|
))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
|
|||||||
+101
-903
File diff suppressed because it is too large
Load Diff
@@ -29,15 +29,9 @@ I18n.register('en', {
|
|||||||
'tab.memory': 'Memory',
|
'tab.memory': 'Memory',
|
||||||
'tab.jobs': 'Jobs',
|
'tab.jobs': 'Jobs',
|
||||||
'tab.routines': 'Routines',
|
'tab.routines': 'Routines',
|
||||||
'tab.settings': 'Settings',
|
|
||||||
'tab.extensions': 'Extensions',
|
'tab.extensions': 'Extensions',
|
||||||
'tab.skills': 'Skills',
|
'tab.skills': 'Skills',
|
||||||
'tab.logs': 'Logs',
|
'tab.logs': 'Logs',
|
||||||
'settings.inference': 'Inference',
|
|
||||||
'settings.agent': 'Agent',
|
|
||||||
'settings.channels': 'Channels',
|
|
||||||
'settings.networking': 'Networking',
|
|
||||||
'settings.mcp': 'MCP',
|
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
'status.connected': 'Connected',
|
'status.connected': 'Connected',
|
||||||
@@ -137,10 +131,10 @@ I18n.register('en', {
|
|||||||
|
|
||||||
// Extensions Tab
|
// Extensions Tab
|
||||||
'extensions.installed': 'Installed Extensions',
|
'extensions.installed': 'Installed Extensions',
|
||||||
'extensions.available': 'Available Extensions',
|
'extensions.available': 'Available WASM Extensions',
|
||||||
'extensions.installWasm': 'Install Extension',
|
'extensions.installWasm': 'Install WASM Extension',
|
||||||
'extensions.noInstalled': 'No extensions installed',
|
'extensions.noInstalled': 'No extensions installed',
|
||||||
'extensions.noAvailable': 'No additional extensions available',
|
'extensions.noAvailable': 'No additional WASM extensions available',
|
||||||
'extensions.loading': 'Loading...',
|
'extensions.loading': 'Loading...',
|
||||||
'extensions.install': 'Install',
|
'extensions.install': 'Install',
|
||||||
'extensions.installing': 'Installing...',
|
'extensions.installing': 'Installing...',
|
||||||
@@ -162,8 +156,13 @@ I18n.register('en', {
|
|||||||
'mcp.addCustom': 'Add Custom MCP Server',
|
'mcp.addCustom': 'Add Custom MCP Server',
|
||||||
'mcp.add': 'Add',
|
'mcp.add': 'Add',
|
||||||
'mcp.addedSuccess': 'Added MCP server {name}',
|
'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 Tab
|
||||||
'skills.installed': 'Installed Skills',
|
'skills.installed': 'Installed Skills',
|
||||||
'skills.noInstalled': 'No skills installed',
|
'skills.noInstalled': 'No skills installed',
|
||||||
@@ -303,7 +302,6 @@ I18n.register('en', {
|
|||||||
|
|
||||||
// Common
|
// Common
|
||||||
'common.loading': 'Loading...',
|
'common.loading': 'Loading...',
|
||||||
'common.loadFailed': 'Failed to load',
|
|
||||||
'common.noData': 'No data',
|
'common.noData': 'No data',
|
||||||
'common.search': 'Search',
|
'common.search': 'Search',
|
||||||
'common.add': 'Add',
|
'common.add': 'Add',
|
||||||
@@ -330,8 +328,6 @@ I18n.register('en', {
|
|||||||
|
|
||||||
// Extensions
|
// Extensions
|
||||||
'ext.active': 'Active',
|
'ext.active': 'Active',
|
||||||
'ext.inactive': 'Inactive',
|
|
||||||
'ext.builtin': 'Built-in',
|
|
||||||
'ext.remove': 'Remove',
|
'ext.remove': 'Remove',
|
||||||
'ext.install': 'Install',
|
'ext.install': 'Install',
|
||||||
'ext.installing': 'Installing...',
|
'ext.installing': 'Installing...',
|
||||||
@@ -359,160 +355,4 @@ I18n.register('en', {
|
|||||||
'config.autoGenerate': 'Auto-generated if empty',
|
'config.autoGenerate': 'Auto-generated if empty',
|
||||||
'config.save': 'Save',
|
'config.save': 'Save',
|
||||||
'config.cancel': 'Cancel',
|
'config.cancel': 'Cancel',
|
||||||
|
|
||||||
// Settings toolbar
|
|
||||||
'settings.export': 'Export',
|
|
||||||
'settings.import': 'Import',
|
|
||||||
'settings.searchPlaceholder': 'Search settings...',
|
|
||||||
'settings.exportSuccess': 'Settings exported',
|
|
||||||
'settings.exportFailed': 'Export failed: {message}',
|
|
||||||
'settings.importSuccess': 'Settings imported successfully',
|
|
||||||
'settings.importFailed': 'Import failed: {message}',
|
|
||||||
'settings.restartRequired': 'Restart required for changes to take effect.',
|
|
||||||
'settings.restartNow': 'Restart Now',
|
|
||||||
'settings.noMatchingSettings': 'No settings matching "{query}"',
|
|
||||||
'settings.noSettings': 'No settings found',
|
|
||||||
'settings.saved': 'Saved',
|
|
||||||
'settings.on': 'On',
|
|
||||||
'settings.off': 'Off',
|
|
||||||
'settings.envValue': 'env: {value}',
|
|
||||||
'settings.envDefault': 'env default',
|
|
||||||
'settings.useEnvDefault': 'use env default',
|
|
||||||
|
|
||||||
// Settings groups
|
|
||||||
'cfg.group.llm': 'LLM Provider',
|
|
||||||
'cfg.group.embeddings': 'Embeddings',
|
|
||||||
'cfg.group.agent': 'Agent',
|
|
||||||
'cfg.group.heartbeat': 'Heartbeat',
|
|
||||||
'cfg.group.sandbox': 'Sandbox',
|
|
||||||
'cfg.group.routines': 'Routines',
|
|
||||||
'cfg.group.safety': 'Safety',
|
|
||||||
'cfg.group.skills': 'Skills',
|
|
||||||
'cfg.group.search': 'Search',
|
|
||||||
'cfg.group.tunnel': 'Tunnel',
|
|
||||||
'cfg.group.gateway': 'Gateway',
|
|
||||||
|
|
||||||
// Inference settings
|
|
||||||
'cfg.llm_backend.label': 'Backend',
|
|
||||||
'cfg.llm_backend.desc': 'LLM inference provider',
|
|
||||||
'cfg.selected_model.label': 'Model',
|
|
||||||
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
|
|
||||||
'cfg.ollama_base_url.label': 'Ollama URL',
|
|
||||||
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
|
|
||||||
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
|
|
||||||
'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
|
|
||||||
'cfg.bedrock_region.label': 'Bedrock Region',
|
|
||||||
'cfg.bedrock_region.desc': 'AWS region for Bedrock',
|
|
||||||
'cfg.bedrock_cross_region.label': 'Cross-Region',
|
|
||||||
'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
|
|
||||||
'cfg.bedrock_profile.label': 'AWS Profile',
|
|
||||||
'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
|
|
||||||
'cfg.embeddings_enabled.label': 'Enabled',
|
|
||||||
'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
|
|
||||||
'cfg.embeddings_provider.label': 'Provider',
|
|
||||||
'cfg.embeddings_provider.desc': 'Embeddings API provider',
|
|
||||||
'cfg.embeddings_model.label': 'Model',
|
|
||||||
'cfg.embeddings_model.desc': 'Embedding model name',
|
|
||||||
|
|
||||||
// Agent settings
|
|
||||||
'cfg.agent_name.label': 'Name',
|
|
||||||
'cfg.agent_name.desc': 'Agent display name',
|
|
||||||
'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
|
|
||||||
'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
|
|
||||||
'cfg.agent_job_timeout.label': 'Job Timeout',
|
|
||||||
'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
|
|
||||||
'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
|
|
||||||
'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
|
|
||||||
'cfg.agent_use_planning.label': 'Planning',
|
|
||||||
'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
|
|
||||||
'cfg.agent_auto_approve.label': 'Auto-approve Tools',
|
|
||||||
'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
|
|
||||||
'cfg.agent_timezone.label': 'Timezone',
|
|
||||||
'cfg.agent_timezone.desc': 'Default timezone (IANA)',
|
|
||||||
'cfg.agent_session_idle.label': 'Session Idle Timeout',
|
|
||||||
'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
|
|
||||||
'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
|
|
||||||
'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
|
|
||||||
'cfg.agent_max_repair.label': 'Max Repair Attempts',
|
|
||||||
'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
|
|
||||||
'cfg.agent_max_cost.label': 'Max Daily Cost',
|
|
||||||
'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
|
|
||||||
'cfg.agent_max_actions.label': 'Max Actions/Hour',
|
|
||||||
'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
|
|
||||||
'cfg.agent_allow_local.label': 'Allow Local Tools',
|
|
||||||
'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
|
|
||||||
|
|
||||||
// Heartbeat settings
|
|
||||||
'cfg.heartbeat_enabled.label': 'Enabled',
|
|
||||||
'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
|
|
||||||
'cfg.heartbeat_interval.label': 'Interval',
|
|
||||||
'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
|
|
||||||
'cfg.heartbeat_notify_channel.label': 'Notify Channel',
|
|
||||||
'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
|
|
||||||
'cfg.heartbeat_notify_user.label': 'Notify User',
|
|
||||||
'cfg.heartbeat_notify_user.desc': 'User ID to notify',
|
|
||||||
'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
|
|
||||||
'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
|
|
||||||
'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
|
|
||||||
'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
|
|
||||||
'cfg.heartbeat_timezone.label': 'Timezone',
|
|
||||||
'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
|
|
||||||
|
|
||||||
// Sandbox settings
|
|
||||||
'cfg.sandbox_enabled.label': 'Enabled',
|
|
||||||
'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
|
|
||||||
'cfg.sandbox_policy.label': 'Policy',
|
|
||||||
'cfg.sandbox_policy.desc': 'Sandbox security policy',
|
|
||||||
'cfg.sandbox_timeout.label': 'Timeout',
|
|
||||||
'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
|
|
||||||
'cfg.sandbox_memory.label': 'Memory Limit',
|
|
||||||
'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
|
|
||||||
'cfg.sandbox_image.label': 'Docker Image',
|
|
||||||
'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
|
|
||||||
|
|
||||||
// Routines settings
|
|
||||||
'cfg.routines_max_concurrent.label': 'Max Concurrent',
|
|
||||||
'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
|
|
||||||
'cfg.routines_cooldown.label': 'Default Cooldown',
|
|
||||||
'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
|
|
||||||
|
|
||||||
// Safety settings
|
|
||||||
'cfg.safety_max_output.label': 'Max Output Length',
|
|
||||||
'cfg.safety_max_output.desc': 'Maximum output tokens per response',
|
|
||||||
'cfg.safety_injection_check.label': 'Injection Check',
|
|
||||||
'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
|
|
||||||
|
|
||||||
// Skills settings
|
|
||||||
'cfg.skills_max_active.label': 'Max Active Skills',
|
|
||||||
'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
|
|
||||||
'cfg.skills_max_tokens.label': 'Max Context Tokens',
|
|
||||||
'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
|
|
||||||
|
|
||||||
// Search settings
|
|
||||||
'cfg.search_fusion.label': 'Fusion Strategy',
|
|
||||||
'cfg.search_fusion.desc': 'Hybrid search ranking method',
|
|
||||||
|
|
||||||
// Networking settings
|
|
||||||
'cfg.tunnel_provider.label': 'Provider',
|
|
||||||
'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
|
|
||||||
'cfg.tunnel_public_url.label': 'Public URL',
|
|
||||||
'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
|
|
||||||
'cfg.gateway_rate_limit.label': 'Rate Limit',
|
|
||||||
'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
|
|
||||||
'cfg.gateway_max_connections.label': 'Max Connections',
|
|
||||||
'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
|
|
||||||
|
|
||||||
// Channels subtab
|
|
||||||
'channels.builtin': 'Built-in Channels',
|
|
||||||
'channels.messaging': 'Messaging Channels',
|
|
||||||
'channels.webGateway': 'Web Gateway',
|
|
||||||
'channels.webGatewayDesc': 'Browser-based chat interface',
|
|
||||||
'channels.httpWebhook': 'HTTP Webhook',
|
|
||||||
'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
|
|
||||||
'channels.cli': 'CLI',
|
|
||||||
'channels.cliDesc': 'Terminal UI with Ratatui',
|
|
||||||
'channels.repl': 'REPL',
|
|
||||||
'channels.replDesc': 'Simple read-eval-print loop for testing',
|
|
||||||
'channels.configureVia': 'Configure via {env}',
|
|
||||||
'channels.runWith': 'Run with: {cmd}',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,15 +29,9 @@ I18n.register('zh-CN', {
|
|||||||
'tab.memory': '记忆',
|
'tab.memory': '记忆',
|
||||||
'tab.jobs': '任务',
|
'tab.jobs': '任务',
|
||||||
'tab.routines': '定时任务',
|
'tab.routines': '定时任务',
|
||||||
'tab.settings': '设置',
|
|
||||||
'tab.extensions': '扩展',
|
'tab.extensions': '扩展',
|
||||||
'tab.skills': '技能',
|
'tab.skills': '技能',
|
||||||
'tab.logs': '日志',
|
'tab.logs': '日志',
|
||||||
'settings.inference': '推理',
|
|
||||||
'settings.agent': '代理',
|
|
||||||
'settings.channels': '频道',
|
|
||||||
'settings.networking': '网络',
|
|
||||||
'settings.mcp': 'MCP',
|
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
'status.connected': '已连接',
|
'status.connected': '已连接',
|
||||||
@@ -137,10 +131,10 @@ I18n.register('zh-CN', {
|
|||||||
|
|
||||||
// 扩展标签页
|
// 扩展标签页
|
||||||
'extensions.installed': '已安装扩展',
|
'extensions.installed': '已安装扩展',
|
||||||
'extensions.available': '可用扩展',
|
'extensions.available': '可用 WASM 扩展',
|
||||||
'extensions.installWasm': '安装扩展',
|
'extensions.installWasm': '安装 WASM 扩展',
|
||||||
'extensions.noInstalled': '没有安装扩展',
|
'extensions.noInstalled': '没有安装扩展',
|
||||||
'extensions.noAvailable': '没有其他可用扩展',
|
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
|
||||||
'extensions.loading': '加载中...',
|
'extensions.loading': '加载中...',
|
||||||
'extensions.install': '安装',
|
'extensions.install': '安装',
|
||||||
'extensions.installing': '安装中...',
|
'extensions.installing': '安装中...',
|
||||||
@@ -162,8 +156,13 @@ I18n.register('zh-CN', {
|
|||||||
'mcp.addCustom': '添加自定义 MCP 服务器',
|
'mcp.addCustom': '添加自定义 MCP 服务器',
|
||||||
'mcp.add': '添加',
|
'mcp.add': '添加',
|
||||||
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
|
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
|
||||||
|
|
||||||
|
// 注册工具
|
||||||
|
'tools.registered': '注册工具',
|
||||||
|
'tools.name': '名称',
|
||||||
|
'tools.description': '描述',
|
||||||
|
'tools.empty': '没有注册工具',
|
||||||
|
|
||||||
// 技能标签页
|
// 技能标签页
|
||||||
'skills.installed': '已安装技能',
|
'skills.installed': '已安装技能',
|
||||||
'skills.noInstalled': '没有安装技能',
|
'skills.noInstalled': '没有安装技能',
|
||||||
@@ -303,7 +302,6 @@ I18n.register('zh-CN', {
|
|||||||
|
|
||||||
// 通用
|
// 通用
|
||||||
'common.loading': '加载中...',
|
'common.loading': '加载中...',
|
||||||
'common.loadFailed': '加载失败',
|
|
||||||
'common.noData': '暂无数据',
|
'common.noData': '暂无数据',
|
||||||
'common.search': '搜索',
|
'common.search': '搜索',
|
||||||
'common.add': '添加',
|
'common.add': '添加',
|
||||||
@@ -330,8 +328,6 @@ I18n.register('zh-CN', {
|
|||||||
|
|
||||||
// 扩展
|
// 扩展
|
||||||
'ext.active': '已激活',
|
'ext.active': '已激活',
|
||||||
'ext.inactive': '未激活',
|
|
||||||
'ext.builtin': '内置',
|
|
||||||
'ext.remove': '移除',
|
'ext.remove': '移除',
|
||||||
'ext.install': '安装',
|
'ext.install': '安装',
|
||||||
'ext.installing': '安装中...',
|
'ext.installing': '安装中...',
|
||||||
@@ -358,160 +354,4 @@ I18n.register('zh-CN', {
|
|||||||
'config.autoGenerate': '如果为空则自动生成',
|
'config.autoGenerate': '如果为空则自动生成',
|
||||||
'config.save': '保存',
|
'config.save': '保存',
|
||||||
'config.cancel': '取消',
|
'config.cancel': '取消',
|
||||||
|
|
||||||
// 设置工具栏
|
|
||||||
'settings.export': '导出',
|
|
||||||
'settings.import': '导入',
|
|
||||||
'settings.searchPlaceholder': '搜索设置...',
|
|
||||||
'settings.exportSuccess': '设置已导出',
|
|
||||||
'settings.exportFailed': '导出失败: {message}',
|
|
||||||
'settings.importSuccess': '设置导入成功',
|
|
||||||
'settings.importFailed': '导入失败: {message}',
|
|
||||||
'settings.restartRequired': '需要重启才能使更改生效。',
|
|
||||||
'settings.restartNow': '立即重启',
|
|
||||||
'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
|
|
||||||
'settings.noSettings': '未找到设置',
|
|
||||||
'settings.saved': '已保存',
|
|
||||||
'settings.on': '开启',
|
|
||||||
'settings.off': '关闭',
|
|
||||||
'settings.envValue': '环境变量: {value}',
|
|
||||||
'settings.envDefault': '使用环境变量默认值',
|
|
||||||
'settings.useEnvDefault': '使用环境变量默认值',
|
|
||||||
|
|
||||||
// 设置分组
|
|
||||||
'cfg.group.llm': 'LLM 提供商',
|
|
||||||
'cfg.group.embeddings': '嵌入向量',
|
|
||||||
'cfg.group.agent': '代理',
|
|
||||||
'cfg.group.heartbeat': '心跳',
|
|
||||||
'cfg.group.sandbox': '沙箱',
|
|
||||||
'cfg.group.routines': '定时任务',
|
|
||||||
'cfg.group.safety': '安全',
|
|
||||||
'cfg.group.skills': '技能',
|
|
||||||
'cfg.group.search': '搜索',
|
|
||||||
'cfg.group.tunnel': '隧道',
|
|
||||||
'cfg.group.gateway': '网关',
|
|
||||||
|
|
||||||
// 推理设置
|
|
||||||
'cfg.llm_backend.label': '后端',
|
|
||||||
'cfg.llm_backend.desc': 'LLM 推理提供商',
|
|
||||||
'cfg.selected_model.label': '模型',
|
|
||||||
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
|
|
||||||
'cfg.ollama_base_url.label': 'Ollama URL',
|
|
||||||
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
|
|
||||||
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
|
|
||||||
'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
|
|
||||||
'cfg.bedrock_region.label': 'Bedrock 区域',
|
|
||||||
'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
|
|
||||||
'cfg.bedrock_cross_region.label': '跨区域',
|
|
||||||
'cfg.bedrock_cross_region.desc': '启用跨区域推理',
|
|
||||||
'cfg.bedrock_profile.label': 'AWS 配置文件',
|
|
||||||
'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
|
|
||||||
'cfg.embeddings_enabled.label': '启用',
|
|
||||||
'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
|
|
||||||
'cfg.embeddings_provider.label': '提供商',
|
|
||||||
'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
|
|
||||||
'cfg.embeddings_model.label': '模型',
|
|
||||||
'cfg.embeddings_model.desc': '嵌入向量模型名称',
|
|
||||||
|
|
||||||
// 代理设置
|
|
||||||
'cfg.agent_name.label': '名称',
|
|
||||||
'cfg.agent_name.desc': '代理显示名称',
|
|
||||||
'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
|
|
||||||
'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
|
|
||||||
'cfg.agent_job_timeout.label': '任务超时',
|
|
||||||
'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
|
|
||||||
'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
|
|
||||||
'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
|
|
||||||
'cfg.agent_use_planning.label': '规划',
|
|
||||||
'cfg.agent_use_planning.desc': '执行前启用多步规划',
|
|
||||||
'cfg.agent_auto_approve.label': '自动批准工具',
|
|
||||||
'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
|
|
||||||
'cfg.agent_timezone.label': '时区',
|
|
||||||
'cfg.agent_timezone.desc': '默认时区(IANA)',
|
|
||||||
'cfg.agent_session_idle.label': '会话空闲超时',
|
|
||||||
'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
|
|
||||||
'cfg.agent_stuck_threshold.label': '卡住阈值',
|
|
||||||
'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
|
|
||||||
'cfg.agent_max_repair.label': '最大修复尝试次数',
|
|
||||||
'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
|
|
||||||
'cfg.agent_max_cost.label': '每日最大费用',
|
|
||||||
'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
|
|
||||||
'cfg.agent_max_actions.label': '每小时最大操作数',
|
|
||||||
'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
|
|
||||||
'cfg.agent_allow_local.label': '允许本地工具',
|
|
||||||
'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
|
|
||||||
|
|
||||||
// 心跳设置
|
|
||||||
'cfg.heartbeat_enabled.label': '启用',
|
|
||||||
'cfg.heartbeat_enabled.desc': '运行定期后台检查',
|
|
||||||
'cfg.heartbeat_interval.label': '间隔',
|
|
||||||
'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800)',
|
|
||||||
'cfg.heartbeat_notify_channel.label': '通知频道',
|
|
||||||
'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
|
|
||||||
'cfg.heartbeat_notify_user.label': '通知用户',
|
|
||||||
'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
|
|
||||||
'cfg.heartbeat_quiet_start.label': '静默时段开始',
|
|
||||||
'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23)',
|
|
||||||
'cfg.heartbeat_quiet_end.label': '静默时段结束',
|
|
||||||
'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23)',
|
|
||||||
'cfg.heartbeat_timezone.label': '时区',
|
|
||||||
'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA)',
|
|
||||||
|
|
||||||
// 沙箱设置
|
|
||||||
'cfg.sandbox_enabled.label': '启用',
|
|
||||||
'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
|
|
||||||
'cfg.sandbox_policy.label': '策略',
|
|
||||||
'cfg.sandbox_policy.desc': '沙箱安全策略',
|
|
||||||
'cfg.sandbox_timeout.label': '超时',
|
|
||||||
'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
|
|
||||||
'cfg.sandbox_memory.label': '内存限制',
|
|
||||||
'cfg.sandbox_memory.desc': '容器内存限制(MB)',
|
|
||||||
'cfg.sandbox_image.label': 'Docker 镜像',
|
|
||||||
'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
|
|
||||||
|
|
||||||
// 定时任务设置
|
|
||||||
'cfg.routines_max_concurrent.label': '最大并发数',
|
|
||||||
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
|
|
||||||
'cfg.routines_cooldown.label': '默认冷却时间',
|
|
||||||
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
|
|
||||||
|
|
||||||
// 安全设置
|
|
||||||
'cfg.safety_max_output.label': '最大输出长度',
|
|
||||||
'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
|
|
||||||
'cfg.safety_injection_check.label': '注入检查',
|
|
||||||
'cfg.safety_injection_check.desc': '启用提示注入检测',
|
|
||||||
|
|
||||||
// 技能设置
|
|
||||||
'cfg.skills_max_active.label': '最大活跃技能数',
|
|
||||||
'cfg.skills_max_active.desc': '同时活跃的最大技能数',
|
|
||||||
'cfg.skills_max_tokens.label': '最大上下文令牌数',
|
|
||||||
'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
|
|
||||||
|
|
||||||
// 搜索设置
|
|
||||||
'cfg.search_fusion.label': '融合策略',
|
|
||||||
'cfg.search_fusion.desc': '混合搜索排名方法',
|
|
||||||
|
|
||||||
// 网络设置
|
|
||||||
'cfg.tunnel_provider.label': '提供商',
|
|
||||||
'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
|
|
||||||
'cfg.tunnel_public_url.label': '公网 URL',
|
|
||||||
'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
|
|
||||||
'cfg.gateway_rate_limit.label': '速率限制',
|
|
||||||
'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
|
|
||||||
'cfg.gateway_max_connections.label': '最大连接数',
|
|
||||||
'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
|
|
||||||
|
|
||||||
// 频道子标签
|
|
||||||
'channels.builtin': '内置频道',
|
|
||||||
'channels.messaging': '消息频道',
|
|
||||||
'channels.webGateway': 'Web 网关',
|
|
||||||
'channels.webGatewayDesc': '基于浏览器的聊天界面',
|
|
||||||
'channels.httpWebhook': 'HTTP Webhook',
|
|
||||||
'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
|
|
||||||
'channels.cli': 'CLI',
|
|
||||||
'channels.cliDesc': '使用 Ratatui 的终端 UI',
|
|
||||||
'channels.repl': 'REPL',
|
|
||||||
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
|
|
||||||
'channels.configureVia': '通过 {env} 配置',
|
|
||||||
'channels.runWith': '运行命令: {cmd}',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||||
<button data-tab="settings" data-i18n="tab.settings">Settings</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>
|
<div class="spacer"></div>
|
||||||
|
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
@@ -270,125 +271,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Extensions Tab -->
|
||||||
<div class="tab-panel" id="tab-settings">
|
<div class="tab-panel" id="tab-extensions">
|
||||||
<div class="settings-layout">
|
<div class="extensions-container">
|
||||||
<div class="settings-sidebar">
|
<div class="extensions-section">
|
||||||
<button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
|
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
||||||
<button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
|
<div class="extensions-list" id="extensions-list">
|
||||||
<button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
|
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||||
<button class="settings-subtab" data-settings-subtab="networking" data-i18n="settings.networking">Networking</button>
|
</div>
|
||||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
|
||||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
|
||||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-content">
|
<div class="extensions-section" id="available-wasm-section">
|
||||||
<div class="settings-toolbar">
|
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
|
||||||
<div class="settings-search">
|
<div class="extensions-list" id="available-wasm-list">
|
||||||
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
|
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||||
</div>
|
|
||||||
<button id="settings-export-btn" class="settings-toolbar-btn" data-i18n="settings.export">Export</button>
|
|
||||||
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-subpanel active" id="settings-inference">
|
</div>
|
||||||
<div class="extensions-container" id="settings-inference-content">
|
<div class="extensions-section">
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
|
||||||
</div>
|
<div class="ext-install-form">
|
||||||
|
<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 id="wasm-install-btn" data-i18n="extensions.install">Install</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-subpanel" id="settings-agent">
|
</div>
|
||||||
<div class="extensions-container" id="settings-agent-content">
|
<div class="extensions-section">
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
||||||
</div>
|
<div class="extensions-list" id="mcp-servers-list">
|
||||||
|
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-subpanel" id="settings-channels">
|
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
||||||
<div class="extensions-container" id="settings-channels-content">
|
<div class="ext-install-form">
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading channels...</div>
|
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
||||||
</div>
|
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||||
</div>
|
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
|
||||||
<div class="settings-subpanel" id="settings-networking">
|
|
||||||
<div class="extensions-container" id="settings-networking-content">
|
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-subpanel" id="settings-extensions">
|
|
||||||
<div class="extensions-container">
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
|
||||||
<div class="extensions-list" id="extensions-list">
|
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="extensions-section" id="available-wasm-section">
|
|
||||||
<h3 data-i18n="extensions.available">Available Extensions</h3>
|
|
||||||
<div class="extensions-list" id="available-wasm-list">
|
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="extensions.installWasm">Install Extension</h3>
|
|
||||||
<div class="ext-install-form">
|
|
||||||
<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 id="wasm-install-btn" data-i18n="extensions.install">Install</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-subpanel" id="settings-mcp">
|
|
||||||
<div class="extensions-container">
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
|
||||||
<div class="extensions-list" id="mcp-servers-list">
|
|
||||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
|
||||||
<div class="ext-install-form">
|
|
||||||
<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 id="mcp-add-btn" data-i18n="mcp.add">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-subpanel" id="settings-skills">
|
|
||||||
<div class="extensions-container">
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
|
||||||
<div class="skill-search-box">
|
|
||||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search for skills...">
|
|
||||||
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
|
|
||||||
</div>
|
|
||||||
<div class="extensions-list" id="skill-search-results"></div>
|
|
||||||
</div>
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
|
||||||
<div class="extensions-list" id="skills-list">
|
|
||||||
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="extensions-section">
|
|
||||||
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
|
||||||
<div class="ext-install-form">
|
|
||||||
<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 id="skill-install-btn" data-i18n="extensions.install">Install</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3 data-i18n="tools.registered">Registered Tools</h3>
|
||||||
|
<table class="tools-table" id="tools-table">
|
||||||
|
<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" data-i18n="tools.empty">No tools registered</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation Modal -->
|
<!-- Skills Tab -->
|
||||||
<div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
|
<div class="tab-panel" id="tab-skills">
|
||||||
<div class="modal">
|
<div class="extensions-container">
|
||||||
<h3 id="confirm-modal-title"></h3>
|
<div class="extensions-section">
|
||||||
<p id="confirm-modal-message"></p>
|
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||||
<div class="modal-actions">
|
<div class="skill-search-box">
|
||||||
<button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
|
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
||||||
<button id="confirm-modal-btn" class="btn-danger">Confirm</button>
|
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="extensions-list" id="skill-search-results"></div>
|
||||||
|
</div>
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
||||||
|
<div class="extensions-list" id="skills-list">
|
||||||
|
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extensions-section">
|
||||||
|
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
||||||
|
<div class="ext-install-form">
|
||||||
|
<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 id="skill-install-btn" data-i18n="extensions.install">Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,12 +18,6 @@
|
|||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
|
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||||
--text-muted: #71717a;
|
|
||||||
--bg-hover: rgba(255, 255, 255, 0.03);
|
|
||||||
--danger-soft: rgba(230, 76, 76, 0.15);
|
|
||||||
--warning-soft: rgba(245, 166, 35, 0.15);
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-base: 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -338,10 +332,10 @@ body {
|
|||||||
.restart-loader-content {
|
.restart-loader-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
background-color: var(--bg-secondary);
|
background-color: #1a1a1a;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid #333;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
@@ -358,7 +352,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restart-title {
|
.restart-title {
|
||||||
color: var(--text);
|
color: #e0e0e0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -394,10 +388,10 @@ body {
|
|||||||
.restart-modal-content {
|
.restart-modal-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
background-color: var(--bg-secondary);
|
background-color: #1a1a1a;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid #333;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
@@ -409,11 +403,11 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-header h2 {
|
.restart-modal-header h2 {
|
||||||
color: var(--text);
|
color: #e0e0e0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -432,8 +426,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-close:hover {
|
.restart-modal-close:hover {
|
||||||
color: var(--text-secondary);
|
color: #ccc;
|
||||||
background-color: var(--bg-tertiary);
|
background-color: #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-body {
|
.restart-modal-body {
|
||||||
@@ -441,21 +435,21 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-description {
|
.restart-modal-description {
|
||||||
color: var(--text-secondary);
|
color: #aaa;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-warning {
|
.restart-modal-warning {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
background-color: var(--warning-soft);
|
background-color: #1e1400;
|
||||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
border: 1px solid #3a2a00;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-warning p {
|
.restart-modal-warning p {
|
||||||
color: var(--warning);
|
color: #facc15;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -466,7 +460,7 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-btn {
|
.restart-modal-btn {
|
||||||
@@ -479,28 +473,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-btn.cancel {
|
.restart-modal-btn.cancel {
|
||||||
color: var(--text-secondary);
|
color: #ccc;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-btn.cancel:hover {
|
.restart-modal-btn.cancel:hover {
|
||||||
background-color: var(--bg-tertiary);
|
background-color: #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-btn.confirm {
|
.restart-modal-btn.confirm {
|
||||||
background-color: var(--accent);
|
background-color: #00D894;
|
||||||
color: #09090b;
|
color: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-btn.confirm:hover {
|
.restart-modal-btn.confirm:hover {
|
||||||
background-color: var(--accent-hover);
|
background-color: #00be82;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress Bar for Restart */
|
/* Progress Bar for Restart */
|
||||||
.restart-progress-bar {
|
.restart-progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.375rem;
|
height: 0.375rem;
|
||||||
background-color: var(--bg-tertiary);
|
background-color: #2a2a2a;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -508,7 +502,7 @@ body {
|
|||||||
.restart-progress-fill {
|
.restart-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background-color: var(--accent);
|
background-color: #00D894;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
animation: indeterminate 1.5s ease-in-out infinite;
|
animation: indeterminate 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -529,14 +523,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-info {
|
.restart-modal-info {
|
||||||
color: var(--text-secondary);
|
color: #666;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.restart-modal-info a {
|
.restart-modal-info a {
|
||||||
color: var(--accent);
|
color: #00D894;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2528,21 +2522,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.extensions-section h3 {
|
.extensions-section h3 {
|
||||||
font-size: 11px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.extensions-section h4 {
|
.extensions-section h4 {
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 16px 0 8px;
|
margin: 16px 0 8px;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.extensions-list {
|
.extensions-list {
|
||||||
@@ -2554,29 +2544,12 @@ body {
|
|||||||
.ext-card {
|
.ext-card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid transparent;
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
|
transition: border-color 0.2s, transform 0.2s;
|
||||||
}
|
|
||||||
|
|
||||||
.ext-card.state-active {
|
|
||||||
border-left-color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ext-card.state-inactive {
|
|
||||||
border-left-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ext-card.state-error {
|
|
||||||
border-left-color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ext-card.state-pairing {
|
|
||||||
border-left-color: var(--warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ext-card:hover {
|
.ext-card:hover {
|
||||||
@@ -2619,11 +2592,6 @@ body {
|
|||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ext-kind.kind-builtin {
|
|
||||||
background: rgba(161, 161, 170, 0.15);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ext-version {
|
.ext-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -2799,20 +2767,13 @@ body {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ext:hover {
|
.btn-ext:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ext:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ext.activate {
|
.btn-ext.activate {
|
||||||
@@ -2904,6 +2865,13 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pairing-hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Configure modal */
|
/* Configure modal */
|
||||||
.configure-overlay {
|
.configure-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2912,7 +2880,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2933,7 +2900,7 @@ body {
|
|||||||
.configure-modal h3 {
|
.configure-modal h3 {
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--text);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.configure-hint {
|
.configure-hint {
|
||||||
@@ -3076,6 +3043,31 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tools-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-table th,
|
||||||
|
.tools-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Activity tab (unified sandbox job events) --- */
|
/* --- Activity tab (unified sandbox job events) --- */
|
||||||
|
|
||||||
.activity-terminal {
|
.activity-terminal {
|
||||||
@@ -3729,14 +3721,10 @@ mark {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ext-install-form input {
|
.ext-install-form input {
|
||||||
padding: 8px 12px;
|
padding: 6px 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -3778,10 +3766,6 @@ mark {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-search-box input {
|
.skill-search-box input {
|
||||||
@@ -3818,10 +3802,10 @@ mark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-trust {
|
.skill-trust {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
padding: 3px 8px;
|
padding: 2px 6px;
|
||||||
border-radius: 9999px;
|
border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
@@ -3965,27 +3949,6 @@ mark {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings layout: horizontal subtabs on mobile */
|
|
||||||
.settings-layout { flex-direction: column; }
|
|
||||||
.settings-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.settings-subtab {
|
|
||||||
border-left: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
.settings-subtab.active {
|
|
||||||
border-left-color: transparent;
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extension install form */
|
/* Extension install form */
|
||||||
.ext-install-form {
|
.ext-install-form {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4012,238 +3975,6 @@ mark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Settings Tab Layout --- */
|
|
||||||
.settings-layout {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-sidebar {
|
|
||||||
width: 180px;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 12px 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-subtab {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: left;
|
|
||||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-subtab:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-subtab.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-left-color: var(--accent);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-subpanel {
|
|
||||||
display: none;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-direction: column;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-subpanel.active {
|
|
||||||
display: flex;
|
|
||||||
animation: settingsFadeIn 0.2s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes settingsFadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings form styles (General subtab) */
|
|
||||||
.settings-group {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-group-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin: 0 -12px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
||||||
border-radius: 6px;
|
|
||||||
gap: 16px;
|
|
||||||
max-height: 80px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row.hidden {
|
|
||||||
max-height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row.search-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-row:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.settings-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-input {
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
|
||||||
width: 240px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-saved-indicator {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--success);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(4px);
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-saved-indicator.visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-description {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--warning-soft);
|
|
||||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 8px 16px;
|
|
||||||
animation: settingsFadeIn 0.25s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-banner-text {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-banner-btn {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--warning);
|
|
||||||
color: #09090b;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart-banner-btn:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-label-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-select {
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
|
||||||
width: 240px;
|
|
||||||
max-width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"]:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slash command autocomplete dropdown */
|
/* Slash command autocomplete dropdown */
|
||||||
.slash-autocomplete {
|
.slash-autocomplete {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -4432,211 +4163,3 @@ input[type="checkbox"]:focus-visible {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings toolbar (search + import/export) */
|
|
||||||
.settings-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-search {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-search input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 10px 6px 32px;
|
|
||||||
background: var(--bg);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: 10px center;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-search input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-toolbar-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-toolbar-btn:hover {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-toolbar-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirmation modal */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: modalFadeIn 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalFadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modalSlideIn {
|
|
||||||
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 0;
|
|
||||||
max-width: 420px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
||||||
animation: modalSlideIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h3 {
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal p {
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--danger);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile settings responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.settings-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
max-height: 140px;
|
|
||||||
}
|
|
||||||
.settings-label-wrap {
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
.settings-input, .settings-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.settings-toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.settings-search {
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading skeletons */
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 12px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-bar {
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Settings search empty state */
|
|
||||||
.settings-search-empty {
|
|
||||||
padding: 32px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ impl TestGatewayBuilder {
|
|||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,6 @@ pub enum SseEvent {
|
|||||||
parameters: String,
|
parameters: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
thread_id: Option<String>,
|
thread_id: Option<String>,
|
||||||
/// Whether the "always" auto-approve option should be shown.
|
|
||||||
allow_always: bool,
|
|
||||||
},
|
},
|
||||||
#[serde(rename = "auth_required")]
|
#[serde(rename = "auth_required")]
|
||||||
AuthRequired {
|
AuthRequired {
|
||||||
@@ -232,8 +230,6 @@ pub enum SseEvent {
|
|||||||
status: String,
|
status: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
fallback_deliverable: Option<serde_json::Value>,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/// An image was generated by a tool.
|
/// An image was generated by a tool.
|
||||||
@@ -1084,7 +1080,6 @@ mod tests {
|
|||||||
description: "Run ls".to_string(),
|
description: "Run ls".to_string(),
|
||||||
parameters: "{}".to_string(),
|
parameters: "{}".to_string(),
|
||||||
thread_id: Some("t1".to_string()),
|
thread_id: Some("t1".to_string()),
|
||||||
allow_always: true,
|
|
||||||
};
|
};
|
||||||
let ws = WsServerMessage::from_sse_event(&sse);
|
let ws = WsServerMessage::from_sse_event(&sse);
|
||||||
match ws {
|
match ws {
|
||||||
|
|||||||
@@ -521,7 +521,6 @@ mod tests {
|
|||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-59
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
check(
|
check(
|
||||||
"NEAR AI session",
|
"NEAR AI session",
|
||||||
check_nearai_session(&settings).await,
|
check_nearai_session().await,
|
||||||
&mut passed,
|
&mut passed,
|
||||||
&mut failed,
|
&mut failed,
|
||||||
&mut skipped,
|
&mut skipped,
|
||||||
@@ -215,22 +215,7 @@ fn check_settings_file() -> CheckResult {
|
|||||||
|
|
||||||
// ── NEAR AI session ─────────────────────────────────────────
|
// ── NEAR AI session ─────────────────────────────────────────
|
||||||
|
|
||||||
async fn check_nearai_session(settings: &Settings) -> CheckResult {
|
async fn check_nearai_session() -> CheckResult {
|
||||||
// Skip entirely when the configured backend is not NEAR AI.
|
|
||||||
let llm_config = match crate::config::LlmConfig::resolve(settings) {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(e) => {
|
|
||||||
// check_llm_config will report the full error; just skip here.
|
|
||||||
return CheckResult::Skip(format!("LLM config error: {e}"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if llm_config.backend != "nearai" {
|
|
||||||
return CheckResult::Skip(format!(
|
|
||||||
"not using NEAR AI backend (backend={})",
|
|
||||||
llm_config.backend
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if session file exists
|
// Check if session file exists
|
||||||
let session_path = crate::config::llm::default_session_path();
|
let session_path = crate::config::llm::default_session_path();
|
||||||
if !session_path.exists() {
|
if !session_path.exists() {
|
||||||
@@ -635,53 +620,12 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn check_nearai_session_does_not_panic() {
|
async fn check_nearai_session_does_not_panic() {
|
||||||
let settings = Settings::default();
|
let result = check_nearai_session().await;
|
||||||
let result = check_nearai_session(&settings).await;
|
|
||||||
match result {
|
match result {
|
||||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn check_nearai_session_skips_for_non_nearai_backend() {
|
|
||||||
struct EnvGuard(&'static str, Option<String>);
|
|
||||||
impl Drop for EnvGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
|
||||||
unsafe {
|
|
||||||
match &self.1 {
|
|
||||||
Some(val) => std::env::set_var(self.0, val),
|
|
||||||
None => std::env::remove_var(self.0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
|
||||||
let prev = std::env::var("LLM_BACKEND").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("LLM_BACKEND", "anthropic");
|
|
||||||
}
|
|
||||||
let _env_guard = EnvGuard("LLM_BACKEND", prev);
|
|
||||||
|
|
||||||
let settings = Settings::default();
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
|
||||||
let result = rt.block_on(check_nearai_session(&settings));
|
|
||||||
match result {
|
|
||||||
CheckResult::Skip(msg) => {
|
|
||||||
assert!(
|
|
||||||
msg.contains("backend=anthropic"),
|
|
||||||
"expected backend name in skip message, got: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!(
|
|
||||||
"expected Skip for non-nearai backend, got: {}",
|
|
||||||
format_result(&other)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_settings_file_handles_missing() {
|
fn check_settings_file_handles_missing() {
|
||||||
// Settings::default_path() might or might not exist, but must not panic
|
// Settings::default_path() might or might not exist, but must not panic
|
||||||
|
|||||||
+3
-5
@@ -7,18 +7,17 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
|
|
||||||
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
|
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
|
||||||
|
|
||||||
/// Run a memory command using the Database trait (works with any backend).
|
/// Run a memory command using the Database trait (works with any backend).
|
||||||
pub async fn run_memory_command_with_db(
|
pub async fn run_memory_command_with_db(
|
||||||
cmd: MemoryCommand,
|
cmd: MemoryCommand,
|
||||||
db: std::sync::Arc<dyn crate::db::Database>,
|
db: std::sync::Arc<dyn crate::db::Database>,
|
||||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||||
cache_config: EmbeddingCacheConfig,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut workspace = Workspace::new_with_db("default", db);
|
let mut workspace = Workspace::new_with_db("default", db);
|
||||||
if let Some(emb) = embeddings {
|
if let Some(emb) = embeddings {
|
||||||
workspace = workspace.with_embeddings_cached(emb, cache_config);
|
workspace = workspace.with_embeddings(emb);
|
||||||
}
|
}
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -86,11 +85,10 @@ pub async fn run_memory_command(
|
|||||||
cmd: MemoryCommand,
|
cmd: MemoryCommand,
|
||||||
pool: deadpool_postgres::Pool,
|
pool: deadpool_postgres::Pool,
|
||||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||||
cache_config: EmbeddingCacheConfig,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let mut workspace = Workspace::new("default", pool);
|
let mut workspace = Workspace::new("default", pool);
|
||||||
if let Some(emb) = embeddings {
|
if let Some(emb) = embeddings {
|
||||||
workspace = workspace.with_embeddings_cached(emb, cache_config);
|
workspace = workspace.with_embeddings(emb);
|
||||||
}
|
}
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
|
|||||||
+1
-4
@@ -336,10 +336,7 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
let cache_config = crate::workspace::EmbeddingCacheConfig {
|
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
|
||||||
max_entries: config.embeddings.cache_size,
|
|
||||||
};
|
|
||||||
run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+73
-263
@@ -5,10 +5,17 @@
|
|||||||
//!
|
//!
|
||||||
//! # Built-in Credentials
|
//! # Built-in Credentials
|
||||||
//!
|
//!
|
||||||
//! Some providers ship with built-in OAuth credentials so users don't need to
|
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
|
||||||
//! register their own OAuth app just to get started. Today this module only
|
//! so users don't need to register their own OAuth app. Google explicitly
|
||||||
//! includes built-in defaults for Google-family tools, and those defaults can
|
//! documents that client_secret for "Desktop App" / "Installed App" types
|
||||||
//! be overridden by provider-specific environment variables when needed.
|
//! is NOT actually secret.
|
||||||
|
//!
|
||||||
|
//! Default credentials are hardcoded below. They can be overridden at:
|
||||||
|
//!
|
||||||
|
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
|
||||||
|
//! env vars before building to replace the hardcoded defaults.
|
||||||
|
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
|
//! env vars, which take priority over built-in defaults.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,7 +23,6 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -54,14 +60,6 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the compile-time override env var name, if this provider supports one.
|
|
||||||
pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
|
|
||||||
match secret_name {
|
|
||||||
"google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared callback server ──────────────────────────────────────────────
|
// ── Shared callback server ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
|
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
|
||||||
@@ -175,8 +173,9 @@ pub async fn exchange_oauth_code(
|
|||||||
code_verifier: Option<&str>,
|
code_verifier: Option<&str>,
|
||||||
access_token_field: &str,
|
access_token_field: &str,
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
let extra_token_params = HashMap::new();
|
// Delegates to exchange_oauth_code_with_resource with resource=None.
|
||||||
exchange_oauth_code_with_params(
|
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
|
||||||
|
exchange_oauth_code_with_resource(
|
||||||
token_url,
|
token_url,
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret,
|
||||||
@@ -184,14 +183,16 @@ pub async fn exchange_oauth_code(
|
|||||||
redirect_uri,
|
redirect_uri,
|
||||||
code_verifier,
|
code_verifier,
|
||||||
access_token_field,
|
access_token_field,
|
||||||
&extra_token_params,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exchange an OAuth authorization code for tokens with generic extra form parameters.
|
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
|
||||||
|
///
|
||||||
|
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn exchange_oauth_code_with_params(
|
pub async fn exchange_oauth_code_with_resource(
|
||||||
token_url: &str,
|
token_url: &str,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
client_secret: Option<&str>,
|
client_secret: Option<&str>,
|
||||||
@@ -199,7 +200,7 @@ pub async fn exchange_oauth_code_with_params(
|
|||||||
redirect_uri: &str,
|
redirect_uri: &str,
|
||||||
code_verifier: Option<&str>,
|
code_verifier: Option<&str>,
|
||||||
access_token_field: &str,
|
access_token_field: &str,
|
||||||
extra_token_params: &HashMap<String, String>,
|
resource: Option<&str>,
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut token_params = vec![
|
let mut token_params = vec![
|
||||||
@@ -212,8 +213,10 @@ pub async fn exchange_oauth_code_with_params(
|
|||||||
token_params.push(("code_verifier", verifier.to_string()));
|
token_params.push(("code_verifier", verifier.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (key, value) in extra_token_params {
|
// RFC 8707: include the `resource` parameter so the authorization server
|
||||||
token_params.push((key.as_str(), value.clone()));
|
// scopes the issued token to the specific MCP server (protected resource).
|
||||||
|
if let Some(resource) = resource {
|
||||||
|
token_params.push(("resource", resource.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut request = client.post(token_url);
|
let mut request = client.post(token_url);
|
||||||
@@ -273,37 +276,6 @@ pub async fn exchange_oauth_code_with_params(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
|
|
||||||
///
|
|
||||||
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn exchange_oauth_code_with_resource(
|
|
||||||
token_url: &str,
|
|
||||||
client_id: &str,
|
|
||||||
client_secret: Option<&str>,
|
|
||||||
code: &str,
|
|
||||||
redirect_uri: &str,
|
|
||||||
code_verifier: Option<&str>,
|
|
||||||
access_token_field: &str,
|
|
||||||
resource: Option<&str>,
|
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
|
||||||
let mut extra_token_params = HashMap::new();
|
|
||||||
if let Some(resource) = resource {
|
|
||||||
extra_token_params.insert("resource".to_string(), resource.to_string());
|
|
||||||
}
|
|
||||||
exchange_oauth_code_with_params(
|
|
||||||
token_url,
|
|
||||||
client_id,
|
|
||||||
client_secret,
|
|
||||||
code,
|
|
||||||
redirect_uri,
|
|
||||||
code_verifier,
|
|
||||||
access_token_field,
|
|
||||||
&extra_token_params,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store OAuth tokens (access + refresh) in the secrets store.
|
/// Store OAuth tokens (access + refresh) in the secrets store.
|
||||||
///
|
///
|
||||||
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
|
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
|
||||||
@@ -451,9 +423,9 @@ pub struct PendingOAuthFlow {
|
|||||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Additional form params for the token exchange request.
|
/// RFC 8707 resource parameter (MCP OAuth only).
|
||||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
/// Sent during token exchange to scope the token to a specific MCP server.
|
||||||
pub token_exchange_extra_params: HashMap<String, String>,
|
pub resource: Option<String>,
|
||||||
/// Secret name for persisting the client ID (MCP OAuth only).
|
/// Secret name for persisting the client ID (MCP OAuth only).
|
||||||
/// Needed so token refresh can find the client_id after the session ends.
|
/// Needed so token refresh can find the client_id after the session ends.
|
||||||
pub client_id_secret_name: Option<String>,
|
pub client_id_secret_name: Option<String>,
|
||||||
@@ -487,7 +459,9 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
|
|||||||
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
|
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
|
||||||
/// localhost.
|
/// localhost.
|
||||||
pub fn use_gateway_callback() -> bool {
|
pub fn use_gateway_callback() -> bool {
|
||||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
|
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
.map(|raw| {
|
.map(|raw| {
|
||||||
url::Url::parse(&raw)
|
url::Url::parse(&raw)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -498,13 +472,6 @@ pub fn use_gateway_callback() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the configured OAuth token-exchange proxy URL, if any.
|
|
||||||
pub fn exchange_proxy_url() -> Option<String> {
|
|
||||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
|
|
||||||
.map(|url| url.trim().to_string())
|
|
||||||
.filter(|url| !url.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
@@ -519,117 +486,23 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
|
|||||||
|
|
||||||
// ── Platform routing helpers ────────────────────────────────────────
|
// ── Platform routing helpers ────────────────────────────────────────
|
||||||
|
|
||||||
const HOSTED_STATE_PREFIX: &str = "ic2";
|
/// Prepend instance name to CSRF state for platform routing.
|
||||||
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct DecodedHostedOAuthState {
|
|
||||||
pub flow_id: String,
|
|
||||||
pub instance_name: Option<String>,
|
|
||||||
pub is_legacy: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct HostedOAuthStatePayload {
|
|
||||||
flow_id: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
instance_name: Option<String>,
|
|
||||||
issued_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_instance_name() -> Option<String> {
|
|
||||||
crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
|
|
||||||
.or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
|
|
||||||
let digest = Sha256::digest(payload_bytes);
|
|
||||||
URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a versioned hosted OAuth state envelope.
|
|
||||||
///
|
///
|
||||||
/// The encoded value is opaque to providers and can be decoded by both
|
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
|
||||||
/// IronClaw and the external auth proxy for routing and callback lookup.
|
/// from the `state` query parameter (format: `instance:nonce`) to route the
|
||||||
pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
|
/// OAuth callback to the correct container.
|
||||||
let payload = HostedOAuthStatePayload {
|
|
||||||
flow_id: flow_id.to_string(),
|
|
||||||
instance_name: instance_name
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(str::to_string),
|
|
||||||
issued_at: std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs(),
|
|
||||||
};
|
|
||||||
let payload_json = match serde_json::to_vec(&payload) {
|
|
||||||
Ok(payload_json) => payload_json,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
|
|
||||||
return payload.flow_id;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let payload = URL_SAFE_NO_PAD.encode(&payload_json);
|
|
||||||
let checksum = hosted_state_checksum(&payload_json);
|
|
||||||
format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode hosted OAuth state in either the new versioned format or the
|
|
||||||
/// legacy `instance:nonce`/`nonce` forms.
|
|
||||||
pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState, String> {
|
|
||||||
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
|
|
||||||
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
|
|
||||||
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
|
|
||||||
{
|
|
||||||
let expected_checksum = hosted_state_checksum(&payload_json);
|
|
||||||
if checksum != expected_checksum {
|
|
||||||
return Err("Hosted OAuth state checksum mismatch".to_string());
|
|
||||||
}
|
|
||||||
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
|
|
||||||
&& !payload.flow_id.trim().is_empty()
|
|
||||||
{
|
|
||||||
return Ok(DecodedHostedOAuthState {
|
|
||||||
flow_id: payload.flow_id,
|
|
||||||
instance_name: payload.instance_name.filter(|v| !v.is_empty()),
|
|
||||||
is_legacy: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((instance_name, flow_id)) = state.split_once(':') {
|
|
||||||
if flow_id.is_empty() {
|
|
||||||
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
|
|
||||||
}
|
|
||||||
return Ok(DecodedHostedOAuthState {
|
|
||||||
flow_id: flow_id.to_string(),
|
|
||||||
instance_name: if instance_name.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(instance_name.to_string())
|
|
||||||
},
|
|
||||||
is_legacy: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.is_empty() {
|
|
||||||
return Err("Hosted OAuth state is empty".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(DecodedHostedOAuthState {
|
|
||||||
flow_id: state.to_string(),
|
|
||||||
instance_name: None,
|
|
||||||
is_legacy: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the hosted callback state used by the public OAuth callback endpoint.
|
|
||||||
///
|
///
|
||||||
/// New flows emit a versioned opaque envelope, while callback decoding accepts
|
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
|
||||||
/// both the envelope and the legacy `instance:nonce` contract.
|
/// (local/non-platform mode).
|
||||||
pub fn build_platform_state(nonce: &str) -> String {
|
pub fn build_platform_state(nonce: &str) -> String {
|
||||||
encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
|
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
|
||||||
|
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty());
|
||||||
|
match instance {
|
||||||
|
Some(name) => format!("{}:{}", name, nonce),
|
||||||
|
None => nonce.to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
|
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
|
||||||
@@ -644,62 +517,43 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
|||||||
.unwrap_or(state)
|
.unwrap_or(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ProxyTokenExchangeRequest<'a> {
|
|
||||||
pub proxy_url: &'a str,
|
|
||||||
pub gateway_token: &'a str,
|
|
||||||
pub token_url: &'a str,
|
|
||||||
pub client_id: &'a str,
|
|
||||||
pub client_secret: Option<&'a str>,
|
|
||||||
pub code: &'a str,
|
|
||||||
pub redirect_uri: &'a str,
|
|
||||||
pub code_verifier: Option<&'a str>,
|
|
||||||
pub access_token_field: &'a str,
|
|
||||||
pub extra_token_params: &'a HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||||
///
|
///
|
||||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
/// The proxy holds `client_secret` server-side so the container never sees it.
|
||||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
/// Authenticated via the gateway auth token (Bearer header).
|
||||||
/// the provider requires it.
|
|
||||||
///
|
///
|
||||||
/// The proxy expects standard OAuth form params plus optional provider-specific
|
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
|
||||||
/// token params and returns a standard token response such as
|
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
|
||||||
/// `{access_token, refresh_token, expires_in}`.
|
|
||||||
pub async fn exchange_via_proxy(
|
pub async fn exchange_via_proxy(
|
||||||
request: ProxyTokenExchangeRequest<'_>,
|
proxy_url: &str,
|
||||||
|
gateway_token: &str,
|
||||||
|
code: &str,
|
||||||
|
redirect_uri: &str,
|
||||||
|
code_verifier: Option<&str>,
|
||||||
|
access_token_field: &str,
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
if request.gateway_token.is_empty() {
|
if gateway_token.is_empty() {
|
||||||
return Err(OAuthCallbackError::Io(
|
return Err(OAuthCallbackError::Io(
|
||||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
"Gateway auth token is required for proxy token exchange".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(60))
|
.timeout(Duration::from_secs(60))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
("code", request.code.to_string()),
|
("code", code.to_string()),
|
||||||
("redirect_uri", request.redirect_uri.to_string()),
|
("redirect_uri", redirect_uri.to_string()),
|
||||||
("token_url", request.token_url.to_string()),
|
|
||||||
("client_id", request.client_id.to_string()),
|
|
||||||
("access_token_field", request.access_token_field.to_string()),
|
|
||||||
];
|
];
|
||||||
if let Some(verifier) = request.code_verifier {
|
if let Some(verifier) = code_verifier {
|
||||||
params.push(("code_verifier", verifier.to_string()));
|
params.push(("code_verifier", verifier.to_string()));
|
||||||
}
|
}
|
||||||
if let Some(secret) = request.client_secret {
|
|
||||||
params.push(("client_secret", secret.to_string()));
|
|
||||||
}
|
|
||||||
for (key, value) in request.extra_token_params {
|
|
||||||
params.push((key.as_str(), value.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(&exchange_url)
|
.post(&exchange_url)
|
||||||
.bearer_auth(request.gateway_token)
|
.bearer_auth(gateway_token)
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -722,7 +576,7 @@ pub async fn exchange_via_proxy(
|
|||||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
||||||
|
|
||||||
let access_token = token_data
|
let access_token = token_data
|
||||||
.get(request.access_token_field)
|
.get(access_token_field)
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
let fields: Vec<&str> = token_data
|
let fields: Vec<&str> = token_data
|
||||||
@@ -731,7 +585,7 @@ pub async fn exchange_via_proxy(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
OAuthCallbackError::Io(format!(
|
OAuthCallbackError::Io(format!(
|
||||||
"No '{}' field in proxy response (fields present: {:?})",
|
"No '{}' field in proxy response (fields present: {:?})",
|
||||||
request.access_token_field, fields
|
access_token_field, fields
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -751,10 +605,14 @@ pub async fn exchange_via_proxy(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::cli::oauth_defaults::{
|
use crate::cli::oauth_defaults::{
|
||||||
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
||||||
};
|
};
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
|
||||||
|
/// Serializes env-mutating tests to prevent parallel races.
|
||||||
|
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_loopback_host() {
|
fn test_is_loopback_host() {
|
||||||
@@ -1077,7 +935,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_platform_state_with_instance() {
|
fn test_build_platform_state_with_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::build_platform_state;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
@@ -1085,11 +943,7 @@ mod tests {
|
|||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
|
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
|
||||||
}
|
}
|
||||||
let encoded = build_platform_state("abc123");
|
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
|
||||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
|
||||||
assert_eq!(decoded.flow_id, "abc123");
|
|
||||||
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
|
|
||||||
assert!(!decoded.is_legacy);
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(val) = original {
|
if let Some(val) = original {
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||||
@@ -1101,7 +955,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_platform_state_without_instance() {
|
fn test_build_platform_state_without_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::build_platform_state;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
@@ -1111,11 +965,7 @@ mod tests {
|
|||||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
||||||
}
|
}
|
||||||
let encoded = build_platform_state("abc123");
|
assert_eq!(build_platform_state("abc123"), "abc123");
|
||||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
|
||||||
assert_eq!(decoded.flow_id, "abc123");
|
|
||||||
assert_eq!(decoded.instance_name, None);
|
|
||||||
assert!(!decoded.is_legacy);
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(val) = original {
|
if let Some(val) = original {
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||||
@@ -1128,7 +978,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_platform_state_with_openclaw_instance() {
|
fn test_build_platform_state_with_openclaw_instance() {
|
||||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
use crate::cli::oauth_defaults::build_platform_state;
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||||
@@ -1138,11 +988,7 @@ mod tests {
|
|||||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
|
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
|
||||||
}
|
}
|
||||||
let encoded = build_platform_state("xyz789");
|
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
|
||||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
|
||||||
assert_eq!(decoded.flow_id, "xyz789");
|
|
||||||
assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
|
|
||||||
assert!(!decoded.is_legacy);
|
|
||||||
unsafe {
|
unsafe {
|
||||||
if let Some(val) = original_ic {
|
if let Some(val) = original_ic {
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||||
@@ -1171,42 +1017,6 @@ mod tests {
|
|||||||
assert_eq!(strip_instance_prefix(""), "");
|
assert_eq!(strip_instance_prefix(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
|
|
||||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
|
||||||
|
|
||||||
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
|
|
||||||
assert_eq!(decoded.flow_id, "abc123");
|
|
||||||
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
|
|
||||||
assert!(decoded.is_legacy);
|
|
||||||
|
|
||||||
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
|
|
||||||
assert_eq!(decoded.flow_id, "abc123");
|
|
||||||
assert_eq!(decoded.instance_name, None);
|
|
||||||
assert!(decoded.is_legacy);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decode_hosted_oauth_state_falls_back_for_non_envelope_ic2_prefix() {
|
|
||||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
|
||||||
|
|
||||||
let decoded =
|
|
||||||
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
|
|
||||||
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
|
|
||||||
assert_eq!(decoded.instance_name, None);
|
|
||||||
assert!(decoded.is_legacy);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
|
|
||||||
use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
|
|
||||||
|
|
||||||
let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
|
|
||||||
let tampered = format!("{encoded}broken");
|
|
||||||
let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
|
|
||||||
assert!(err.contains("checksum"), "unexpected error: {err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
|
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
|
||||||
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
|
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
|
||||||
/// scopes tokens to a specific MCP server.
|
/// scopes tokens to a specific MCP server.
|
||||||
|
|||||||
+7
-14
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
|
|
||||||
// Check for OAuth configuration
|
// Check for OAuth configuration
|
||||||
if let Some(ref oauth) = auth.oauth {
|
if let Some(ref oauth) = auth.oauth {
|
||||||
// For providers with shared tokens, combine scopes from all installed
|
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
|
||||||
// tools so one auth covers everything.
|
// combine scopes from all installed tools so one auth covers everything.
|
||||||
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
|
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
|
||||||
if combined.scopes.len() > oauth.scopes.len() {
|
if combined.scopes.len() > oauth.scopes.len() {
|
||||||
let extra = combined.scopes.len() - oauth.scopes.len();
|
let extra = combined.scopes.len() - oauth.scopes.len();
|
||||||
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan the tools directory for all capabilities files sharing the same secret_name
|
/// Scan the tools directory for all capabilities files sharing the same secret_name
|
||||||
/// and combine their OAuth scopes so one authorization covers the full shared
|
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
|
||||||
/// credential set.
|
/// for ALL installed Google tools, so one login covers everything.
|
||||||
async fn combine_provider_scopes(
|
async fn combine_provider_scopes(
|
||||||
tools_dir: &Path,
|
tools_dir: &Path,
|
||||||
secret_name: &str,
|
secret_name: &str,
|
||||||
@@ -736,18 +736,11 @@ async fn auth_tool_oauth(
|
|||||||
})
|
})
|
||||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
let mut message = format!(
|
anyhow::anyhow!(
|
||||||
"OAuth client_id not configured.\n\
|
"OAuth client_id not configured.\n\
|
||||||
Set {} env var",
|
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
|
||||||
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
||||||
);
|
)
|
||||||
if let Some(override_env) =
|
|
||||||
oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
|
|
||||||
{
|
|
||||||
message.push_str(&format!(", or build with {override_env}"));
|
|
||||||
}
|
|
||||||
message.push('.');
|
|
||||||
anyhow::anyhow!(message)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ use crate::llm::SessionManager;
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::workspace::EmbeddingProvider;
|
use crate::workspace::EmbeddingProvider;
|
||||||
|
|
||||||
/// Default maximum number of cached embeddings.
|
|
||||||
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
|
|
||||||
|
|
||||||
/// Embeddings provider configuration.
|
/// Embeddings provider configuration.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EmbeddingsConfig {
|
pub struct EmbeddingsConfig {
|
||||||
@@ -29,12 +26,6 @@ pub struct EmbeddingsConfig {
|
|||||||
/// Custom base URL for OpenAI-compatible embedding providers.
|
/// Custom base URL for OpenAI-compatible embedding providers.
|
||||||
/// When set, overrides the default `https://api.openai.com`.
|
/// When set, overrides the default `https://api.openai.com`.
|
||||||
pub openai_base_url: Option<String>,
|
pub openai_base_url: Option<String>,
|
||||||
/// Maximum entries in the embedding LRU cache (default 10,000).
|
|
||||||
///
|
|
||||||
/// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
|
|
||||||
/// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
|
|
||||||
/// due to HashMap buckets, per-entry Vec/timestamp overhead).
|
|
||||||
pub cache_size: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EmbeddingsConfig {
|
impl Default for EmbeddingsConfig {
|
||||||
@@ -49,7 +40,6 @@ impl Default for EmbeddingsConfig {
|
|||||||
ollama_base_url: "http://localhost:11434".to_string(),
|
ollama_base_url: "http://localhost:11434".to_string(),
|
||||||
dimension,
|
dimension,
|
||||||
openai_base_url: None,
|
openai_base_url: None,
|
||||||
cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,15 +80,6 @@ impl EmbeddingsConfig {
|
|||||||
|
|
||||||
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
||||||
|
|
||||||
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
|
|
||||||
|
|
||||||
if cache_size == 0 {
|
|
||||||
return Err(ConfigError::InvalidValue {
|
|
||||||
key: "EMBEDDING_CACHE_SIZE".to_string(),
|
|
||||||
message: "must be at least 1".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enabled,
|
enabled,
|
||||||
provider,
|
provider,
|
||||||
@@ -107,7 +88,6 @@ impl EmbeddingsConfig {
|
|||||||
ollama_base_url,
|
ollama_base_url,
|
||||||
dimension,
|
dimension,
|
||||||
openai_base_url,
|
openai_base_url,
|
||||||
cache_size,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +183,13 @@ mod tests {
|
|||||||
std::env::remove_var("EMBEDDING_MODEL");
|
std::env::remove_var("EMBEDDING_MODEL");
|
||||||
std::env::remove_var("OPENAI_API_KEY");
|
std::env::remove_var("OPENAI_API_KEY");
|
||||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||||
std::env::remove_var("EMBEDDING_CACHE_SIZE");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embeddings_disabled_not_overridden_by_openai_key() {
|
fn embeddings_disabled_not_overridden_by_openai_key() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -260,6 +240,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn embeddings_env_override_takes_precedence() {
|
fn embeddings_env_override_takes_precedence() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
|
||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -300,8 +281,10 @@ mod tests {
|
|||||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.openai_base_url.as_deref(),
|
config.openai_base_url.as_deref(),
|
||||||
Some("https://custom.example.com")
|
Some("https://custom.example.com"),
|
||||||
|
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
|
||||||
);
|
);
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||||
@@ -320,24 +303,4 @@ mod tests {
|
|||||||
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
|
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cache_size_zero_rejected() {
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
clear_embedding_env();
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = Settings::default();
|
|
||||||
let result = EmbeddingsConfig::resolve(&settings);
|
|
||||||
assert!(result.is_err(), "cache_size=0 should be rejected");
|
|
||||||
let err = result.unwrap_err().to_string();
|
|
||||||
assert!(err.contains("at least 1"), "should mention minimum: {err}");
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("EMBEDDING_CACHE_SIZE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ pub use self::channels::{
|
|||||||
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
|
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
|
||||||
};
|
};
|
||||||
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
||||||
pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
|
pub use self::embeddings::EmbeddingsConfig;
|
||||||
pub use self::heartbeat::HeartbeatConfig;
|
pub use self::heartbeat::HeartbeatConfig;
|
||||||
pub use self::hygiene::HygieneConfig;
|
pub use self::hygiene::HygieneConfig;
|
||||||
pub use self::llm::default_session_path;
|
pub use self::llm::default_session_path;
|
||||||
|
|||||||
+35
-29
@@ -7,7 +7,7 @@ use secrecy::SecretString;
|
|||||||
pub struct RelayConfig {
|
pub struct RelayConfig {
|
||||||
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
|
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
|
/// API key for authenticated channel-relay endpoints.
|
||||||
pub api_key: SecretString,
|
pub api_key: SecretString,
|
||||||
/// Override for the OAuth callback URL (e.g., a tunnel URL).
|
/// Override for the OAuth callback URL (e.g., a tunnel URL).
|
||||||
pub callback_url: Option<String>,
|
pub callback_url: Option<String>,
|
||||||
@@ -15,8 +15,12 @@ pub struct RelayConfig {
|
|||||||
pub instance_id: Option<String>,
|
pub instance_id: Option<String>,
|
||||||
/// HTTP request timeout in seconds (default: 30).
|
/// HTTP request timeout in seconds (default: 30).
|
||||||
pub request_timeout_secs: u64,
|
pub request_timeout_secs: u64,
|
||||||
/// Path for the webhook callback endpoint (default: `/relay/events`).
|
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
|
||||||
pub webhook_path: String,
|
pub stream_timeout_secs: u64,
|
||||||
|
/// Initial exponential backoff in milliseconds (default: 1000).
|
||||||
|
pub backoff_initial_ms: u64,
|
||||||
|
/// Maximum exponential backoff in milliseconds (default: 60000).
|
||||||
|
pub backoff_max_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for RelayConfig {
|
impl std::fmt::Debug for RelayConfig {
|
||||||
@@ -27,7 +31,9 @@ impl std::fmt::Debug for RelayConfig {
|
|||||||
.field("callback_url", &self.callback_url)
|
.field("callback_url", &self.callback_url)
|
||||||
.field("instance_id", &self.instance_id)
|
.field("instance_id", &self.instance_id)
|
||||||
.field("request_timeout_secs", &self.request_timeout_secs)
|
.field("request_timeout_secs", &self.request_timeout_secs)
|
||||||
.field("webhook_path", &self.webhook_path)
|
.field("stream_timeout_secs", &self.stream_timeout_secs)
|
||||||
|
.field("backoff_initial_ms", &self.backoff_initial_ms)
|
||||||
|
.field("backoff_max_ms", &self.backoff_max_ms)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,8 @@ impl std::fmt::Debug for RelayConfig {
|
|||||||
impl RelayConfig {
|
impl RelayConfig {
|
||||||
/// Load relay config from environment variables.
|
/// Load relay config from environment variables.
|
||||||
///
|
///
|
||||||
/// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
|
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
|
||||||
/// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
|
/// is not set, making the relay integration opt-in.
|
||||||
/// The signing secret is fetched from channel-relay at activation time via
|
|
||||||
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
|
|
||||||
pub fn from_env() -> Option<Self> {
|
pub fn from_env() -> Option<Self> {
|
||||||
Self::from_env_reader(|key| std::env::var(key).ok())
|
Self::from_env_reader(|key| std::env::var(key).ok())
|
||||||
}
|
}
|
||||||
@@ -51,7 +55,9 @@ impl RelayConfig {
|
|||||||
callback_url: None,
|
callback_url: None,
|
||||||
instance_id: None,
|
instance_id: None,
|
||||||
request_timeout_secs: 30,
|
request_timeout_secs: 30,
|
||||||
webhook_path: "/relay/events".into(),
|
stream_timeout_secs: 86400,
|
||||||
|
backoff_initial_ms: 1000,
|
||||||
|
backoff_max_ms: 60000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +73,15 @@ impl RelayConfig {
|
|||||||
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
|
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(30),
|
.unwrap_or(30),
|
||||||
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
|
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(86400),
|
||||||
|
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1000),
|
||||||
|
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(60000),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,21 +97,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_reader_requires_only_url_and_api_key() {
|
fn from_env_reader_loads_defaults() {
|
||||||
// Signing secret is fetched at activation time — only URL + API key needed.
|
|
||||||
let config = RelayConfig::from_env_reader(|key| match key {
|
|
||||||
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
|
||||||
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
config.is_some(),
|
|
||||||
"relay config should load with just URL + API key"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_env_reader_loads_all_required() {
|
|
||||||
let config = RelayConfig::from_env_reader(|key| match key {
|
let config = RelayConfig::from_env_reader(|key| match key {
|
||||||
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
||||||
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
||||||
@@ -107,7 +107,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(config.url, "http://localhost:3001");
|
assert_eq!(config.url, "http://localhost:3001");
|
||||||
assert_eq!(config.request_timeout_secs, 30);
|
assert_eq!(config.request_timeout_secs, 30);
|
||||||
assert_eq!(config.webhook_path, "/relay/events");
|
assert_eq!(config.stream_timeout_secs, 86400);
|
||||||
|
assert_eq!(config.backoff_initial_ms, 1000);
|
||||||
|
assert_eq!(config.backoff_max_ms, 60000);
|
||||||
assert!(config.callback_url.is_none());
|
assert!(config.callback_url.is_none());
|
||||||
assert!(config.instance_id.is_none());
|
assert!(config.instance_id.is_none());
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,9 @@ mod tests {
|
|||||||
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
|
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
|
||||||
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
|
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
|
||||||
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
|
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
|
||||||
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
|
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
|
||||||
|
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
|
||||||
|
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("config should be Some");
|
.expect("config should be Some");
|
||||||
@@ -131,7 +135,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
|
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
|
||||||
assert_eq!(config.request_timeout_secs, 60);
|
assert_eq!(config.request_timeout_secs, 60);
|
||||||
assert_eq!(config.webhook_path, "/custom/events");
|
assert_eq!(config.stream_timeout_secs, 43200);
|
||||||
|
assert_eq!(config.backoff_initial_ms, 2000);
|
||||||
|
assert_eq!(config.backoff_max_ms, 120000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -142,7 +148,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_redacts_secrets() {
|
fn debug_redacts_api_key() {
|
||||||
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
|
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
|
||||||
let debug = format!("{:?}", config);
|
let debug = format!("{:?}", config);
|
||||||
assert!(debug.contains("[REDACTED]"));
|
assert!(debug.contains("[REDACTED]"));
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
//! Structured fallback deliverables for failed or stuck jobs.
|
|
||||||
//!
|
|
||||||
//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
|
|
||||||
//! what was accomplished before the failure: partial results, action statistics,
|
|
||||||
//! cost, and timing. This gives users visibility into terminal jobs instead of
|
|
||||||
//! just an error string.
|
|
||||||
//!
|
|
||||||
//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
|
|
||||||
//! and surfaced through the `job_status` tool.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::context::memory::Memory;
|
|
||||||
use crate::context::state::JobContext;
|
|
||||||
|
|
||||||
/// Structured summary of a failed or stuck job.
|
|
||||||
///
|
|
||||||
/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
|
|
||||||
/// or is marked stuck. Surfaced through the `job_status` tool.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct FallbackDeliverable {
|
|
||||||
/// True if at least one action succeeded before failure.
|
|
||||||
pub partial: bool,
|
|
||||||
/// Why the job failed.
|
|
||||||
pub failure_reason: String,
|
|
||||||
/// Last action taken before failure.
|
|
||||||
pub last_action: Option<LastAction>,
|
|
||||||
/// Aggregate action statistics.
|
|
||||||
pub action_stats: ActionStats,
|
|
||||||
/// Total tokens consumed.
|
|
||||||
pub tokens_used: u64,
|
|
||||||
/// Total cost incurred (decimal as string for JSON safety).
|
|
||||||
pub cost: String,
|
|
||||||
/// Wall-clock elapsed time in seconds.
|
|
||||||
pub elapsed_secs: f64,
|
|
||||||
/// Number of self-repair attempts.
|
|
||||||
pub repair_attempts: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Summary of the last action taken before failure.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LastAction {
|
|
||||||
pub tool_name: String,
|
|
||||||
/// Truncated to 200 bytes (UTF-8 safe).
|
|
||||||
pub output_preview: String,
|
|
||||||
pub success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Aggregate action counts.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ActionStats {
|
|
||||||
pub total: u32,
|
|
||||||
pub successful: u32,
|
|
||||||
pub failed: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FallbackDeliverable {
|
|
||||||
/// Build a fallback deliverable from a job context and its memory.
|
|
||||||
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
|
|
||||||
let successful = memory.successful_actions() as u32;
|
|
||||||
let failed = memory.failed_actions() as u32;
|
|
||||||
let total = memory.actions.len() as u32;
|
|
||||||
|
|
||||||
let last_action = memory.last_action().map(|a| {
|
|
||||||
// Use sanitized output to avoid leaking secrets through the fallback API surface.
|
|
||||||
// For failed actions (no sanitized output), fall back to the error message.
|
|
||||||
// Borrow the string slice directly when possible to avoid cloning
|
|
||||||
// potentially large outputs just for truncation.
|
|
||||||
let owned_fallback;
|
|
||||||
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
|
|
||||||
match v {
|
|
||||||
serde_json::Value::String(s) => s.as_str(),
|
|
||||||
other => {
|
|
||||||
owned_fallback = serde_json::to_string(other).unwrap_or_default();
|
|
||||||
&owned_fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(ref err) = a.error {
|
|
||||||
err.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let preview = truncate_str(preview_str, 200);
|
|
||||||
LastAction {
|
|
||||||
tool_name: a.tool_name.clone(),
|
|
||||||
output_preview: preview.to_string(),
|
|
||||||
success: a.success,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
partial: successful > 0,
|
|
||||||
failure_reason: truncate_str(reason, 1000).to_string(),
|
|
||||||
last_action,
|
|
||||||
action_stats: ActionStats {
|
|
||||||
total,
|
|
||||||
successful,
|
|
||||||
failed,
|
|
||||||
},
|
|
||||||
tokens_used: ctx.total_tokens_used,
|
|
||||||
cost: ctx.actual_cost.to_string(),
|
|
||||||
elapsed_secs,
|
|
||||||
repair_attempts: ctx.repair_attempts,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncate a string to at most `max_len` bytes on a char boundary.
|
|
||||||
fn truncate_str(s: &str, max_len: usize) -> &str {
|
|
||||||
&s[..crate::util::floor_char_boundary(s, max_len)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::context::memory::Memory;
|
|
||||||
use crate::context::state::JobContext;
|
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use std::time::Duration as StdDuration;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_zero_actions() {
|
|
||||||
let ctx = JobContext::new("Test", "Empty job");
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
|
|
||||||
|
|
||||||
assert!(!fb.partial); // safety: test
|
|
||||||
assert_eq!(fb.failure_reason, "timed out"); // safety: test
|
|
||||||
assert!(fb.last_action.is_none()); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.total, 0); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.successful, 0); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.failed, 0); // safety: test
|
|
||||||
assert_eq!(fb.tokens_used, 0); // safety: test
|
|
||||||
assert_eq!(fb.cost, "0"); // safety: test
|
|
||||||
assert_eq!(fb.repair_attempts, 0); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_mixed_actions() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Mixed job");
|
|
||||||
ctx.total_tokens_used = 5000;
|
|
||||||
ctx.actual_cost = Decimal::new(42, 2); // 0.42
|
|
||||||
ctx.repair_attempts = 1;
|
|
||||||
|
|
||||||
let mut memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
// 3 successes
|
|
||||||
for _ in 0..3 {
|
|
||||||
let action = memory
|
|
||||||
.create_action("tool_a", serde_json::json!({}))
|
|
||||||
.succeed(
|
|
||||||
Some("output".to_string()),
|
|
||||||
serde_json::json!({}),
|
|
||||||
StdDuration::from_secs(1),
|
|
||||||
);
|
|
||||||
memory.record_action(action);
|
|
||||||
}
|
|
||||||
// 2 failures
|
|
||||||
for _ in 0..2 {
|
|
||||||
let action = memory
|
|
||||||
.create_action("tool_b", serde_json::json!({}))
|
|
||||||
.fail("broke", StdDuration::from_secs(1));
|
|
||||||
memory.record_action(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
|
|
||||||
|
|
||||||
assert!(fb.partial); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.total, 5); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.successful, 3); // safety: test
|
|
||||||
assert_eq!(fb.action_stats.failed, 2); // safety: test
|
|
||||||
assert_eq!(fb.tokens_used, 5000); // safety: test
|
|
||||||
assert_eq!(fb.cost, "0.42"); // safety: test
|
|
||||||
assert_eq!(fb.repair_attempts, 1); // safety: test
|
|
||||||
assert!(fb.last_action.is_some()); // safety: test
|
|
||||||
let la = fb.last_action.unwrap(); // safety: test
|
|
||||||
assert_eq!(la.tool_name, "tool_b"); // safety: test
|
|
||||||
assert!(!la.success); // safety: test
|
|
||||||
// Failed actions should surface the error message as the output preview
|
|
||||||
assert_eq!(la.output_preview, "broke"); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_failed_action_shows_error() {
|
|
||||||
let ctx = JobContext::new("Test", "Error preview");
|
|
||||||
let mut memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let action = memory
|
|
||||||
.create_action("broken_tool", serde_json::json!({}))
|
|
||||||
.fail("connection timed out after 30s", StdDuration::from_secs(30));
|
|
||||||
memory.record_action(action);
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
|
|
||||||
let la = fb.last_action.unwrap(); // safety: test
|
|
||||||
assert!(!la.success); // safety: test
|
|
||||||
assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_last_action_truncation() {
|
|
||||||
let ctx = JobContext::new("Test", "Truncation");
|
|
||||||
let mut memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let long_output = "x".repeat(500);
|
|
||||||
let action = memory
|
|
||||||
.create_action("tool_c", serde_json::json!({}))
|
|
||||||
.succeed(
|
|
||||||
Some(long_output.clone()),
|
|
||||||
serde_json::Value::String(long_output),
|
|
||||||
StdDuration::from_secs(1),
|
|
||||||
);
|
|
||||||
memory.record_action(action);
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
|
||||||
let la = fb.last_action.unwrap(); // safety: test
|
|
||||||
assert!(la.output_preview.len() <= 200); // safety: test
|
|
||||||
assert!(!la.output_preview.is_empty()); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_uses_sanitized_output() {
|
|
||||||
let ctx = JobContext::new("Test", "Sanitized");
|
|
||||||
let mut memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let action = memory
|
|
||||||
.create_action("tool_d", serde_json::json!({}))
|
|
||||||
.succeed(
|
|
||||||
Some("[REDACTED]".to_string()),
|
|
||||||
serde_json::json!({"api_key": "sk-secret-key-12345"}),
|
|
||||||
StdDuration::from_secs(1),
|
|
||||||
);
|
|
||||||
memory.record_action(action);
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
|
||||||
let la = fb.last_action.unwrap(); // safety: test
|
|
||||||
// Must use sanitized output, not raw
|
|
||||||
assert!(!la.output_preview.contains("sk-secret")); // safety: test
|
|
||||||
assert!(la.output_preview.contains("REDACTED")); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_elapsed_time() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Timing");
|
|
||||||
let now = Utc::now();
|
|
||||||
ctx.started_at = Some(now - Duration::seconds(10));
|
|
||||||
ctx.completed_at = Some(now);
|
|
||||||
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
|
||||||
|
|
||||||
// Should be approximately 10 seconds
|
|
||||||
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_no_started_at() {
|
|
||||||
let ctx = JobContext::new("Test", "Never started");
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
|
||||||
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_elapsed_time_no_completed_at() {
|
|
||||||
let mut ctx = JobContext::new("Test", "Still running");
|
|
||||||
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
|
|
||||||
// completed_at is None — should use Utc::now() as fallback
|
|
||||||
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
|
|
||||||
|
|
||||||
// Should be approximately 5 seconds (using now as end time)
|
|
||||||
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_failure_reason_truncation() {
|
|
||||||
let ctx = JobContext::new("Test", "Long reason");
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
|
|
||||||
let long_reason = "x".repeat(5000);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
|
|
||||||
|
|
||||||
assert!(fb.failure_reason.len() <= 1000); // safety: test
|
|
||||||
assert!(!fb.failure_reason.is_empty()); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncate_str_ascii() {
|
|
||||||
assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
|
|
||||||
assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncate_str_unicode() {
|
|
||||||
// "é" is 2 bytes in UTF-8
|
|
||||||
let s = "café";
|
|
||||||
assert_eq!(truncate_str(s, 10), "café"); // safety: test
|
|
||||||
// Truncating at 4 would split "é", should back up to 3
|
|
||||||
assert_eq!(truncate_str(s, 4), "caf"); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_fallback_serialization() {
|
|
||||||
let ctx = JobContext::new("Test", "Serialize");
|
|
||||||
let memory = Memory::new(ctx.job_id);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
|
|
||||||
|
|
||||||
// Should serialize to JSON and back without error
|
|
||||||
let json = serde_json::to_value(&fb).unwrap(); // safety: test
|
|
||||||
let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
|
|
||||||
assert_eq!(deserialized.failure_reason, "test error"); // safety: test
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+70
-79
@@ -58,19 +58,15 @@ impl ActionRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mark the action as successful.
|
/// Mark the action as successful.
|
||||||
///
|
|
||||||
/// `output_sanitized` is the tool output after safety processing (string).
|
|
||||||
/// `output_raw` is the original tool result (JSON value, stored as a
|
|
||||||
/// pretty-printed JSON string in `ActionRecord.output_raw`).
|
|
||||||
pub fn succeed(
|
pub fn succeed(
|
||||||
mut self,
|
mut self,
|
||||||
output_sanitized: Option<String>,
|
output_raw: Option<String>,
|
||||||
output_raw: serde_json::Value,
|
output_sanitized: serde_json::Value,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.success = true;
|
self.success = true;
|
||||||
self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
|
self.output_raw = output_raw;
|
||||||
self.output_sanitized = output_sanitized.map(serde_json::Value::String);
|
self.output_sanitized = Some(output_sanitized);
|
||||||
self.duration = duration;
|
self.duration = duration;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -252,15 +248,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_action_record() {
|
fn test_action_record() {
|
||||||
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
|
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
|
||||||
assert_eq!(action.sequence, 0); // safety: test
|
assert_eq!(action.sequence, 0);
|
||||||
assert!(!action.success); // safety: test
|
assert!(!action.success);
|
||||||
|
|
||||||
let action = action.succeed(
|
let action = action.succeed(
|
||||||
Some("raw".to_string()),
|
Some("raw".to_string()),
|
||||||
serde_json::json!({"result": "ok"}),
|
serde_json::json!({"result": "ok"}),
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
);
|
);
|
||||||
assert!(action.success); // safety: test
|
assert!(action.success);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -271,7 +267,7 @@ mod tests {
|
|||||||
memory.add(ChatMessage::user("How are you?"));
|
memory.add(ChatMessage::user("How are you?"));
|
||||||
memory.add(ChatMessage::assistant("Good!"));
|
memory.add(ChatMessage::assistant("Good!"));
|
||||||
|
|
||||||
assert_eq!(memory.len(), 3); // Oldest removed // safety: test
|
assert_eq!(memory.len(), 3); // Oldest removed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -290,9 +286,9 @@ mod tests {
|
|||||||
.with_cost(Decimal::new(20, 1));
|
.with_cost(Decimal::new(20, 1));
|
||||||
memory.record_action(action2);
|
memory.record_action(action2);
|
||||||
|
|
||||||
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
|
assert_eq!(memory.total_cost(), Decimal::new(30, 1));
|
||||||
assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
|
assert_eq!(memory.total_duration(), Duration::from_secs(3));
|
||||||
assert_eq!(memory.successful_actions(), 2); // safety: test
|
assert_eq!(memory.successful_actions(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -300,11 +296,11 @@ mod tests {
|
|||||||
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
|
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
|
||||||
let action = action.fail("something went wrong", Duration::from_millis(50));
|
let action = action.fail("something went wrong", Duration::from_millis(50));
|
||||||
|
|
||||||
assert!(!action.success); // safety: test
|
assert!(!action.success);
|
||||||
assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
|
assert_eq!(action.error.as_deref(), Some("something went wrong"));
|
||||||
assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
|
assert_eq!(action.duration, Duration::from_millis(50));
|
||||||
assert!(action.output_raw.is_none()); // safety: test
|
assert!(action.output_raw.is_none());
|
||||||
assert!(action.output_sanitized.is_none()); // safety: test
|
assert!(action.output_sanitized.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -312,9 +308,9 @@ mod tests {
|
|||||||
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
|
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
|
||||||
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
|
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
|
||||||
|
|
||||||
assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
|
assert_eq!(action.sanitization_warnings.len(), 2);
|
||||||
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
|
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
|
||||||
assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
|
assert_eq!(action.sanitization_warnings[1], "possible xss");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -323,46 +319,41 @@ mod tests {
|
|||||||
let cost = Decimal::new(42, 2); // 0.42
|
let cost = Decimal::new(42, 2); // 0.42
|
||||||
let action = action.with_cost(cost);
|
let action = action.with_cost(cost);
|
||||||
|
|
||||||
assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
|
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_action_record_new_defaults() {
|
fn test_action_record_new_defaults() {
|
||||||
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
|
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
|
||||||
|
|
||||||
assert_eq!(action.sequence, 5); // safety: test
|
assert_eq!(action.sequence, 5);
|
||||||
assert_eq!(action.tool_name, "my_tool"); // safety: test
|
assert_eq!(action.tool_name, "my_tool");
|
||||||
assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
|
assert_eq!(action.input, serde_json::json!({"key": "val"}));
|
||||||
assert!(!action.success); // safety: test
|
assert!(!action.success);
|
||||||
assert!(action.output_raw.is_none()); // safety: test
|
assert!(action.output_raw.is_none());
|
||||||
assert!(action.output_sanitized.is_none()); // safety: test
|
assert!(action.output_sanitized.is_none());
|
||||||
assert!(action.sanitization_warnings.is_empty()); // safety: test
|
assert!(action.sanitization_warnings.is_empty());
|
||||||
assert!(action.cost.is_none()); // safety: test
|
assert!(action.cost.is_none());
|
||||||
assert_eq!(action.duration, Duration::ZERO); // safety: test
|
assert_eq!(action.duration, Duration::ZERO);
|
||||||
assert!(action.error.is_none()); // safety: test
|
assert!(action.error.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_action_record_succeed_sets_fields() {
|
fn test_action_record_succeed_sets_fields() {
|
||||||
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
|
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
|
||||||
let action = action.succeed(
|
let action = action.succeed(
|
||||||
Some("sanitized output".into()),
|
Some("raw output here".into()),
|
||||||
serde_json::json!({"clean": true}),
|
serde_json::json!({"clean": true}),
|
||||||
Duration::from_secs(7),
|
Duration::from_secs(7),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(action.success); // safety: test
|
assert!(action.success);
|
||||||
// output_raw is the JSON value pretty-printed
|
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
|
||||||
let expected_raw =
|
|
||||||
serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
|
|
||||||
assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
|
|
||||||
// output_sanitized wraps the string in a JSON string value
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
|
||||||
action.output_sanitized,
|
action.output_sanitized,
|
||||||
Some(serde_json::json!("sanitized output"))
|
Some(serde_json::json!({"clean": true}))
|
||||||
);
|
);
|
||||||
assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
|
assert_eq!(action.duration, Duration::from_secs(7));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -370,13 +361,13 @@ mod tests {
|
|||||||
let mut mem = ConversationMemory::new(10);
|
let mut mem = ConversationMemory::new(10);
|
||||||
mem.add(ChatMessage::user("hello"));
|
mem.add(ChatMessage::user("hello"));
|
||||||
mem.add(ChatMessage::assistant("hi"));
|
mem.add(ChatMessage::assistant("hi"));
|
||||||
assert_eq!(mem.len(), 2); // safety: test
|
assert_eq!(mem.len(), 2);
|
||||||
assert!(!mem.is_empty()); // safety: test
|
assert!(!mem.is_empty());
|
||||||
|
|
||||||
mem.clear();
|
mem.clear();
|
||||||
assert_eq!(mem.len(), 0); // safety: test
|
assert_eq!(mem.len(), 0);
|
||||||
assert!(mem.is_empty()); // safety: test
|
assert!(mem.is_empty());
|
||||||
assert!(mem.messages().is_empty()); // safety: test
|
assert!(mem.messages().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -388,20 +379,20 @@ mod tests {
|
|||||||
mem.add(ChatMessage::assistant("four"));
|
mem.add(ChatMessage::assistant("four"));
|
||||||
|
|
||||||
let last_2 = mem.last_n(2);
|
let last_2 = mem.last_n(2);
|
||||||
assert_eq!(last_2.len(), 2); // safety: test
|
assert_eq!(last_2.len(), 2);
|
||||||
assert_eq!(last_2[0].content, "three"); // safety: test
|
assert_eq!(last_2[0].content, "three");
|
||||||
assert_eq!(last_2[1].content, "four"); // safety: test
|
assert_eq!(last_2[1].content, "four");
|
||||||
|
|
||||||
// Requesting more than available returns all
|
// Requesting more than available returns all
|
||||||
let last_100 = mem.last_n(100);
|
let last_100 = mem.last_n(100);
|
||||||
assert_eq!(last_100.len(), 4); // safety: test
|
assert_eq!(last_100.len(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_conversation_memory_last_n_empty() {
|
fn test_conversation_memory_last_n_empty() {
|
||||||
let mem = ConversationMemory::new(10);
|
let mem = ConversationMemory::new(10);
|
||||||
let result = mem.last_n(5);
|
let result = mem.last_n(5);
|
||||||
assert!(result.is_empty()); // safety: test
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -414,13 +405,13 @@ mod tests {
|
|||||||
// At capacity (3). Adding one more should trim, but keep system.
|
// At capacity (3). Adding one more should trim, but keep system.
|
||||||
mem.add(ChatMessage::user("msg3"));
|
mem.add(ChatMessage::user("msg3"));
|
||||||
|
|
||||||
assert_eq!(mem.len(), 3); // safety: test
|
assert_eq!(mem.len(), 3);
|
||||||
// System message must survive
|
// System message must survive
|
||||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
|
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||||
assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
|
assert_eq!(mem.messages()[0].content, "You are helpful");
|
||||||
// Oldest non-system message (msg1) should be gone
|
// Oldest non-system message (msg1) should be gone
|
||||||
assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
|
assert_eq!(mem.messages()[1].content, "msg2");
|
||||||
assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
|
assert_eq!(mem.messages()[2].content, "msg3");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -431,9 +422,9 @@ mod tests {
|
|||||||
// Now at capacity. Add another.
|
// Now at capacity. Add another.
|
||||||
mem.add(ChatMessage::user("b"));
|
mem.add(ChatMessage::user("b"));
|
||||||
|
|
||||||
assert_eq!(mem.len(), 2); // safety: test
|
assert_eq!(mem.len(), 2);
|
||||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
|
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||||
assert_eq!(mem.messages()[1].content, "b"); // safety: test
|
assert_eq!(mem.messages()[1].content, "b");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -449,7 +440,7 @@ mod tests {
|
|||||||
mem.add(ChatMessage::user("hello"));
|
mem.add(ChatMessage::user("hello"));
|
||||||
// Should have broken out rather than looping forever.
|
// Should have broken out rather than looping forever.
|
||||||
// The system message is protected, so len may exceed max.
|
// The system message is protected, so len may exceed max.
|
||||||
assert!(mem.len() <= 2); // safety: test
|
assert!(mem.len() <= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -468,14 +459,14 @@ mod tests {
|
|||||||
.fail("oops", Duration::from_millis(2));
|
.fail("oops", Duration::from_millis(2));
|
||||||
memory.record_action(err);
|
memory.record_action(err);
|
||||||
|
|
||||||
assert_eq!(memory.successful_actions(), 1); // safety: test
|
assert_eq!(memory.successful_actions(), 1);
|
||||||
assert_eq!(memory.failed_actions(), 1); // safety: test
|
assert_eq!(memory.failed_actions(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_memory_last_action() {
|
fn test_memory_last_action() {
|
||||||
let mut memory = Memory::new(Uuid::new_v4());
|
let mut memory = Memory::new(Uuid::new_v4());
|
||||||
assert!(memory.last_action().is_none()); // safety: test
|
assert!(memory.last_action().is_none());
|
||||||
|
|
||||||
let a1 = memory
|
let a1 = memory
|
||||||
.create_action("first", serde_json::json!({}))
|
.create_action("first", serde_json::json!({}))
|
||||||
@@ -487,8 +478,8 @@ mod tests {
|
|||||||
.fail("nope", Duration::ZERO);
|
.fail("nope", Duration::ZERO);
|
||||||
memory.record_action(a2);
|
memory.record_action(a2);
|
||||||
|
|
||||||
let last = memory.last_action().unwrap(); // safety: test
|
let last = memory.last_action().unwrap();
|
||||||
assert_eq!(last.tool_name, "second"); // safety: test
|
assert_eq!(last.tool_name, "second");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -508,9 +499,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
memory.record_action(a);
|
memory.record_action(a);
|
||||||
|
|
||||||
assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
|
assert_eq!(memory.actions_by_tool("shell").len(), 3);
|
||||||
assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
|
assert_eq!(memory.actions_by_tool("http").len(), 1);
|
||||||
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
|
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -518,25 +509,25 @@ mod tests {
|
|||||||
let mut memory = Memory::new(Uuid::new_v4());
|
let mut memory = Memory::new(Uuid::new_v4());
|
||||||
|
|
||||||
let a0 = memory.create_action("t", serde_json::json!({}));
|
let a0 = memory.create_action("t", serde_json::json!({}));
|
||||||
assert_eq!(a0.sequence, 0); // safety: test
|
assert_eq!(a0.sequence, 0);
|
||||||
|
|
||||||
let a1 = memory.create_action("t", serde_json::json!({}));
|
let a1 = memory.create_action("t", serde_json::json!({}));
|
||||||
assert_eq!(a1.sequence, 1); // safety: test
|
assert_eq!(a1.sequence, 1);
|
||||||
|
|
||||||
let a2 = memory.create_action("t", serde_json::json!({}));
|
let a2 = memory.create_action("t", serde_json::json!({}));
|
||||||
assert_eq!(a2.sequence, 2); // safety: test
|
assert_eq!(a2.sequence, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_memory_add_message_delegates_to_conversation() {
|
fn test_memory_add_message_delegates_to_conversation() {
|
||||||
let mut memory = Memory::new(Uuid::new_v4());
|
let mut memory = Memory::new(Uuid::new_v4());
|
||||||
assert!(memory.conversation.is_empty()); // safety: test
|
assert!(memory.conversation.is_empty());
|
||||||
|
|
||||||
memory.add_message(ChatMessage::user("hello"));
|
memory.add_message(ChatMessage::user("hello"));
|
||||||
memory.add_message(ChatMessage::assistant("hi"));
|
memory.add_message(ChatMessage::assistant("hi"));
|
||||||
|
|
||||||
assert_eq!(memory.conversation.len(), 2); // safety: test
|
assert_eq!(memory.conversation.len(), 2);
|
||||||
assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
|
assert_eq!(memory.conversation.messages()[0].content, "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -549,7 +540,7 @@ mod tests {
|
|||||||
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
||||||
memory.record_action(a);
|
memory.record_action(a);
|
||||||
|
|
||||||
assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
|
assert_eq!(memory.total_cost(), Decimal::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -569,6 +560,6 @@ mod tests {
|
|||||||
memory.record_action(a2);
|
memory.record_action(a2);
|
||||||
|
|
||||||
// Both successful and failed actions contribute to total duration
|
// Both successful and failed actions contribute to total duration
|
||||||
assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
|
assert_eq!(memory.total_duration(), Duration::from_millis(300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,10 @@
|
|||||||
//! - State machine
|
//! - State machine
|
||||||
//! - Resource tracking
|
//! - Resource tracking
|
||||||
|
|
||||||
pub mod fallback;
|
|
||||||
mod manager;
|
mod manager;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use fallback::FallbackDeliverable;
|
|
||||||
pub use manager::ContextManager;
|
pub use manager::ContextManager;
|
||||||
pub use memory::{ActionRecord, ConversationMemory, Memory};
|
pub use memory::{ActionRecord, ConversationMemory, Memory};
|
||||||
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
|
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
|
||||||
|
|||||||
@@ -476,28 +476,4 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
&format!(
|
|
||||||
"SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
|
|
||||||
ROUTINE_RUN_COLUMNS
|
|
||||||
),
|
|
||||||
params![],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut runs = Vec::new();
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
runs.push(row_to_routine_run_libsql(&row)?);
|
|
||||||
}
|
|
||||||
Ok(runs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,9 +525,6 @@ pub trait RoutineStore: Send + Sync {
|
|||||||
run_id: Uuid,
|
run_id: Uuid,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
) -> Result<(), DatabaseError>;
|
) -> Result<(), DatabaseError>;
|
||||||
/// List routine runs that were dispatched as full_job but have not yet
|
|
||||||
/// been finalized (status='running' with a linked job_id).
|
|
||||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -503,10 +503,6 @@ impl RoutineStore for PgBackend {
|
|||||||
) -> Result<(), DatabaseError> {
|
) -> Result<(), DatabaseError> {
|
||||||
self.store.link_routine_run_to_job(run_id, job_id).await
|
self.store.link_routine_run_to_job(run_id, job_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
self.store.list_dispatched_routine_runs().await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ToolFailureStore ====================
|
// ==================== ToolFailureStore ====================
|
||||||
|
|||||||
+240
-516
File diff suppressed because it is too large
Load Diff
@@ -1348,18 +1348,6 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List routine runs dispatched as full_job that have not yet been finalized.
|
|
||||||
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let rows = conn
|
|
||||||
.query(
|
|
||||||
"SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
rows.iter().map(row_to_routine_run).collect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ use crate::llm::provider::{
|
|||||||
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
|
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
|
||||||
strip_unsupported_tool_params,
|
strip_unsupported_tool_params,
|
||||||
};
|
};
|
||||||
use crate::llm::retry::cap_retry_after;
|
|
||||||
|
|
||||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||||
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
|
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
|
||||||
@@ -151,7 +150,6 @@ impl AnthropicOAuthProvider {
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
.map(std::time::Duration::from_secs)
|
.map(std::time::Duration::from_secs)
|
||||||
.map(cap_retry_after)
|
|
||||||
.or(Some(std::time::Duration::from_secs(60)));
|
.or(Some(std::time::Duration::from_secs(60)));
|
||||||
|
|
||||||
let response_text = response
|
let response_text = response
|
||||||
@@ -768,14 +766,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_retry_after_large_number() {
|
fn test_retry_after_large_number() {
|
||||||
// Verify large numbers are capped to the safe maximum
|
// Verify large numbers are accepted
|
||||||
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
|
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
|
||||||
assert_eq!(
|
assert_eq!(duration, Some(std::time::Duration::from_secs(7200)));
|
||||||
duration,
|
|
||||||
Some(std::time::Duration::from_secs(
|
|
||||||
crate::llm::retry::MAX_RETRY_AFTER_SECS
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to test Retry-After header parsing logic for Anthropic
|
/// Helper function to test Retry-After header parsing logic for Anthropic
|
||||||
@@ -786,7 +779,6 @@ mod tests {
|
|||||||
.parse::<u64>()
|
.parse::<u64>()
|
||||||
.ok()
|
.ok()
|
||||||
.map(std::time::Duration::from_secs)
|
.map(std::time::Duration::from_secs)
|
||||||
.map(cap_retry_after)
|
|
||||||
.or(Some(std::time::Duration::from_secs(60)))
|
.or(Some(std::time::Duration::from_secs(60)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,12 +167,6 @@ impl CircuitBreakerProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CircuitState::Open => {
|
CircuitState::Open => {
|
||||||
debug_assert!(
|
|
||||||
false,
|
|
||||||
"BUG: record_success() called while circuit breaker is Open — \
|
|
||||||
check_allowed() was bypassed for provider {}",
|
|
||||||
self.inner.model_name()
|
|
||||||
);
|
|
||||||
// Shouldn't get here (check_allowed blocks Open), but recover
|
// Shouldn't get here (check_allowed blocks Open), but recover
|
||||||
state.state = CircuitState::Closed;
|
state.state = CircuitState::Closed;
|
||||||
state.consecutive_failures = 0;
|
state.consecutive_failures = 0;
|
||||||
|
|||||||
+10
-18
@@ -22,7 +22,7 @@ use crate::llm::provider::{
|
|||||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||||
ToolCompletionRequest, ToolCompletionResponse,
|
ToolCompletionRequest, ToolCompletionResponse,
|
||||||
};
|
};
|
||||||
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
|
use crate::llm::{costs, session::SessionManager};
|
||||||
|
|
||||||
/// Information about an available model from NEAR AI API.
|
/// Information about an available model from NEAR AI API.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -252,7 +252,7 @@ impl NearAiChatProvider {
|
|||||||
.and_then(|v| {
|
.and_then(|v| {
|
||||||
// Try delay-seconds first (most common from API providers)
|
// Try delay-seconds first (most common from API providers)
|
||||||
if let Ok(secs) = v.trim().parse::<u64>() {
|
if let Ok(secs) = v.trim().parse::<u64>() {
|
||||||
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
|
return Some(std::time::Duration::from_secs(secs));
|
||||||
}
|
}
|
||||||
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
|
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
|
||||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
|
||||||
@@ -260,9 +260,9 @@ impl NearAiChatProvider {
|
|||||||
let delta = dt.signed_duration_since(now);
|
let delta = dt.signed_duration_since(now);
|
||||||
// Use max(0) so past/present dates yield Duration::ZERO
|
// Use max(0) so past/present dates yield Duration::ZERO
|
||||||
// rather than None (which would cause an immediate retry).
|
// rather than None (which would cause an immediate retry).
|
||||||
return Some(cap_retry_after(std::time::Duration::from_secs(
|
return Some(std::time::Duration::from_secs(
|
||||||
delta.num_seconds().max(0) as u64,
|
delta.num_seconds().max(0) as u64
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
@@ -2306,17 +2306,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_retry_after_large_number() {
|
fn test_retry_after_large_number() {
|
||||||
// Verify large numbers are capped to the safe maximum
|
// Verify large numbers are accepted
|
||||||
let duration = parse_retry_after_for_test("3600"); // 1 hour
|
let duration = parse_retry_after_for_test("3600"); // 1 hour
|
||||||
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
|
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
|
||||||
|
|
||||||
let huge = parse_retry_after_for_test("18446744073709551615");
|
|
||||||
assert_eq!(
|
|
||||||
huge,
|
|
||||||
Some(std::time::Duration::from_secs(
|
|
||||||
crate::llm::retry::MAX_RETRY_AFTER_SECS
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to test Retry-After header parsing logic
|
/// Helper function to test Retry-After header parsing logic
|
||||||
@@ -2324,13 +2316,13 @@ mod tests {
|
|||||||
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
|
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
|
||||||
let trimmed = header_value.trim();
|
let trimmed = header_value.trim();
|
||||||
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
|
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
|
||||||
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
|
Some(std::time::Duration::from_secs(secs))
|
||||||
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
|
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let delta = dt.signed_duration_since(now);
|
let delta = dt.signed_duration_since(now);
|
||||||
Some(cap_retry_after(std::time::Duration::from_secs(
|
Some(std::time::Duration::from_secs(
|
||||||
delta.num_seconds().max(0) as u64,
|
delta.num_seconds().max(0) as u64
|
||||||
)))
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ pub enum OAuthCallbackError {
|
|||||||
/// deployments where `127.0.0.1` is unreachable from the user's browser),
|
/// deployments where `127.0.0.1` is unreachable from the user's browser),
|
||||||
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
|
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
|
||||||
pub fn callback_url() -> String {
|
pub fn callback_url() -> String {
|
||||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
|
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
|
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +57,7 @@ pub fn callback_url() -> String {
|
|||||||
/// Note: this transmits the session token over plain HTTP — prefer SSH port
|
/// Note: this transmits the session token over plain HTTP — prefer SSH port
|
||||||
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
|
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
|
||||||
pub fn callback_host() -> String {
|
pub fn callback_host() -> String {
|
||||||
crate::config::helpers::env_or_override("OAUTH_CALLBACK_HOST")
|
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if `host` is a loopback address that only accepts local connections.
|
/// Returns `true` if `host` is a loopback address that only accepts local connections.
|
||||||
|
|||||||
@@ -108,8 +108,6 @@ mod tests {
|
|||||||
assert!(has_native_thinking("nanbeige-4.1-3b"));
|
assert!(has_native_thinking("nanbeige-4.1-3b"));
|
||||||
assert!(has_native_thinking("step-3.5-flash-197b"));
|
assert!(has_native_thinking("step-3.5-flash-197b"));
|
||||||
assert!(has_native_thinking("minimax-m2.5-139b"));
|
assert!(has_native_thinking("minimax-m2.5-139b"));
|
||||||
assert!(has_native_thinking("MiniMax-M2.7"));
|
|
||||||
assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -19,12 +19,6 @@ use crate::llm::provider::{
|
|||||||
ToolCompletionResponse,
|
ToolCompletionResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Upper bound for provider-suggested `Retry-After` delays.
|
|
||||||
///
|
|
||||||
/// This prevents malicious or malformed headers from turning a retryable
|
|
||||||
/// response into an effectively unbounded sleep.
|
|
||||||
pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600;
|
|
||||||
|
|
||||||
/// Returns `true` if the `LlmError` is transient and the request should be retried.
|
/// Returns `true` if the `LlmError` is transient and the request should be retried.
|
||||||
///
|
///
|
||||||
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
|
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
|
||||||
@@ -73,11 +67,6 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
|
|||||||
Duration::from_millis(delay_ms)
|
Duration::from_millis(delay_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clamp a provider-suggested retry delay to a safe maximum.
|
|
||||||
pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
|
|
||||||
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for the retry decorator.
|
/// Configuration for the retry decorator.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RetryConfig {
|
pub struct RetryConfig {
|
||||||
@@ -432,16 +421,4 @@ mod tests {
|
|||||||
panic!("Expected RateLimited error");
|
panic!("Expected RateLimited error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cap_retry_after_clamps_huge_delays() {
|
|
||||||
assert_eq!(
|
|
||||||
cap_retry_after(Duration::from_secs(u64::MAX)),
|
|
||||||
Duration::from_secs(MAX_RETRY_AFTER_SECS)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
cap_retry_after(Duration::from_secs(0)),
|
|
||||||
Duration::from_secs(0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-22
@@ -323,17 +323,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Load WASM channels and register their webhook routes.
|
// Load WASM channels and register their webhook routes.
|
||||||
// Ensure the channels directory exists so the WASM runtime initializes even when
|
|
||||||
// no channels are installed yet — hot-activation needs the runtime to be available.
|
|
||||||
if config.channels.wasm_channels_enabled
|
|
||||||
&& let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir)
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
path = %config.channels.wasm_channels_dir.display(),
|
|
||||||
error = %e,
|
|
||||||
"Failed to create WASM channels directory"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||||
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||||
&config,
|
&config,
|
||||||
@@ -522,16 +511,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
gw = gw.with_skill_catalog(Arc::clone(sc));
|
gw = gw.with_skill_catalog(Arc::clone(sc));
|
||||||
}
|
}
|
||||||
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
|
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
|
||||||
{
|
|
||||||
let active_model = components.llm.model_name().to_string();
|
|
||||||
let mut enabled = channel_names.clone();
|
|
||||||
enabled.push("gateway".into());
|
|
||||||
gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot {
|
|
||||||
llm_backend: config.llm.backend.to_string(),
|
|
||||||
llm_model: active_model,
|
|
||||||
enabled_channels: enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if config.sandbox.enabled {
|
if config.sandbox.enabled {
|
||||||
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
|
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
|
||||||
|
|
||||||
@@ -748,7 +727,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
document_extraction: Some(Arc::new(
|
document_extraction: Some(Arc::new(
|
||||||
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
|
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
|
||||||
)),
|
)),
|
||||||
builder: components.builder,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut agent = Agent::new(
|
let mut agent = Agent::new(
|
||||||
|
|||||||
@@ -333,12 +333,6 @@ async fn job_event_handler(
|
|||||||
.get("session_id")
|
.get("session_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string()),
|
.map(|s| s.to_string()),
|
||||||
// NOTE: `fallback_deliverable` is currently always None in SSE events.
|
|
||||||
// In-memory jobs store fallback data in JobContext.metadata (accessed via job_status tool).
|
|
||||||
// Sandbox containers don't yet emit fallback data in their event payloads.
|
|
||||||
// This field is forward-compatible infrastructure for when container workers
|
|
||||||
// gain context/memory tracking capabilities.
|
|
||||||
fallback_deliverable: payload.data.get("fallback_deliverable").cloned(),
|
|
||||||
},
|
},
|
||||||
_ => SseEvent::JobStatus {
|
_ => SseEvent::JobStatus {
|
||||||
job_id: job_id_str,
|
job_id: job_id_str,
|
||||||
|
|||||||
@@ -440,6 +440,39 @@ impl PairingStore {
|
|||||||
Ok(file.allow_from)
|
Ok(file.allow_from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear the allow-from list for a channel.
|
||||||
|
///
|
||||||
|
/// Called on credential refresh so that existing users must re-approve
|
||||||
|
/// after a bot token change.
|
||||||
|
pub fn clear_allow_from(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||||
|
let path = allow_from_path(&self.base_dir, channel)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let file = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&path)?;
|
||||||
|
file.lock_exclusive()?;
|
||||||
|
let store = AllowFromStoreFile {
|
||||||
|
version: 1,
|
||||||
|
allow_from: Vec::new(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&store)?;
|
||||||
|
fs::write(&path, json)?;
|
||||||
|
fs4::FileExt::unlock(&file)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all pending pairing requests for a channel.
|
||||||
|
///
|
||||||
|
/// Called on credential refresh so stale requests don't confuse users.
|
||||||
|
pub fn clear_pending(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||||
|
self.write_pairing_file(channel, &[])
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a sender is allowed (by id or username).
|
/// Check if a sender is allowed (by id or username).
|
||||||
pub fn is_sender_allowed(
|
pub fn is_sender_allowed(
|
||||||
&self,
|
&self,
|
||||||
@@ -517,6 +550,11 @@ impl PairingStore {
|
|||||||
requests: &[PairingRequest],
|
requests: &[PairingRequest],
|
||||||
) -> Result<(), PairingStoreError> {
|
) -> Result<(), PairingStoreError> {
|
||||||
let path = pairing_path(&self.base_dir, channel)?;
|
let path = pairing_path(&self.base_dir, channel)?;
|
||||||
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut file = fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
@@ -717,4 +755,117 @@ mod tests {
|
|||||||
store.list_pending("").unwrap_err();
|
store.list_pending("").unwrap_err();
|
||||||
store.upsert_request("", "u1", None).unwrap_err();
|
store.upsert_request("", "u1", None).unwrap_err();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_allow_from_removes_all_entries() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
store.approve("telegram", &r1.code).unwrap();
|
||||||
|
|
||||||
|
let list = store.read_allow_from("telegram").unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
|
||||||
|
store.clear_allow_from("telegram").unwrap();
|
||||||
|
let list = store.read_allow_from("telegram").unwrap();
|
||||||
|
assert!(list.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_pending_removes_all_requests() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
store
|
||||||
|
.upsert_request("telegram", "user1", Some(serde_json::json!({"chat_id": 1})))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.upsert_request("telegram", "user2", Some(serde_json::json!({"chat_id": 2})))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let requests = store.list_pending("telegram").unwrap();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
|
||||||
|
store.clear_pending("telegram").unwrap();
|
||||||
|
let requests = store.list_pending("telegram").unwrap();
|
||||||
|
assert!(requests.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_allow_from_allows_new_approval() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
store.approve("telegram", &r1.code).unwrap();
|
||||||
|
|
||||||
|
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
|
||||||
|
store.clear_allow_from("telegram").unwrap();
|
||||||
|
|
||||||
|
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_allow_from_on_nonexistent_file() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
// No requests created, so allow_from file doesn't exist
|
||||||
|
let result = store.clear_allow_from("telegram");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// After clearing, should return empty list
|
||||||
|
let list = store.read_allow_from("telegram").unwrap();
|
||||||
|
assert!(list.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_pending_on_nonexistent_file() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
// No requests created, so pairing file doesn't exist
|
||||||
|
let result = store.clear_pending("telegram");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// After clearing, should return empty list
|
||||||
|
let requests = store.list_pending("telegram").unwrap();
|
||||||
|
assert!(requests.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_and_reapprove_workflow() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
|
||||||
|
// Step 1: Create and approve user1
|
||||||
|
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
store.approve("telegram", &r1.code).unwrap();
|
||||||
|
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
|
||||||
|
// Step 2: Simulate credential refresh by clearing pairing state
|
||||||
|
store.clear_allow_from("telegram").unwrap();
|
||||||
|
store.clear_pending("telegram").unwrap();
|
||||||
|
|
||||||
|
// Step 3: Verify user1 is no longer approved and no pending requests exist
|
||||||
|
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
let requests = store.list_pending("telegram").unwrap();
|
||||||
|
assert!(requests.is_empty());
|
||||||
|
|
||||||
|
// Step 4: Create new pairing request and approve user1 again
|
||||||
|
let r2 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
assert!(r2.created); // Should be a new request
|
||||||
|
store.approve("telegram", &r2.code).unwrap();
|
||||||
|
assert!(store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_one_channel_doesnt_affect_other() {
|
||||||
|
let (store, _) = test_store();
|
||||||
|
|
||||||
|
// Approve users on two channels
|
||||||
|
let r1 = store.upsert_request("telegram", "user1", None).unwrap();
|
||||||
|
store.approve("telegram", &r1.code).unwrap();
|
||||||
|
|
||||||
|
let r2 = store.upsert_request("discord", "user2", None).unwrap();
|
||||||
|
store.approve("discord", &r2.code).unwrap();
|
||||||
|
|
||||||
|
// Clear only telegram
|
||||||
|
store.clear_allow_from("telegram").unwrap();
|
||||||
|
|
||||||
|
// Verify telegram is cleared but discord is not
|
||||||
|
assert!(!store.is_sender_allowed("telegram", "user1", None).unwrap());
|
||||||
|
assert!(store.is_sender_allowed("discord", "user2", None).unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-16
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
|
|||||||
.save_secret("http_webhook_secret", &SecretString::from(secret))
|
.save_secret("http_webhook_secret", &SecretString::from(secret))
|
||||||
.await?;
|
.await?;
|
||||||
print_success("Webhook secret generated and saved to database");
|
print_success("Webhook secret generated and saved to database");
|
||||||
print_info(http_webhook_secret_hint());
|
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
||||||
@@ -535,10 +535,6 @@ pub fn generate_webhook_secret() -> String {
|
|||||||
generate_secret_with_length(32)
|
generate_secret_with_length(32)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_webhook_secret_hint() -> &'static str {
|
|
||||||
"The secret is stored in the encrypted secrets database and will be loaded automatically on startup."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_e164(account: &str) -> Result<(), String> {
|
fn validate_e164(account: &str) -> Result<(), String> {
|
||||||
if !account.starts_with('+') {
|
if !account.starts_with('+') {
|
||||||
return Err("E.164 account must start with '+'".to_string());
|
return Err("E.164 account must start with '+'".to_string());
|
||||||
@@ -1140,9 +1136,8 @@ mod tests {
|
|||||||
|
|
||||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
|
||||||
use crate::setup::channels::{
|
use crate::setup::channels::{
|
||||||
SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
|
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
|
||||||
substitute_validation_placeholders, validate_cloudflare_token_format,
|
validate_cloudflare_token_format, validate_public_https_url,
|
||||||
validate_public_https_url,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_secrets_context() -> SecretsContext {
|
fn test_secrets_context() -> SecretsContext {
|
||||||
@@ -1342,12 +1337,4 @@ mod tests {
|
|||||||
.to_string();
|
.to_string();
|
||||||
assert!(err.contains("DNS resolution failed"));
|
assert!(err.contains("DNS resolution failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_http_webhook_secret_hint_reflects_current_behavior() {
|
|
||||||
let hint = http_webhook_secret_hint();
|
|
||||||
assert!(hint.contains("encrypted secrets database"));
|
|
||||||
assert!(hint.contains("loaded automatically on startup"));
|
|
||||||
assert!(!hint.contains("ironclaw secret get"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,432 +0,0 @@
|
|||||||
//! Fault injection framework for testing retry, failover, and circuit breaker behavior.
|
|
||||||
//!
|
|
||||||
//! Provides [`FaultInjector`] which can be attached to [`StubLlm`](super::StubLlm) to
|
|
||||||
//! produce configurable error sequences, random failures, and delays.
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//!
|
|
||||||
//! ```rust,no_run
|
|
||||||
//! use ironclaw::testing::fault_injection::*;
|
|
||||||
//!
|
|
||||||
//! // Fail twice with transient errors, then succeed
|
|
||||||
//! let injector = FaultInjector::sequence([
|
|
||||||
//! FaultAction::Fail(FaultType::RequestFailed),
|
|
||||||
//! FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
|
|
||||||
//! FaultAction::Succeed,
|
|
||||||
//! ]);
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::llm::error::LlmError;
|
|
||||||
|
|
||||||
/// The type of fault to inject.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum FaultType {
|
|
||||||
/// Transient request failure (retryable).
|
|
||||||
RequestFailed,
|
|
||||||
/// Rate limited with optional retry-after duration.
|
|
||||||
RateLimited { retry_after: Option<Duration> },
|
|
||||||
/// Authentication failure (non-retryable).
|
|
||||||
AuthFailed,
|
|
||||||
/// Invalid response from provider (retryable).
|
|
||||||
InvalidResponse,
|
|
||||||
/// I/O error (retryable).
|
|
||||||
IoError,
|
|
||||||
/// Context length exceeded (non-retryable).
|
|
||||||
ContextLengthExceeded,
|
|
||||||
/// Session expired (transient for circuit breaker, not retryable).
|
|
||||||
SessionExpired,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FaultType {
|
|
||||||
/// Convert to the corresponding `LlmError`.
|
|
||||||
pub fn to_llm_error(&self, provider: &str) -> LlmError {
|
|
||||||
match self {
|
|
||||||
FaultType::RequestFailed => LlmError::RequestFailed {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
reason: "injected fault: request failed".to_string(),
|
|
||||||
},
|
|
||||||
FaultType::RateLimited { retry_after } => LlmError::RateLimited {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
retry_after: *retry_after,
|
|
||||||
},
|
|
||||||
FaultType::AuthFailed => LlmError::AuthFailed {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
},
|
|
||||||
FaultType::InvalidResponse => LlmError::InvalidResponse {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
reason: "injected fault: invalid response".to_string(),
|
|
||||||
},
|
|
||||||
FaultType::IoError => LlmError::Io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::ConnectionReset,
|
|
||||||
"injected fault: connection reset",
|
|
||||||
)),
|
|
||||||
FaultType::ContextLengthExceeded => LlmError::ContextLengthExceeded {
|
|
||||||
used: 100_000,
|
|
||||||
limit: 50_000,
|
|
||||||
},
|
|
||||||
FaultType::SessionExpired => LlmError::SessionExpired {
|
|
||||||
provider: provider.to_string(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action to take on a given call.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum FaultAction {
|
|
||||||
/// Return a successful response.
|
|
||||||
Succeed,
|
|
||||||
/// Return an error of the given type.
|
|
||||||
Fail(FaultType),
|
|
||||||
/// Sleep for the given duration, then succeed.
|
|
||||||
Delay(Duration),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the fault sequence is consumed.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum FaultMode {
|
|
||||||
/// Play the sequence once, then succeed for all subsequent calls.
|
|
||||||
SequenceOnce,
|
|
||||||
/// Loop the sequence forever.
|
|
||||||
SequenceLoop,
|
|
||||||
/// Fail randomly at the given rate (0.0 = never, 1.0 = always) with
|
|
||||||
/// the specified fault type. Uses a seeded RNG for reproducibility.
|
|
||||||
/// The seed is stored so that [`FaultInjector::reset()`] can re-initialize
|
|
||||||
/// the RNG for test reproducibility.
|
|
||||||
Random {
|
|
||||||
error_rate: f64,
|
|
||||||
fault: FaultType,
|
|
||||||
seed: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A configurable fault injector for [`StubLlm`](super::StubLlm).
|
|
||||||
///
|
|
||||||
/// Thread-safe: uses atomic call counter and mutex-protected RNG.
|
|
||||||
pub struct FaultInjector {
|
|
||||||
actions: Vec<FaultAction>,
|
|
||||||
mode: FaultMode,
|
|
||||||
call_index: AtomicU32,
|
|
||||||
/// Seeded RNG for Random mode, behind Mutex for Sync.
|
|
||||||
rng_state: Mutex<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for FaultInjector {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("FaultInjector")
|
|
||||||
.field("call_index", &self.call_index.load(Ordering::Relaxed))
|
|
||||||
.field("mode", &self.mode)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FaultInjector {
|
|
||||||
/// Create a fault injector that plays actions once, then succeeds.
|
|
||||||
pub fn sequence(actions: impl IntoIterator<Item = FaultAction>) -> Self {
|
|
||||||
Self {
|
|
||||||
actions: actions.into_iter().collect(),
|
|
||||||
mode: FaultMode::SequenceOnce,
|
|
||||||
call_index: AtomicU32::new(0),
|
|
||||||
rng_state: Mutex::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a fault injector that loops the action sequence forever.
|
|
||||||
pub fn sequence_loop(actions: impl IntoIterator<Item = FaultAction>) -> Self {
|
|
||||||
Self {
|
|
||||||
actions: actions.into_iter().collect(),
|
|
||||||
mode: FaultMode::SequenceLoop,
|
|
||||||
call_index: AtomicU32::new(0),
|
|
||||||
rng_state: Mutex::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a fault injector with random failures at the given rate.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if `error_rate` is not in `0.0..=1.0` or is NaN.
|
|
||||||
///
|
|
||||||
/// The seed is guarded against zero, which is a fixed point for xorshift.
|
|
||||||
pub fn random(error_rate: f64, fault: FaultType, seed: u64) -> Self {
|
|
||||||
assert!(
|
|
||||||
!error_rate.is_nan() && (0.0..=1.0).contains(&error_rate),
|
|
||||||
"error_rate must be in 0.0..=1.0 and not NaN, got {error_rate}"
|
|
||||||
);
|
|
||||||
let seed = if seed == 0 { 1 } else { seed };
|
|
||||||
Self {
|
|
||||||
actions: Vec::new(),
|
|
||||||
mode: FaultMode::Random {
|
|
||||||
error_rate,
|
|
||||||
fault,
|
|
||||||
seed,
|
|
||||||
},
|
|
||||||
call_index: AtomicU32::new(0),
|
|
||||||
rng_state: Mutex::new(seed),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the action for the next call.
|
|
||||||
pub fn next_action(&self) -> FaultAction {
|
|
||||||
let index = self.call_index.fetch_add(1, Ordering::Relaxed) as usize;
|
|
||||||
|
|
||||||
match &self.mode {
|
|
||||||
FaultMode::SequenceOnce => {
|
|
||||||
if index < self.actions.len() {
|
|
||||||
self.actions[index].clone()
|
|
||||||
} else {
|
|
||||||
FaultAction::Succeed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FaultMode::SequenceLoop => {
|
|
||||||
if self.actions.is_empty() {
|
|
||||||
FaultAction::Succeed
|
|
||||||
} else {
|
|
||||||
self.actions[index % self.actions.len()].clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FaultMode::Random {
|
|
||||||
error_rate, fault, ..
|
|
||||||
} => {
|
|
||||||
// Simple xorshift64 PRNG for reproducible randomness.
|
|
||||||
let random_val = {
|
|
||||||
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
|
|
||||||
*state ^= *state << 13;
|
|
||||||
*state ^= *state >> 7;
|
|
||||||
*state ^= *state << 17;
|
|
||||||
(*state as f64) / (u64::MAX as f64)
|
|
||||||
};
|
|
||||||
if random_val <= *error_rate {
|
|
||||||
FaultAction::Fail(fault.clone())
|
|
||||||
} else {
|
|
||||||
FaultAction::Succeed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the total number of calls made.
|
|
||||||
pub fn call_count(&self) -> u32 {
|
|
||||||
self.call_index.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the injector to its initial state.
|
|
||||||
///
|
|
||||||
/// For `Random` mode, re-initializes the RNG from the stored seed,
|
|
||||||
/// which is useful for test reproducibility.
|
|
||||||
/// For all modes, resets the call counter to zero.
|
|
||||||
pub fn reset(&self) {
|
|
||||||
self.call_index.store(0, Ordering::Relaxed);
|
|
||||||
if let FaultMode::Random { seed, .. } = &self.mode {
|
|
||||||
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
|
|
||||||
*state = *seed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sequence_once_plays_then_succeeds() {
|
|
||||||
let injector = FaultInjector::sequence([
|
|
||||||
FaultAction::Fail(FaultType::RequestFailed),
|
|
||||||
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
|
|
||||||
FaultAction::Succeed,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// First two calls should fail
|
|
||||||
assert!(matches!(
|
|
||||||
injector.next_action(),
|
|
||||||
FaultAction::Fail(FaultType::RequestFailed)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
injector.next_action(),
|
|
||||||
FaultAction::Fail(FaultType::RateLimited { .. })
|
|
||||||
));
|
|
||||||
// Third call is explicit succeed
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
// Beyond sequence: implicit succeed
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
assert_eq!(injector.call_count(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sequence_loop_repeats() {
|
|
||||||
let injector = FaultInjector::sequence_loop([
|
|
||||||
FaultAction::Fail(FaultType::RequestFailed),
|
|
||||||
FaultAction::Succeed,
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn random_mode_is_deterministic_with_seed() {
|
|
||||||
let injector1 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
||||||
let injector2 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
||||||
|
|
||||||
let results1: Vec<bool> = (0..20)
|
|
||||||
.map(|_| matches!(injector1.next_action(), FaultAction::Fail(_)))
|
|
||||||
.collect();
|
|
||||||
let results2: Vec<bool> = (0..20)
|
|
||||||
.map(|_| matches!(injector2.next_action(), FaultAction::Fail(_)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(results1, results2, "Same seed should produce same sequence");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fault_type_produces_correct_llm_errors() {
|
|
||||||
let provider = "test-provider";
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::RequestFailed.to_llm_error(provider),
|
|
||||||
LlmError::RequestFailed { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::RateLimited {
|
|
||||||
retry_after: Some(Duration::from_secs(5))
|
|
||||||
}
|
|
||||||
.to_llm_error(provider),
|
|
||||||
LlmError::RateLimited { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::AuthFailed.to_llm_error(provider),
|
|
||||||
LlmError::AuthFailed { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::InvalidResponse.to_llm_error(provider),
|
|
||||||
LlmError::InvalidResponse { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::IoError.to_llm_error(provider),
|
|
||||||
LlmError::Io(_)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::ContextLengthExceeded.to_llm_error(provider),
|
|
||||||
LlmError::ContextLengthExceeded { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
FaultType::SessionExpired.to_llm_error(provider),
|
|
||||||
LlmError::SessionExpired { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delay_action_exists() {
|
|
||||||
let injector = FaultInjector::sequence([FaultAction::Delay(Duration::from_millis(100))]);
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Delay(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn random_seed_zero_does_not_always_fail() {
|
|
||||||
// seed=0 is a fixed point for xorshift; the constructor guards it to 1.
|
|
||||||
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 0);
|
|
||||||
let failures = (0..100)
|
|
||||||
.filter(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
||||||
.count();
|
|
||||||
assert!(failures < 100, "seed=0 must not produce stuck RNG");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_sequence_always_succeeds() {
|
|
||||||
let injector = FaultInjector::sequence([]);
|
|
||||||
for _ in 0..10 {
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reset_restores_random_rng_from_stored_seed() {
|
|
||||||
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
|
|
||||||
let run1: Vec<bool> = (0..20)
|
|
||||||
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
injector.reset();
|
|
||||||
assert_eq!(injector.call_count(), 0);
|
|
||||||
|
|
||||||
let run2: Vec<bool> = (0..20)
|
|
||||||
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(run1, run2, "reset() should reproduce the same sequence");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
|
|
||||||
fn random_rejects_error_rate_above_one() {
|
|
||||||
FaultInjector::random(1.5, FaultType::RequestFailed, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
|
|
||||||
fn random_rejects_negative_error_rate() {
|
|
||||||
FaultInjector::random(-0.1, FaultType::RequestFailed, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "error_rate must be in 0.0..=1.0 and not NaN")]
|
|
||||||
fn random_rejects_nan_error_rate() {
|
|
||||||
FaultInjector::random(f64::NAN, FaultType::RequestFailed, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_rate_one_always_fails() {
|
|
||||||
let injector = FaultInjector::random(1.0, FaultType::RequestFailed, 42);
|
|
||||||
for _ in 0..100 {
|
|
||||||
assert!(
|
|
||||||
matches!(injector.next_action(), FaultAction::Fail(_)),
|
|
||||||
"error_rate=1.0 must always produce failures"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_rate_zero_never_fails() {
|
|
||||||
let injector = FaultInjector::random(0.0, FaultType::RequestFailed, 42);
|
|
||||||
for _ in 0..100 {
|
|
||||||
assert!(
|
|
||||||
matches!(injector.next_action(), FaultAction::Succeed),
|
|
||||||
"error_rate=0.0 must never produce failures"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delay_action_pauses_execution() {
|
|
||||||
tokio::time::pause();
|
|
||||||
let injector = FaultInjector::sequence([
|
|
||||||
FaultAction::Delay(Duration::from_secs(10)),
|
|
||||||
FaultAction::Succeed,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// First action is a delay
|
|
||||||
let action = injector.next_action();
|
|
||||||
assert!(matches!(action, FaultAction::Delay(d) if d == Duration::from_secs(10)));
|
|
||||||
|
|
||||||
// Simulate what StubLlm does: sleep then succeed
|
|
||||||
if let FaultAction::Delay(d) = action {
|
|
||||||
let start = tokio::time::Instant::now();
|
|
||||||
tokio::time::sleep(d).await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
assert!(
|
|
||||||
elapsed >= Duration::from_secs(10),
|
|
||||||
"delay should have paused for at least 10s, got {elapsed:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next action succeeds
|
|
||||||
assert!(matches!(injector.next_action(), FaultAction::Succeed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-66
@@ -19,11 +19,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod fault_injection;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -86,9 +84,6 @@ pub struct StubLlm {
|
|||||||
call_count: AtomicU32,
|
call_count: AtomicU32,
|
||||||
should_fail: AtomicBool,
|
should_fail: AtomicBool,
|
||||||
error_kind: StubErrorKind,
|
error_kind: StubErrorKind,
|
||||||
/// Optional fault injector for fine-grained failure control.
|
|
||||||
/// When set, takes precedence over the `should_fail` / `error_kind` fields.
|
|
||||||
fault_injector: Option<Arc<fault_injection::FaultInjector>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StubLlm {
|
impl StubLlm {
|
||||||
@@ -100,7 +95,6 @@ impl StubLlm {
|
|||||||
call_count: AtomicU32::new(0),
|
call_count: AtomicU32::new(0),
|
||||||
should_fail: AtomicBool::new(false),
|
should_fail: AtomicBool::new(false),
|
||||||
error_kind: StubErrorKind::Transient,
|
error_kind: StubErrorKind::Transient,
|
||||||
fault_injector: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +106,6 @@ impl StubLlm {
|
|||||||
call_count: AtomicU32::new(0),
|
call_count: AtomicU32::new(0),
|
||||||
should_fail: AtomicBool::new(true),
|
should_fail: AtomicBool::new(true),
|
||||||
error_kind: StubErrorKind::Transient,
|
error_kind: StubErrorKind::Transient,
|
||||||
fault_injector: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +117,6 @@ impl StubLlm {
|
|||||||
call_count: AtomicU32::new(0),
|
call_count: AtomicU32::new(0),
|
||||||
should_fail: AtomicBool::new(true),
|
should_fail: AtomicBool::new(true),
|
||||||
error_kind: StubErrorKind::NonTransient,
|
error_kind: StubErrorKind::NonTransient,
|
||||||
fault_injector: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,39 +131,11 @@ impl StubLlm {
|
|||||||
self.call_count.load(Ordering::Relaxed)
|
self.call_count.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a fault injector for fine-grained failure control.
|
|
||||||
///
|
|
||||||
/// When set, the injector's `next_action()` is consulted on every call,
|
|
||||||
/// taking precedence over the `should_fail` / `error_kind` fields.
|
|
||||||
pub fn with_fault_injector(mut self, injector: Arc<fault_injection::FaultInjector>) -> Self {
|
|
||||||
self.fault_injector = Some(injector);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle whether calls should fail at runtime.
|
/// Toggle whether calls should fail at runtime.
|
||||||
pub fn set_failing(&self, fail: bool) {
|
pub fn set_failing(&self, fail: bool) {
|
||||||
self.should_fail.store(fail, Ordering::Relaxed);
|
self.should_fail.store(fail, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check the fault injector or should_fail flag, returning an error if
|
|
||||||
/// the call should fail, or None if it should succeed.
|
|
||||||
async fn check_faults(&self) -> Option<LlmError> {
|
|
||||||
if let Some(ref injector) = self.fault_injector {
|
|
||||||
match injector.next_action() {
|
|
||||||
fault_injection::FaultAction::Fail(fault) => {
|
|
||||||
return Some(fault.to_llm_error(&self.model_name));
|
|
||||||
}
|
|
||||||
fault_injection::FaultAction::Delay(duration) => {
|
|
||||||
tokio::time::sleep(duration).await;
|
|
||||||
}
|
|
||||||
fault_injection::FaultAction::Succeed => {}
|
|
||||||
}
|
|
||||||
} else if self.should_fail.load(Ordering::Relaxed) {
|
|
||||||
return Some(self.make_error());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_error(&self) -> LlmError {
|
fn make_error(&self) -> LlmError {
|
||||||
match self.error_kind {
|
match self.error_kind {
|
||||||
StubErrorKind::Transient => LlmError::RequestFailed {
|
StubErrorKind::Transient => LlmError::RequestFailed {
|
||||||
@@ -204,8 +168,8 @@ impl LlmProvider for StubLlm {
|
|||||||
|
|
||||||
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
self.call_count.fetch_add(1, Ordering::Relaxed);
|
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||||
if let Some(err) = self.check_faults().await {
|
if self.should_fail.load(Ordering::Relaxed) {
|
||||||
return Err(err);
|
return Err(self.make_error());
|
||||||
}
|
}
|
||||||
Ok(CompletionResponse {
|
Ok(CompletionResponse {
|
||||||
content: self.response.clone(),
|
content: self.response.clone(),
|
||||||
@@ -222,8 +186,8 @@ impl LlmProvider for StubLlm {
|
|||||||
_request: ToolCompletionRequest,
|
_request: ToolCompletionRequest,
|
||||||
) -> Result<ToolCompletionResponse, LlmError> {
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
self.call_count.fetch_add(1, Ordering::Relaxed);
|
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||||
if let Some(err) = self.check_faults().await {
|
if self.should_fail.load(Ordering::Relaxed) {
|
||||||
return Err(err);
|
return Err(self.make_error());
|
||||||
}
|
}
|
||||||
Ok(ToolCompletionResponse {
|
Ok(ToolCompletionResponse {
|
||||||
content: Some(self.response.clone()),
|
content: Some(self.response.clone()),
|
||||||
@@ -492,7 +456,6 @@ impl TestHarnessBuilder {
|
|||||||
http_interceptor: None,
|
http_interceptor: None,
|
||||||
transcription: None,
|
transcription: None,
|
||||||
document_extraction: None,
|
document_extraction: None,
|
||||||
builder: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TestHarness {
|
TestHarness {
|
||||||
@@ -1545,29 +1508,4 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("update actuals");
|
.expect("update actuals");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stub_llm_fault_injector_sequence() {
|
|
||||||
use crate::llm::LlmProvider;
|
|
||||||
use crate::testing::fault_injection::{FaultAction, FaultInjector, FaultType};
|
|
||||||
|
|
||||||
let injector = Arc::new(FaultInjector::sequence([
|
|
||||||
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
|
|
||||||
FaultAction::Succeed,
|
|
||||||
]));
|
|
||||||
|
|
||||||
let stub = StubLlm::new("hello").with_fault_injector(injector);
|
|
||||||
|
|
||||||
let req = crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user("hi")]);
|
|
||||||
|
|
||||||
// First call should fail with RateLimited
|
|
||||||
let result = stub.complete(req.clone()).await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(matches!(result.unwrap_err(), LlmError::RateLimited { .. }));
|
|
||||||
|
|
||||||
// Second call should succeed
|
|
||||||
let result = stub.complete(req).await;
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap().content, "hello");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-61
@@ -837,7 +837,7 @@ impl Tool for HttpTool {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if has_credentials {
|
if has_credentials {
|
||||||
return ApprovalRequirement::UnlessAutoApproved;
|
return ApprovalRequirement::Always;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET requests (or missing method, since GET is the default) are low-risk
|
// GET requests (or missing method, since GET is the default) are low-risk
|
||||||
@@ -1093,31 +1093,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auth_header_object_format_returns_unless_auto_approved() {
|
fn test_auth_header_object_format_returns_always() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data",
|
"url": "https://api.example.com/data",
|
||||||
"headers": {"Authorization": "Bearer token123"}
|
"headers": {"Authorization": "Bearer token123"}
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auth_header_array_format_returns_unless_auto_approved() {
|
fn test_auth_header_array_format_returns_always() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data",
|
"url": "https://api.example.com/data",
|
||||||
"headers": [{"name": "Authorization", "value": "Bearer token123"}]
|
"headers": [{"name": "Authorization", "value": "Bearer token123"}]
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1130,10 +1124,7 @@ mod tests {
|
|||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {"AUTHORIZATION": "Bearer x"}
|
"headers": {"AUTHORIZATION": "Bearer x"}
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
|
|
||||||
// Array format with mixed case
|
// Array format with mixed case
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
@@ -1141,10 +1132,7 @@ mod tests {
|
|||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": [{"name": "X-Api-Key", "value": "key123"}]
|
"headers": [{"name": "X-Api-Key", "value": "key123"}]
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1173,8 +1161,8 @@ mod tests {
|
|||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(¶ms),
|
tool.requires_approval(¶ms),
|
||||||
ApprovalRequirement::UnlessAutoApproved,
|
ApprovalRequirement::Always,
|
||||||
"Header '{}' should trigger UnlessAutoApproved approval",
|
"Header '{}' should trigger Always approval",
|
||||||
header_name
|
header_name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1215,7 +1203,7 @@ mod tests {
|
|||||||
// ── Credential registry approval tests ─────────────────────────────
|
// ── Credential registry approval tests ─────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_host_with_credential_mapping_returns_unless_auto_approved() {
|
fn test_host_with_credential_mapping_returns_always() {
|
||||||
use crate::secrets::CredentialMapping;
|
use crate::secrets::CredentialMapping;
|
||||||
use crate::tools::wasm::SharedCredentialRegistry;
|
use crate::tools::wasm::SharedCredentialRegistry;
|
||||||
|
|
||||||
@@ -1235,10 +1223,7 @@ mod tests {
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.openai.com/v1/models"
|
"url": "https://api.openai.com/v1/models"
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1258,55 +1243,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_url_query_param_credential_returns_unless_auto_approved() {
|
fn test_url_query_param_credential_returns_always() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://api.example.com/data?api_key=secret123"
|
"url": "https://api.example.com/data?api_key=secret123"
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bearer_value_in_custom_header_returns_unless_auto_approved() {
|
fn test_bearer_value_in_custom_header_returns_always() {
|
||||||
let tool = HttpTool::new();
|
let tool = HttpTool::new();
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
|
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression test: credentialed HTTP requests must return
|
|
||||||
/// `UnlessAutoApproved` (not `Always`) so that the session auto-approve
|
|
||||||
/// set is respected when the user says "always".
|
|
||||||
#[test]
|
|
||||||
fn test_credentialed_requests_respect_auto_approve() {
|
|
||||||
let tool = HttpTool::new();
|
|
||||||
|
|
||||||
// Manual credentials (Authorization header)
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"method": "GET",
|
|
||||||
"url": "https://api.github.com/orgs/Casa",
|
|
||||||
"headers": {"Authorization": "Bearer ghp_abc123"}
|
|
||||||
});
|
|
||||||
// Must NOT be Always — Always ignores the session auto-approve set
|
|
||||||
assert_ne!(
|
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::Always,
|
|
||||||
"Credentialed HTTP requests must not return Always; use UnlessAutoApproved"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+119
-181
@@ -1005,8 +1005,7 @@ impl Tool for JobStatusTool {
|
|||||||
"created_at": job_ctx.created_at.to_rfc3339(),
|
"created_at": job_ctx.created_at.to_rfc3339(),
|
||||||
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
|
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
|
||||||
"completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()),
|
"completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()),
|
||||||
"actual_cost": job_ctx.actual_cost.to_string(),
|
"actual_cost": job_ctx.actual_cost.to_string()
|
||||||
"fallback_deliverable": job_ctx.metadata.get("fallback_deliverable"),
|
|
||||||
});
|
});
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
}
|
}
|
||||||
@@ -1385,7 +1384,7 @@ mod tests {
|
|||||||
let tool = CreateJobTool::new(manager.clone());
|
let tool = CreateJobTool::new(manager.clone());
|
||||||
|
|
||||||
// Without sandbox deps, it should use the local path
|
// Without sandbox deps, it should use the local path
|
||||||
assert!(!tool.sandbox_enabled()); // safety: test
|
assert!(!tool.sandbox_enabled());
|
||||||
|
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"title": "Test Job",
|
"title": "Test Job",
|
||||||
@@ -1393,13 +1392,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); // safety: test
|
let job_id = result.result.get("job_id").unwrap().as_str().unwrap();
|
||||||
assert!(!job_id.is_empty()); // safety: test
|
assert!(!job_id.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
result.result.get("status").unwrap().as_str().unwrap(),
|
||||||
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
|
|
||||||
"pending"
|
"pending"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1411,11 +1409,11 @@ mod tests {
|
|||||||
// Without sandbox
|
// Without sandbox
|
||||||
let tool = CreateJobTool::new(Arc::clone(&manager));
|
let tool = CreateJobTool::new(Arc::clone(&manager));
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
|
let props = schema.get("properties").unwrap().as_object().unwrap();
|
||||||
assert!(props.contains_key("title")); // safety: test
|
assert!(props.contains_key("title"));
|
||||||
assert!(props.contains_key("description")); // safety: test
|
assert!(props.contains_key("description"));
|
||||||
assert!(!props.contains_key("wait")); // safety: test
|
assert!(!props.contains_key("wait"));
|
||||||
assert!(!props.contains_key("mode")); // safety: test
|
assert!(!props.contains_key("mode"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1424,7 +1422,7 @@ mod tests {
|
|||||||
|
|
||||||
// Without sandbox: default timeout
|
// Without sandbox: default timeout
|
||||||
let tool = CreateJobTool::new(Arc::clone(&manager));
|
let tool = CreateJobTool::new(Arc::clone(&manager));
|
||||||
assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); // safety: test
|
assert_eq!(tool.execution_timeout(), Duration::from_secs(30));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1457,23 +1455,23 @@ mod tests {
|
|||||||
let manager = Arc::new(ContextManager::new(5));
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
|
|
||||||
// Create some jobs
|
// Create some jobs
|
||||||
manager.create_job("Job 1", "Desc 1").await.unwrap(); // safety: test
|
manager.create_job("Job 1", "Desc 1").await.unwrap();
|
||||||
manager.create_job("Job 2", "Desc 2").await.unwrap(); // safety: test
|
manager.create_job("Job 2", "Desc 2").await.unwrap();
|
||||||
|
|
||||||
let tool = ListJobsTool::new(manager);
|
let tool = ListJobsTool::new(manager);
|
||||||
|
|
||||||
let params = serde_json::json!({});
|
let params = serde_json::json!({});
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
|
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
|
||||||
assert_eq!(jobs.len(), 2); // safety: test
|
assert_eq!(jobs.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_job_status_tool() {
|
async fn test_job_status_tool() {
|
||||||
let manager = Arc::new(ContextManager::new(5));
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
let job_id = manager.create_job("Test Job", "Description").await.unwrap(); // safety: test
|
let job_id = manager.create_job("Test Job", "Description").await.unwrap();
|
||||||
|
|
||||||
let tool = JobStatusTool::new(manager);
|
let tool = JobStatusTool::new(manager);
|
||||||
|
|
||||||
@@ -1481,11 +1479,10 @@ mod tests {
|
|||||||
"job_id": job_id.to_string()
|
"job_id": job_id.to_string()
|
||||||
});
|
});
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
result.result.get("title").unwrap().as_str().unwrap(),
|
||||||
result.result.get("title").unwrap().as_str().unwrap(), // safety: test
|
|
||||||
"Test Job"
|
"Test Job"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1499,9 +1496,8 @@ mod tests {
|
|||||||
let missing_title = tool
|
let missing_title = tool
|
||||||
.execute(serde_json::json!({ "description": "A test job" }), &ctx)
|
.execute(serde_json::json!({ "description": "A test job" }), &ctx)
|
||||||
.await;
|
.await;
|
||||||
assert!(missing_title.is_err()); // safety: test
|
assert!(missing_title.is_err());
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
missing_title
|
missing_title
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string()
|
.to_string()
|
||||||
@@ -1511,9 +1507,8 @@ mod tests {
|
|||||||
let missing_description = tool
|
let missing_description = tool
|
||||||
.execute(serde_json::json!({ "title": "Test Job" }), &ctx)
|
.execute(serde_json::json!({ "title": "Test Job" }), &ctx)
|
||||||
.await;
|
.await;
|
||||||
assert!(missing_description.is_err()); // safety: test
|
assert!(missing_description.is_err());
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
missing_description
|
missing_description
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string()
|
.to_string()
|
||||||
@@ -1527,19 +1522,19 @@ mod tests {
|
|||||||
let pending_id = manager
|
let pending_id = manager
|
||||||
.create_job_for_user("default", "Pending Job", "Todo")
|
.create_job_for_user("default", "Pending Job", "Todo")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
let completed_id = manager
|
let completed_id = manager
|
||||||
.create_job_for_user("default", "Completed Job", "Done")
|
.create_job_for_user("default", "Completed Job", "Done")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
let failed_id = manager
|
let failed_id = manager
|
||||||
.create_job_for_user("default", "Failed Job", "Oops")
|
.create_job_for_user("default", "Failed Job", "Oops")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.create_job_for_user("other-user", "Other User Job", "Ignore")
|
.create_job_for_user("other-user", "Other User Job", "Ignore")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
manager
|
manager
|
||||||
.update_context(completed_id, |ctx| {
|
.update_context(completed_id, |ctx| {
|
||||||
@@ -1547,44 +1542,41 @@ mod tests {
|
|||||||
ctx.transition_to(JobState::Completed, Some("done".to_string()))
|
ctx.transition_to(JobState::Completed, Some("done".to_string()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.update_context(failed_id, |ctx| {
|
.update_context(failed_id, |ctx| {
|
||||||
ctx.transition_to(JobState::InProgress, None)?;
|
ctx.transition_to(JobState::InProgress, None)?;
|
||||||
ctx.transition_to(JobState::Failed, Some("boom".to_string()))
|
ctx.transition_to(JobState::Failed, Some("boom".to_string()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let tool = ListJobsTool::new(Arc::clone(&manager));
|
let tool = ListJobsTool::new(Arc::clone(&manager));
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap(); // safety: test
|
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
|
||||||
|
|
||||||
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
|
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
|
||||||
assert_eq!(jobs.len(), 3); // safety: test
|
assert_eq!(jobs.len(), 3);
|
||||||
assert!(jobs.iter().any(|job| {
|
assert!(jobs.iter().any(|job| {
|
||||||
// safety: test
|
|
||||||
job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string())
|
job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string())
|
||||||
&& job.get("status").and_then(|v| v.as_str()) == Some("Pending")
|
&& job.get("status").and_then(|v| v.as_str()) == Some("Pending")
|
||||||
}));
|
}));
|
||||||
assert!(jobs.iter().any(|job| {
|
assert!(jobs.iter().any(|job| {
|
||||||
// safety: test
|
|
||||||
job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string())
|
job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string())
|
||||||
&& job.get("status").and_then(|v| v.as_str()) == Some("Completed")
|
&& job.get("status").and_then(|v| v.as_str()) == Some("Completed")
|
||||||
}));
|
}));
|
||||||
assert!(jobs.iter().any(|job| {
|
assert!(jobs.iter().any(|job| {
|
||||||
// safety: test
|
|
||||||
job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string())
|
job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string())
|
||||||
&& job.get("status").and_then(|v| v.as_str()) == Some("Failed")
|
&& job.get("status").and_then(|v| v.as_str()) == Some("Failed")
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let summary = result.result.get("summary").unwrap(); // safety: test
|
let summary = result.result.get("summary").unwrap();
|
||||||
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3)); // safety: test
|
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3));
|
||||||
assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1)); // safety: test
|
assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1));
|
||||||
assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1)); // safety: test
|
assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1));
|
||||||
assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1)); // safety: test
|
assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1593,30 +1585,29 @@ mod tests {
|
|||||||
let job_id = manager
|
let job_id = manager
|
||||||
.create_job_for_user("default", "Transition Job", "Track me")
|
.create_job_for_user("default", "Transition Job", "Track me")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.update_context(job_id, |ctx| {
|
.update_context(job_id, |ctx| {
|
||||||
ctx.transition_to(JobState::InProgress, Some("started".to_string()))?;
|
ctx.transition_to(JobState::InProgress, Some("started".to_string()))?;
|
||||||
ctx.transition_to(JobState::Completed, Some("finished".to_string()))
|
ctx.transition_to(JobState::Completed, Some("finished".to_string()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let tool = JobStatusTool::new(Arc::clone(&manager));
|
let tool = JobStatusTool::new(Arc::clone(&manager));
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
|
||||||
result.result.get("status").and_then(|v| v.as_str()),
|
result.result.get("status").and_then(|v| v.as_str()),
|
||||||
Some("Completed")
|
Some("Completed")
|
||||||
);
|
);
|
||||||
assert!(result.result.get("started_at").unwrap().is_string()); // safety: test
|
assert!(result.result.get("started_at").unwrap().is_string());
|
||||||
assert!(result.result.get("completed_at").unwrap().is_string()); // safety: test
|
assert!(result.result.get("completed_at").unwrap().is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1625,27 +1616,26 @@ mod tests {
|
|||||||
let job_id = manager
|
let job_id = manager
|
||||||
.create_job_for_user("default", "Running Job", "In progress")
|
.create_job_for_user("default", "Running Job", "In progress")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let tool = CancelJobTool::new(Arc::clone(&manager));
|
let tool = CancelJobTool::new(Arc::clone(&manager));
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
|
||||||
result.result.get("status").and_then(|v| v.as_str()),
|
result.result.get("status").and_then(|v| v.as_str()),
|
||||||
Some("cancelled")
|
Some("cancelled")
|
||||||
);
|
);
|
||||||
let updated = manager.get_context(job_id).await.unwrap(); // safety: test
|
let updated = manager.get_context(job_id).await.unwrap();
|
||||||
assert_eq!(updated.state, JobState::Cancelled); // safety: test
|
assert_eq!(updated.state, JobState::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1654,81 +1644,39 @@ mod tests {
|
|||||||
let job_id = manager
|
let job_id = manager
|
||||||
.create_job_for_user("default", "Completed Job", "Already done")
|
.create_job_for_user("default", "Completed Job", "Already done")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.update_context(job_id, |ctx| {
|
.update_context(job_id, |ctx| {
|
||||||
ctx.transition_to(JobState::InProgress, None)?;
|
ctx.transition_to(JobState::InProgress, None)?;
|
||||||
ctx.transition_to(JobState::Completed, Some("done".to_string()))
|
ctx.transition_to(JobState::Completed, Some("done".to_string()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let tool = CancelJobTool::new(Arc::clone(&manager));
|
let tool = CancelJobTool::new(Arc::clone(&manager));
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool
|
let result = tool
|
||||||
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); // safety: test
|
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap();
|
||||||
assert!(error.contains("Cannot cancel job")); // safety: test
|
assert!(error.contains("Cannot cancel job"));
|
||||||
assert!(error.contains("completed")); // safety: test
|
assert!(error.contains("completed"));
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_job_status_includes_fallback_deliverable() {
|
|
||||||
let manager = Arc::new(ContextManager::new(5));
|
|
||||||
let job_id = manager
|
|
||||||
.create_job_for_user("default", "Failing Job", "Will fail")
|
|
||||||
.await
|
|
||||||
.unwrap(); // safety: test
|
|
||||||
|
|
||||||
// Inject a real FallbackDeliverable into the job metadata.
|
|
||||||
let fallback = serde_json::json!({
|
|
||||||
"partial": true,
|
|
||||||
"failure_reason": "max iterations",
|
|
||||||
"last_action": null,
|
|
||||||
"action_stats": { "total": 5, "successful": 3, "failed": 2 },
|
|
||||||
"tokens_used": 1000,
|
|
||||||
"cost": "0.05",
|
|
||||||
"elapsed_secs": 12.5,
|
|
||||||
"repair_attempts": 1,
|
|
||||||
});
|
|
||||||
manager
|
|
||||||
.update_context(job_id, |ctx| {
|
|
||||||
ctx.metadata = serde_json::json!({ "fallback_deliverable": fallback.clone() });
|
|
||||||
Ok::<(), String>(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap() // safety: test
|
|
||||||
.unwrap(); // safety: test
|
|
||||||
|
|
||||||
let tool = JobStatusTool::new(manager);
|
|
||||||
let params = serde_json::json!({ "job_id": job_id.to_string() });
|
|
||||||
let ctx = JobContext::default();
|
|
||||||
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
|
|
||||||
|
|
||||||
let fb = result.result.get("fallback_deliverable").unwrap(); // safety: test
|
|
||||||
assert_eq!(fb.get("partial").unwrap(), true); // safety: test
|
|
||||||
assert_eq!(fb.get("failure_reason").unwrap(), "max iterations"); // safety: test
|
|
||||||
let stats = fb.get("action_stats").unwrap(); // safety: test
|
|
||||||
assert_eq!(stats.get("total").unwrap(), 5); // safety: test
|
|
||||||
assert_eq!(stats.get("successful").unwrap(), 3); // safety: test
|
|
||||||
assert_eq!(stats.get("failed").unwrap(), 2); // safety: test
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_project_dir_auto() {
|
fn test_resolve_project_dir_auto() {
|
||||||
let project_id = Uuid::new_v4();
|
let project_id = Uuid::new_v4();
|
||||||
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); // safety: test
|
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap();
|
||||||
assert!(dir.exists()); // safety: test
|
assert!(dir.exists());
|
||||||
assert!(dir.ends_with(project_id.to_string())); // safety: test
|
assert!(dir.ends_with(project_id.to_string()));
|
||||||
assert_eq!(browse_id, project_id.to_string()); // safety: test
|
assert_eq!(browse_id, project_id.to_string());
|
||||||
|
|
||||||
// Must be under the projects base
|
// Must be under the projects base
|
||||||
let base = projects_base().canonicalize().unwrap(); // safety: test
|
let base = projects_base().canonicalize().unwrap();
|
||||||
assert!(dir.starts_with(&base)); // safety: test
|
assert!(dir.starts_with(&base));
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
@@ -1736,34 +1684,33 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_project_dir_explicit_under_base() {
|
fn test_resolve_project_dir_explicit_under_base() {
|
||||||
let base = projects_base();
|
let base = projects_base();
|
||||||
std::fs::create_dir_all(&base).unwrap(); // safety: test
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
let explicit = base.join("test_explicit_project");
|
let explicit = base.join("test_explicit_project");
|
||||||
// Explicit paths must already exist (no auto-create).
|
// Explicit paths must already exist (no auto-create).
|
||||||
std::fs::create_dir_all(&explicit).unwrap(); // safety: test
|
std::fs::create_dir_all(&explicit).unwrap();
|
||||||
let project_id = Uuid::new_v4();
|
let project_id = Uuid::new_v4();
|
||||||
|
|
||||||
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); // safety: test
|
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap();
|
||||||
assert!(dir.exists()); // safety: test
|
assert!(dir.exists());
|
||||||
assert_eq!(browse_id, "test_explicit_project"); // safety: test
|
assert_eq!(browse_id, "test_explicit_project");
|
||||||
|
|
||||||
let canonical_base = base.canonicalize().unwrap(); // safety: test
|
let canonical_base = base.canonicalize().unwrap();
|
||||||
assert!(dir.starts_with(&canonical_base)); // safety: test
|
assert!(dir.starts_with(&canonical_base));
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&explicit);
|
let _ = std::fs::remove_dir_all(&explicit);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_project_dir_rejects_outside_base() {
|
fn test_resolve_project_dir_rejects_outside_base() {
|
||||||
let tmp = tempfile::tempdir().unwrap(); // safety: test
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let escape_attempt = tmp.path().join("evil_project");
|
let escape_attempt = tmp.path().join("evil_project");
|
||||||
// Don't create it: explicit paths that don't exist are rejected
|
// Don't create it: explicit paths that don't exist are rejected
|
||||||
// before the prefix check even runs.
|
// before the prefix check even runs.
|
||||||
|
|
||||||
let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4());
|
let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4());
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("does not exist"),
|
err.contains("does not exist"),
|
||||||
"expected 'does not exist' error, got: {}",
|
"expected 'does not exist' error, got: {}",
|
||||||
err
|
err
|
||||||
@@ -1773,14 +1720,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_project_dir_rejects_outside_base_existing() {
|
fn test_resolve_project_dir_rejects_outside_base_existing() {
|
||||||
// A directory that exists but is outside the projects base.
|
// A directory that exists but is outside the projects base.
|
||||||
let tmp = tempfile::tempdir().unwrap(); // safety: test
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let outside = tmp.path().to_path_buf();
|
let outside = tmp.path().to_path_buf();
|
||||||
|
|
||||||
let result = resolve_project_dir(Some(outside), Uuid::new_v4());
|
let result = resolve_project_dir(Some(outside), Uuid::new_v4());
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("must be under"),
|
err.contains("must be under"),
|
||||||
"expected 'must be under' error, got: {}",
|
"expected 'must be under' error, got: {}",
|
||||||
err
|
err
|
||||||
@@ -1794,7 +1740,7 @@ mod tests {
|
|||||||
let traversal = base.join("legit").join("..").join("..").join(".ssh");
|
let traversal = base.join("legit").join("..").join("..").join(".ssh");
|
||||||
|
|
||||||
let result = resolve_project_dir(Some(traversal), Uuid::new_v4());
|
let result = resolve_project_dir(Some(traversal), Uuid::new_v4());
|
||||||
assert!(result.is_err(), "traversal path should be rejected"); // safety: test
|
assert!(result.is_err(), "traversal path should be rejected");
|
||||||
|
|
||||||
// Traversal path that actually resolves gets the prefix check.
|
// Traversal path that actually resolves gets the prefix check.
|
||||||
// `base/../` resolves to the parent of projects base, which is outside.
|
// `base/../` resolves to the parent of projects base, which is outside.
|
||||||
@@ -1802,7 +1748,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&base_parent).ok();
|
std::fs::create_dir_all(&base_parent).ok();
|
||||||
if base_parent.exists() {
|
if base_parent.exists() {
|
||||||
let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4());
|
let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4());
|
||||||
assert!(result.is_err(), "path outside base should be rejected"); // safety: test
|
assert!(result.is_err(), "path outside base should be rejected");
|
||||||
let _ = std::fs::remove_dir_all(&base_parent);
|
let _ = std::fs::remove_dir_all(&base_parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1816,9 +1762,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
|
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
|
let props = schema.get("properties").unwrap().as_object().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
props.contains_key("project_dir"),
|
props.contains_key("project_dir"),
|
||||||
"sandbox schema must expose project_dir"
|
"sandbox schema must expose project_dir"
|
||||||
);
|
);
|
||||||
@@ -1833,9 +1778,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
|
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
|
let props = schema.get("properties").unwrap().as_object().unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
props.contains_key("credentials"),
|
props.contains_key("credentials"),
|
||||||
"sandbox schema must expose credentials"
|
"sandbox schema must expose credentials"
|
||||||
);
|
);
|
||||||
@@ -1848,13 +1792,13 @@ mod tests {
|
|||||||
|
|
||||||
// No credentials parameter
|
// No credentials parameter
|
||||||
let params = serde_json::json!({"title": "t", "description": "d"});
|
let params = serde_json::json!({"title": "t", "description": "d"});
|
||||||
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap(); // safety: test
|
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap();
|
||||||
assert!(grants.is_empty()); // safety: test
|
assert!(grants.is_empty());
|
||||||
|
|
||||||
// Empty credentials object
|
// Empty credentials object
|
||||||
let params = serde_json::json!({"credentials": {}});
|
let params = serde_json::json!({"credentials": {}});
|
||||||
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap(); // safety: test
|
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap();
|
||||||
assert!(grants.is_empty()); // safety: test
|
assert!(grants.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1864,10 +1808,9 @@ mod tests {
|
|||||||
|
|
||||||
let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}});
|
let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}});
|
||||||
let result = tool.parse_credentials(¶ms, "user1").await;
|
let result = tool.parse_credentials(¶ms, "user1").await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("no secrets store"),
|
err.contains("no secrets store"),
|
||||||
"expected 'no secrets store' error, got: {}",
|
"expected 'no secrets store' error, got: {}",
|
||||||
err
|
err
|
||||||
@@ -1885,10 +1828,9 @@ mod tests {
|
|||||||
|
|
||||||
let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}});
|
let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}});
|
||||||
let result = tool.parse_credentials(¶ms, "user1").await;
|
let result = tool.parse_credentials(¶ms, "user1").await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("not found"),
|
err.contains("not found"),
|
||||||
"expected 'not found' error, got: {}",
|
"expected 'not found' error, got: {}",
|
||||||
err
|
err
|
||||||
@@ -1910,17 +1852,17 @@ mod tests {
|
|||||||
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
|
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
|
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
|
||||||
|
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"credentials": {"github_token": "GITHUB_TOKEN"}
|
"credentials": {"github_token": "GITHUB_TOKEN"}
|
||||||
});
|
});
|
||||||
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap(); // safety: test
|
let grants = tool.parse_credentials(¶ms, "user1").await.unwrap();
|
||||||
assert_eq!(grants.len(), 1); // safety: test
|
assert_eq!(grants.len(), 1);
|
||||||
assert_eq!(grants[0].secret_name, "github_token"); // safety: test
|
assert_eq!(grants[0].secret_name, "github_token");
|
||||||
assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); // safety: test
|
assert_eq!(grants[0].env_var, "GITHUB_TOKEN");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool {
|
fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool {
|
||||||
@@ -1934,7 +1876,7 @@ mod tests {
|
|||||||
let job_id = cm
|
let job_id = cm
|
||||||
.create_job_for_user("default", "Test Job", "desc")
|
.create_job_for_user("default", "Test Job", "desc")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let queue: PromptQueue =
|
let queue: PromptQueue =
|
||||||
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
@@ -1947,19 +1889,18 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
result.result.get("status").unwrap().as_str().unwrap(),
|
||||||
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
|
|
||||||
"queued"
|
"queued"
|
||||||
);
|
);
|
||||||
|
|
||||||
let q = queue.lock().await;
|
let q = queue.lock().await;
|
||||||
let prompts = q.get(&job_id).unwrap(); // safety: test
|
let prompts = q.get(&job_id).unwrap();
|
||||||
assert_eq!(prompts.len(), 1); // safety: test
|
assert_eq!(prompts.len(), 1);
|
||||||
assert_eq!(prompts[0].content, "What's the status?"); // safety: test
|
assert_eq!(prompts[0].content, "What's the status?");
|
||||||
assert!(!prompts[0].done); // safety: test
|
assert!(!prompts[0].done);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1969,7 +1910,6 @@ mod tests {
|
|||||||
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
let tool = test_prompt_tool(queue);
|
let tool = test_prompt_tool(queue);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
@@ -1988,7 +1928,7 @@ mod tests {
|
|||||||
|
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await;
|
let result = tool.execute(params, &ctx).await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2003,7 +1943,7 @@ mod tests {
|
|||||||
|
|
||||||
let ctx = JobContext::default();
|
let ctx = JobContext::default();
|
||||||
let result = tool.execute(params, &ctx).await;
|
let result = tool.execute(params, &ctx).await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2018,7 +1958,7 @@ mod tests {
|
|||||||
let job_id = cm
|
let job_id = cm
|
||||||
.create_job_for_user("owner-user", "Secret Job", "classified")
|
.create_job_for_user("owner-user", "Secret Job", "classified")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
// We need a Store to construct the tool, but creating one requires
|
// We need a Store to construct the tool, but creating one requires
|
||||||
// a database URL. Instead, test the ownership logic directly:
|
// a database URL. Instead, test the ownership logic directly:
|
||||||
@@ -2028,9 +1968,9 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let job_ctx = cm.get_context(job_id).await.unwrap(); // safety: test
|
let job_ctx = cm.get_context(job_id).await.unwrap();
|
||||||
assert_ne!(job_ctx.user_id, attacker_ctx.user_id); // safety: test
|
assert_ne!(job_ctx.user_id, attacker_ctx.user_id);
|
||||||
assert_eq!(job_ctx.user_id, "owner-user"); // safety: test
|
assert_eq!(job_ctx.user_id, "owner-user");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2051,12 +1991,12 @@ mod tests {
|
|||||||
"required": ["job_id"]
|
"required": ["job_id"]
|
||||||
});
|
});
|
||||||
|
|
||||||
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
|
let props = schema.get("properties").unwrap().as_object().unwrap();
|
||||||
assert!(props.contains_key("job_id")); // safety: test
|
assert!(props.contains_key("job_id"));
|
||||||
assert!(props.contains_key("limit")); // safety: test
|
assert!(props.contains_key("limit"));
|
||||||
let required = schema.get("required").unwrap().as_array().unwrap(); // safety: test
|
let required = schema.get("required").unwrap().as_array().unwrap();
|
||||||
assert_eq!(required.len(), 1); // safety: test
|
assert_eq!(required.len(), 1);
|
||||||
assert_eq!(required[0].as_str().unwrap(), "job_id"); // safety: test
|
assert_eq!(required[0].as_str().unwrap(), "job_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2065,7 +2005,7 @@ mod tests {
|
|||||||
let job_id = cm
|
let job_id = cm
|
||||||
.create_job_for_user("owner-user", "Test Job", "desc")
|
.create_job_for_user("owner-user", "Test Job", "desc")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let queue: PromptQueue =
|
let queue: PromptQueue =
|
||||||
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
@@ -2083,10 +2023,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let result = tool.execute(params, &ctx).await;
|
let result = tool.execute(params, &ctx).await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("does not belong to current user"),
|
err.contains("does not belong to current user"),
|
||||||
"expected ownership error, got: {}",
|
"expected ownership error, got: {}",
|
||||||
err
|
err
|
||||||
@@ -2096,34 +2035,33 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_job_id_full_uuid() {
|
async fn test_resolve_job_id_full_uuid() {
|
||||||
let cm = ContextManager::new(5);
|
let cm = ContextManager::new(5);
|
||||||
let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
|
let job_id = cm.create_job("Test", "Desc").await.unwrap();
|
||||||
|
|
||||||
let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap(); // safety: test
|
let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap();
|
||||||
assert_eq!(resolved, job_id); // safety: test
|
assert_eq!(resolved, job_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_job_id_short_prefix() {
|
async fn test_resolve_job_id_short_prefix() {
|
||||||
let cm = ContextManager::new(5);
|
let cm = ContextManager::new(5);
|
||||||
let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
|
let job_id = cm.create_job("Test", "Desc").await.unwrap();
|
||||||
|
|
||||||
// Use first 8 hex chars (without dashes)
|
// Use first 8 hex chars (without dashes)
|
||||||
let hex = job_id.to_string().replace('-', "");
|
let hex = job_id.to_string().replace('-', "");
|
||||||
let prefix = &hex[..8];
|
let prefix = &hex[..8];
|
||||||
let resolved = resolve_job_id(prefix, &cm).await.unwrap(); // safety: test
|
let resolved = resolve_job_id(prefix, &cm).await.unwrap();
|
||||||
assert_eq!(resolved, job_id); // safety: test
|
assert_eq!(resolved, job_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_job_id_no_match() {
|
async fn test_resolve_job_id_no_match() {
|
||||||
let cm = ContextManager::new(5);
|
let cm = ContextManager::new(5);
|
||||||
cm.create_job("Test", "Desc").await.unwrap(); // safety: test
|
cm.create_job("Test", "Desc").await.unwrap();
|
||||||
|
|
||||||
let result = resolve_job_id("00000000", &cm).await;
|
let result = resolve_job_id("00000000", &cm).await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err().to_string();
|
let err = result.unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
err.contains("no job found"),
|
err.contains("no job found"),
|
||||||
"expected 'no job found', got: {}",
|
"expected 'no job found', got: {}",
|
||||||
err
|
err
|
||||||
@@ -2134,6 +2072,6 @@ mod tests {
|
|||||||
async fn test_resolve_job_id_invalid_input() {
|
async fn test_resolve_job_id_invalid_input() {
|
||||||
let cm = ContextManager::new(5);
|
let cm = ContextManager::new(5);
|
||||||
let result = resolve_job_id("not-hex-at-all!", &cm).await;
|
let result = resolve_job_id("not-hex-at-all!", &cm).await;
|
||||||
assert!(result.is_err()); // safety: test
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+343
-1573
File diff suppressed because it is too large
Load Diff
+19
-121
@@ -1,9 +1,8 @@
|
|||||||
//! On-demand tool discovery (like CLI `--help`).
|
//! On-demand tool discovery (like CLI `--help`).
|
||||||
//!
|
//!
|
||||||
//! Three levels of detail:
|
//! Two levels of detail:
|
||||||
//! - Default: name, description, parameter names (compact ~150 bytes)
|
//! - Default: name, description, parameter names (compact ~150 bytes)
|
||||||
//! - `detail: "summary"`: adds curated rules, notes, and examples
|
//! - `include_schema: true`: adds the full typed JSON Schema
|
||||||
//! - `detail: "schema"` / `include_schema: true`: adds the full typed JSON Schema
|
|
||||||
//!
|
//!
|
||||||
//! Keeps the tools array compact (WASM tools use permissive schemas)
|
//! Keeps the tools array compact (WASM tools use permissive schemas)
|
||||||
//! while allowing precise discovery when needed.
|
//! while allowing precise discovery when needed.
|
||||||
@@ -14,59 +13,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::registry::ToolRegistry;
|
use crate::tools::registry::ToolRegistry;
|
||||||
use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum ToolInfoDetail {
|
|
||||||
Names,
|
|
||||||
Summary,
|
|
||||||
Schema,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToolInfoDetail {
|
|
||||||
fn parse(params: &serde_json::Value) -> Result<Self, ToolError> {
|
|
||||||
if params
|
|
||||||
.get("include_schema")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Ok(Self::Schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
match params.get("detail").and_then(|v| v.as_str()) {
|
|
||||||
None | Some("names") => Ok(Self::Names),
|
|
||||||
Some("summary") => Ok(Self::Summary),
|
|
||||||
Some("schema") => Ok(Self::Schema),
|
|
||||||
Some(other) => Err(ToolError::InvalidParameters(format!(
|
|
||||||
"invalid detail '{other}' (expected 'names', 'summary', or 'schema')"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema_param_names(schema: &serde_json::Value) -> Vec<String> {
|
|
||||||
schema
|
|
||||||
.get("properties")
|
|
||||||
.and_then(|p| p.as_object())
|
|
||||||
.map(|props| props.keys().cloned().collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fallback_summary(schema: &serde_json::Value) -> ToolDiscoverySummary {
|
|
||||||
ToolDiscoverySummary {
|
|
||||||
always_required: schema
|
|
||||||
.get("required")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|required| {
|
|
||||||
required
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.as_str().map(str::to_string))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
..ToolDiscoverySummary::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ToolInfoTool {
|
pub struct ToolInfoTool {
|
||||||
registry: Weak<ToolRegistry>,
|
registry: Weak<ToolRegistry>,
|
||||||
@@ -85,7 +32,8 @@ impl Tool for ToolInfoTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema."
|
"Get info about any tool: description and parameter names. \
|
||||||
|
Set include_schema to true for the full typed parameter schema."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -96,15 +44,9 @@ impl Tool for ToolInfoTool {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Name of the tool to get info about"
|
"description": "Name of the tool to get info about"
|
||||||
},
|
},
|
||||||
"detail": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["names", "summary", "schema"],
|
|
||||||
"description": "Response detail level. 'names' returns parameter names only. 'summary' adds curated rules/examples. 'schema' returns the full discovery schema.",
|
|
||||||
"default": "names"
|
|
||||||
},
|
|
||||||
"include_schema": {
|
"include_schema": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Deprecated compatibility alias for detail='schema'. If true, include the full discovery schema.",
|
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -119,7 +61,10 @@ impl Tool for ToolInfoTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let name = require_str(¶ms, "name")?;
|
let name = require_str(¶ms, "name")?;
|
||||||
let detail = ToolInfoDetail::parse(¶ms)?;
|
let include_schema = params
|
||||||
|
.get("include_schema")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let registry = self.registry.upgrade().ok_or_else(|| {
|
let registry = self.registry.upgrade().ok_or_else(|| {
|
||||||
ToolError::ExecutionFailed(
|
ToolError::ExecutionFailed(
|
||||||
@@ -132,7 +77,13 @@ impl Tool for ToolInfoTool {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let schema = tool.discovery_schema();
|
let schema = tool.discovery_schema();
|
||||||
let param_names = schema_param_names(&schema);
|
|
||||||
|
// Extract just param names from the schema's "properties" keys
|
||||||
|
let param_names: Vec<&str> = schema
|
||||||
|
.get("properties")
|
||||||
|
.and_then(|p| p.as_object())
|
||||||
|
.map(|props| props.keys().map(|k| k.as_str()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut info = serde_json::json!({
|
let mut info = serde_json::json!({
|
||||||
"name": tool.name(),
|
"name": tool.name(),
|
||||||
@@ -140,21 +91,8 @@ impl Tool for ToolInfoTool {
|
|||||||
"parameters": param_names,
|
"parameters": param_names,
|
||||||
});
|
});
|
||||||
|
|
||||||
match detail {
|
if include_schema {
|
||||||
ToolInfoDetail::Names => {}
|
info["schema"] = schema;
|
||||||
ToolInfoDetail::Summary => {
|
|
||||||
let summary = tool
|
|
||||||
.discovery_summary()
|
|
||||||
.unwrap_or_else(|| fallback_summary(&schema));
|
|
||||||
info["summary"] = serde_json::to_value(summary).map_err(|err| {
|
|
||||||
ToolError::ExecutionFailed(format!(
|
|
||||||
"failed to serialize discovery summary: {err}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
ToolInfoDetail::Schema => {
|
|
||||||
info["schema"] = schema;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ToolOutput::success(info, start.elapsed()))
|
Ok(ToolOutput::success(info, start.elapsed()))
|
||||||
@@ -197,30 +135,6 @@ mod tests {
|
|||||||
assert!(info.get("schema").is_none());
|
assert!(info.get("schema").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_tool_info_with_summary() {
|
|
||||||
let registry = Arc::new(ToolRegistry::new());
|
|
||||||
registry.register(Arc::new(EchoTool)).await;
|
|
||||||
|
|
||||||
let tool = ToolInfoTool::new(Arc::downgrade(®istry));
|
|
||||||
let ctx = JobContext::default();
|
|
||||||
let result = tool
|
|
||||||
.execute(
|
|
||||||
serde_json::json!({"name": "echo", "detail": "summary"}),
|
|
||||||
&ctx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let info = &result.result;
|
|
||||||
assert_eq!(info["name"], "echo");
|
|
||||||
assert!(info["summary"].is_object());
|
|
||||||
assert_eq!(
|
|
||||||
info["summary"]["always_required"],
|
|
||||||
serde_json::json!(["message"])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tool_info_with_schema() {
|
async fn test_tool_info_with_schema() {
|
||||||
let registry = Arc::new(ToolRegistry::new());
|
let registry = Arc::new(ToolRegistry::new());
|
||||||
@@ -243,22 +157,6 @@ mod tests {
|
|||||||
assert!(info["schema"]["properties"].is_object());
|
assert!(info["schema"]["properties"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_tool_info_invalid_detail() {
|
|
||||||
let registry = Arc::new(ToolRegistry::new());
|
|
||||||
registry.register(Arc::new(EchoTool)).await;
|
|
||||||
|
|
||||||
let tool = ToolInfoTool::new(Arc::downgrade(®istry));
|
|
||||||
let ctx = JobContext::default();
|
|
||||||
let result = tool
|
|
||||||
.execute(
|
|
||||||
serde_json::json!({"name": "echo", "detail": "verbose"}),
|
|
||||||
&ctx,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tool_info_unknown_tool() {
|
async fn test_tool_info_unknown_tool() {
|
||||||
let registry = Arc::new(ToolRegistry::new());
|
let registry = Arc::new(ToolRegistry::new());
|
||||||
|
|||||||
@@ -291,33 +291,6 @@ mod tests {
|
|||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_execute_empty_tool_name_returns_not_found() {
|
|
||||||
// Regression: execute_tool_with_safety must reject empty tool names
|
|
||||||
// gracefully via ToolError::NotFound (not a panic).
|
|
||||||
let registry = registry_with(vec![]).await;
|
|
||||||
let safety = test_safety();
|
|
||||||
|
|
||||||
let result = execute_tool_with_safety(
|
|
||||||
®istry,
|
|
||||||
&safety,
|
|
||||||
"",
|
|
||||||
&serde_json::json!({}),
|
|
||||||
&test_job_ctx(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
result,
|
|
||||||
Err(crate::error::Error::Tool(
|
|
||||||
crate::error::ToolError::NotFound { .. }
|
|
||||||
))
|
|
||||||
),
|
|
||||||
"Empty tool name should return ToolError::NotFound, got: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_execute_success() {
|
async fn test_execute_success() {
|
||||||
let registry = registry_with(vec![Arc::new(EchoTool)]).await;
|
let registry = registry_with(vec![Arc::new(EchoTool)]).await;
|
||||||
|
|||||||
+42
-205
@@ -288,71 +288,6 @@ impl McpClient {
|
|||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-run the MCP initialize handshake outside the OnceCell cache.
|
|
||||||
///
|
|
||||||
/// This is used for recoverable session-expiry failures when an MCP server
|
|
||||||
/// reports that the current session ID is no longer valid.
|
|
||||||
async fn reinitialize_session(&self) -> Result<InitializeResult, ToolError> {
|
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
|
||||||
session_manager.terminate(&self.server_name).await;
|
|
||||||
session_manager
|
|
||||||
.get_or_create(&self.server_name, &self.server_url)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = McpRequest::initialize(self.next_request_id());
|
|
||||||
let response = self
|
|
||||||
.transport
|
|
||||||
.send(&request, &self.build_request_headers().await?)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(error) = response.error {
|
|
||||||
return Err(ToolError::ExternalService(format!(
|
|
||||||
"MCP initialization error: {} (code {})",
|
|
||||||
error.message, error.code
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let init_result: InitializeResult = response
|
|
||||||
.result
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::ExternalService("No result in initialize response".to_string())
|
|
||||||
})
|
|
||||||
.and_then(|r| {
|
|
||||||
serde_json::from_value(r).map_err(|e| {
|
|
||||||
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
|
||||||
session_manager.mark_initialized(&self.server_name).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let notification = McpRequest::initialized_notification();
|
|
||||||
if let Err(e) = self
|
|
||||||
.transport
|
|
||||||
.send(¬ification, &self.build_request_headers().await?)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::debug!(
|
|
||||||
"Failed to send initialized notification to '{}': {}",
|
|
||||||
self.server_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(init_result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return true when the error looks like a recoverable MCP session expiry.
|
|
||||||
fn is_session_expiry_error(message: &str) -> bool {
|
|
||||||
let lower = message.to_ascii_lowercase();
|
|
||||||
lower.contains("session")
|
|
||||||
&& (lower.contains("400")
|
|
||||||
|| lower.contains("missing session id")
|
|
||||||
|| lower.contains("no valid session id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a request to the MCP server with auth and session headers.
|
/// Send a request to the MCP server with auth and session headers.
|
||||||
/// Automatically attempts token refresh on 401 errors (HTTP transports only).
|
/// Automatically attempts token refresh on 401 errors (HTTP transports only).
|
||||||
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
|
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
|
||||||
@@ -362,26 +297,13 @@ impl McpClient {
|
|||||||
return self.transport.send(&request, &headers).await;
|
return self.transport.send(&request, &headers).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh
|
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh)
|
||||||
// or recoverable session reinitialization).
|
|
||||||
for attempt in 0..2 {
|
for attempt in 0..2 {
|
||||||
let headers = self.build_request_headers().await?;
|
let headers = self.build_request_headers().await?;
|
||||||
let result = self.transport.send(&request, &headers).await;
|
let result = self.transport.send(&request, &headers).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => return Ok(response),
|
Ok(response) => return Ok(response),
|
||||||
Err(ToolError::ExternalService(ref msg))
|
|
||||||
if attempt == 0
|
|
||||||
&& self.session_manager.is_some()
|
|
||||||
&& Self::is_session_expiry_error(msg) =>
|
|
||||||
{
|
|
||||||
tracing::debug!(
|
|
||||||
"MCP session expired, attempting reinitialize for '{}'",
|
|
||||||
self.server_name
|
|
||||||
);
|
|
||||||
self.reinitialize_session().await?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(ToolError::ExternalService(ref msg))
|
Err(ToolError::ExternalService(ref msg))
|
||||||
if msg.contains("401")
|
if msg.contains("401")
|
||||||
|| msg.contains("Unauthorized")
|
|| msg.contains("Unauthorized")
|
||||||
@@ -440,7 +362,47 @@ impl McpClient {
|
|||||||
{
|
{
|
||||||
return Ok(InitializeResult::default());
|
return Ok(InitializeResult::default());
|
||||||
}
|
}
|
||||||
self.reinitialize_session().await
|
if let Some(ref session_manager) = self.session_manager {
|
||||||
|
session_manager
|
||||||
|
.get_or_create(&self.server_name, &self.server_url)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = McpRequest::initialize(self.next_request_id());
|
||||||
|
let response = self.send_request(request).await?;
|
||||||
|
|
||||||
|
if let Some(error) = response.error {
|
||||||
|
return Err(ToolError::ExternalService(format!(
|
||||||
|
"MCP initialization error: {} (code {})",
|
||||||
|
error.message, error.code
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let init_result: InitializeResult = response
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::ExternalService("No result in initialize response".to_string())
|
||||||
|
})
|
||||||
|
.and_then(|r| {
|
||||||
|
serde_json::from_value(r).map_err(|e| {
|
||||||
|
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(ref session_manager) = self.session_manager {
|
||||||
|
session_manager.mark_initialized(&self.server_name).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notification = McpRequest::initialized_notification();
|
||||||
|
if let Err(e) = self.send_request(notification).await {
|
||||||
|
tracing::debug!(
|
||||||
|
"Failed to send initialized notification to '{}': {}",
|
||||||
|
self.server_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(init_result)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -903,54 +865,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mock transport that can return errors and successful responses in a
|
|
||||||
/// controlled sequence.
|
|
||||||
struct RetryMockTransport {
|
|
||||||
supports_http: bool,
|
|
||||||
outcomes: std::sync::Mutex<std::collections::VecDeque<Result<McpResponse, ToolError>>>,
|
|
||||||
recorded_headers: std::sync::Mutex<Vec<HashMap<String, String>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RetryMockTransport {
|
|
||||||
fn new(supports_http: bool, outcomes: Vec<Result<McpResponse, ToolError>>) -> Self {
|
|
||||||
Self {
|
|
||||||
supports_http,
|
|
||||||
outcomes: std::sync::Mutex::new(outcomes.into()),
|
|
||||||
recorded_headers: std::sync::Mutex::new(Vec::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recorded_headers(&self) -> Vec<HashMap<String, String>> {
|
|
||||||
self.recorded_headers.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl McpTransport for RetryMockTransport {
|
|
||||||
async fn send(
|
|
||||||
&self,
|
|
||||||
_request: &McpRequest,
|
|
||||||
headers: &HashMap<String, String>,
|
|
||||||
) -> Result<McpResponse, ToolError> {
|
|
||||||
self.recorded_headers.lock().unwrap().push(headers.clone());
|
|
||||||
let mut outcomes = self.outcomes.lock().unwrap();
|
|
||||||
if outcomes.is_empty() {
|
|
||||||
return Err(ToolError::ExternalService(
|
|
||||||
"No more mock outcomes".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
outcomes.pop_front().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown(&self) -> Result<(), ToolError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_http_features(&self) -> bool {
|
|
||||||
self.supports_http
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_non_http_transport_skips_401_retry() {
|
async fn test_non_http_transport_skips_401_retry() {
|
||||||
// initialize response, then notification ack (consumed but ignored),
|
// initialize response, then notification ack (consumed but ignored),
|
||||||
@@ -1051,83 +965,6 @@ mod tests {
|
|||||||
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
|
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_http_session_error_triggers_reinitialize_and_retry() {
|
|
||||||
let init_response = McpResponse {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id: Some(1),
|
|
||||||
result: Some(serde_json::json!({
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {},
|
|
||||||
"serverInfo": {"name": "test", "version": "1.0"}
|
|
||||||
})),
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
let notification_ack = McpResponse {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id: None,
|
|
||||||
result: None,
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
let notification_ack2 = notification_ack.clone();
|
|
||||||
let session_error = Err(ToolError::ExternalService(
|
|
||||||
"[test] MCP server returned status: 400 - No valid session ID provided".to_string(),
|
|
||||||
));
|
|
||||||
let reinit_response = McpResponse {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id: Some(2),
|
|
||||||
result: Some(serde_json::json!({
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {},
|
|
||||||
"serverInfo": {"name": "test", "version": "1.0"}
|
|
||||||
})),
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
let call_response = McpResponse {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id: Some(3),
|
|
||||||
result: Some(serde_json::json!({
|
|
||||||
"content": [{"type": "text", "text": "pong"}],
|
|
||||||
"is_error": false
|
|
||||||
})),
|
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let transport = Arc::new(RetryMockTransport::new(
|
|
||||||
true,
|
|
||||||
vec![
|
|
||||||
Ok(init_response),
|
|
||||||
Ok(notification_ack),
|
|
||||||
session_error,
|
|
||||||
Ok(reinit_response),
|
|
||||||
Ok(notification_ack2),
|
|
||||||
Ok(call_response),
|
|
||||||
],
|
|
||||||
));
|
|
||||||
let session_manager = Arc::new(McpSessionManager::new());
|
|
||||||
let client = McpClient::new_with_transport(
|
|
||||||
"test-http",
|
|
||||||
transport.clone(),
|
|
||||||
Some(session_manager),
|
|
||||||
None,
|
|
||||||
"default",
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
client.initialize().await.expect("initial handshake");
|
|
||||||
|
|
||||||
let result = client
|
|
||||||
.call_tool("echo", serde_json::json!({"input": "hello"}))
|
|
||||||
.await
|
|
||||||
.expect("call should recover after session expiry");
|
|
||||||
assert!(!result.is_error);
|
|
||||||
assert_eq!(result.content.len(), 1);
|
|
||||||
assert_eq!(result.content[0].as_text(), Some("pong"));
|
|
||||||
|
|
||||||
let headers = transport.recorded_headers();
|
|
||||||
assert_eq!(headers.len(), 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_strip_top_level_nulls_removes_null_fields() {
|
fn test_strip_top_level_nulls_removes_null_fields() {
|
||||||
let input = serde_json::json!({
|
let input = serde_json::json!({
|
||||||
|
|||||||
+27
-87
@@ -13,9 +13,7 @@ use crate::orchestrator::job_manager::ContainerJobManager;
|
|||||||
use crate::secrets::SecretsStore;
|
use crate::secrets::SecretsStore;
|
||||||
use crate::skills::catalog::SkillCatalog;
|
use crate::skills::catalog::SkillCatalog;
|
||||||
use crate::skills::registry::SkillRegistry;
|
use crate::skills::registry::SkillRegistry;
|
||||||
use crate::tools::builder::{
|
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||||
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
|
|
||||||
};
|
|
||||||
use crate::tools::builtin::{
|
use crate::tools::builtin::{
|
||||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
||||||
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
|
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
|
||||||
@@ -96,15 +94,6 @@ pub struct ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ToolRegistry {
|
impl ToolRegistry {
|
||||||
fn tool_definition(tool: &Arc<dyn Tool>) -> ToolDefinition {
|
|
||||||
let schema = tool.schema();
|
|
||||||
ToolDefinition {
|
|
||||||
name: schema.name,
|
|
||||||
description: schema.description,
|
|
||||||
parameters: schema.parameters,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new empty registry.
|
/// Create a new empty registry.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -217,7 +206,11 @@ impl ToolRegistry {
|
|||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.map(Self::tool_definition)
|
.map(|tool| ToolDefinition {
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
description: tool.description().to_string(),
|
||||||
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||||
defs
|
defs
|
||||||
@@ -228,7 +221,13 @@ impl ToolRegistry {
|
|||||||
let tools = self.tools.read().await;
|
let tools = self.tools.read().await;
|
||||||
names
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|name| tools.get(*name).map(Self::tool_definition))
|
.filter_map(|name| {
|
||||||
|
tools.get(*name).map(|tool| ToolDefinition {
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
description: tool.description().to_string(),
|
||||||
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +282,11 @@ impl ToolRegistry {
|
|||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.filter(|tool| tool.domain() == domain)
|
.filter(|tool| tool.domain() == domain)
|
||||||
.map(Self::tool_definition)
|
.map(|tool| ToolDefinition {
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
description: tool.description().to_string(),
|
||||||
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +312,11 @@ impl ToolRegistry {
|
|||||||
ApprovalRequirement::Never
|
ApprovalRequirement::Never
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.map(Self::tool_definition)
|
.map(|tool| ToolDefinition {
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
description: tool.description().to_string(),
|
||||||
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||||
defs
|
defs
|
||||||
@@ -578,23 +585,22 @@ impl ToolRegistry {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
config: Option<BuilderConfig>,
|
config: Option<BuilderConfig>,
|
||||||
) -> Arc<dyn SoftwareBuilder> {
|
) {
|
||||||
// First register dev tools needed by the builder
|
// First register dev tools needed by the builder
|
||||||
self.register_dev_tools();
|
self.register_dev_tools();
|
||||||
|
|
||||||
// Create the builder (arg order: config, llm, tools)
|
// Create the builder (arg order: config, llm, tools)
|
||||||
let builder: Arc<dyn SoftwareBuilder> = Arc::new(LlmSoftwareBuilder::new(
|
let builder = Arc::new(LlmSoftwareBuilder::new(
|
||||||
config.unwrap_or_default(),
|
config.unwrap_or_default(),
|
||||||
llm,
|
llm,
|
||||||
Arc::clone(self),
|
Arc::clone(self),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Register the build_software tool
|
// Register the build_software tool
|
||||||
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
|
self.register(Arc::new(BuildSoftwareTool::new(builder)))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
tracing::info!("Registered software builder tool");
|
tracing::debug!("Registered software builder tool");
|
||||||
builder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a WASM tool from bytes.
|
/// Register a WASM tool from bytes.
|
||||||
@@ -782,7 +788,6 @@ impl std::fmt::Debug for ToolRegistry {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::tools::registry::EchoTool;
|
use crate::tools::registry::EchoTool;
|
||||||
use crate::tools::tool::ToolDiscoverySummary;
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_register_and_get() {
|
async fn test_register_and_get() {
|
||||||
@@ -813,71 +818,6 @@ mod tests {
|
|||||||
assert_eq!(defs[0].name, "echo");
|
assert_eq!(defs[0].name, "echo");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_tool_definitions_use_tool_schema() {
|
|
||||||
struct DiscoveryTool;
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Tool for DiscoveryTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"discovery_tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Discovery test tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn discovery_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string" },
|
|
||||||
"extra": { "type": "string" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
|
|
||||||
Some(ToolDiscoverySummary {
|
|
||||||
notes: vec!["extra guidance".into()],
|
|
||||||
..ToolDiscoverySummary::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_params: serde_json::Value,
|
|
||||||
_ctx: &crate::context::JobContext,
|
|
||||||
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let registry = ToolRegistry::new();
|
|
||||||
registry.register(Arc::new(DiscoveryTool)).await;
|
|
||||||
|
|
||||||
let defs = registry.tool_definitions().await;
|
|
||||||
let def = defs
|
|
||||||
.iter()
|
|
||||||
.find(|def| def.name == "discovery_tool")
|
|
||||||
.expect("tool definition should be present");
|
|
||||||
assert!(
|
|
||||||
def.description.contains("tool_info"),
|
|
||||||
"live tool definition should include schema hint: {}",
|
|
||||||
def.description
|
|
||||||
);
|
|
||||||
assert!(def.parameters.get("extra").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_builtin_tool_cannot_be_shadowed() {
|
async fn test_builtin_tool_cannot_be_shadowed() {
|
||||||
let registry = ToolRegistry::new();
|
let registry = ToolRegistry::new();
|
||||||
|
|||||||
@@ -605,7 +605,15 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"event_emit",
|
"event_emit",
|
||||||
crate::tools::builtin::routine::event_emit_parameters_schema(),
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"event_source": { "type": "string", "description": "Event source" },
|
||||||
|
"event_type": { "type": "string", "description": "Event type" },
|
||||||
|
"payload": { "type": "object", "description": "Event payload", "properties": {} }
|
||||||
|
},
|
||||||
|
"required": ["event_source", "event_type"]
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
// Job tools with complex deps
|
// Job tools with complex deps
|
||||||
(
|
(
|
||||||
|
|||||||
+2
-35
@@ -231,19 +231,6 @@ impl ToolSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Curated discovery guidance surfaced by `tool_info(detail: "summary")`.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct ToolDiscoverySummary {
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub always_required: Vec<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub conditional_requirements: Vec<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub notes: Vec<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub examples: Vec<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for tools that the agent can use.
|
/// Trait for tools that the agent can use.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Tool: Send + Sync {
|
pub trait Tool: Send + Sync {
|
||||||
@@ -360,32 +347,12 @@ pub trait Tool: Send + Sync {
|
|||||||
self.parameters_schema()
|
self.parameters_schema()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
|
|
||||||
///
|
|
||||||
/// Default: no custom summary; callers may derive a minimal fallback from
|
|
||||||
/// `discovery_schema()`.
|
|
||||||
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the tool schema for LLM function calling.
|
/// Get the tool schema for LLM function calling.
|
||||||
fn schema(&self) -> ToolSchema {
|
fn schema(&self) -> ToolSchema {
|
||||||
let parameters = self.parameters_schema();
|
|
||||||
let has_discovery_hint =
|
|
||||||
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
|
|
||||||
let description = if has_discovery_hint {
|
|
||||||
format!(
|
|
||||||
"{} (call tool_info(name: \"{}\", detail: \"summary\") for rules/examples or detail: \"schema\" for the full discovery schema)",
|
|
||||||
self.description(),
|
|
||||||
self.name()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
self.description().to_string()
|
|
||||||
};
|
|
||||||
ToolSchema {
|
ToolSchema {
|
||||||
name: self.name().to_string(),
|
name: self.name().to_string(),
|
||||||
description,
|
description: self.description().to_string(),
|
||||||
parameters,
|
parameters: self.parameters_schema(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-149
@@ -196,7 +196,6 @@ impl Worker {
|
|||||||
.get("session_id")
|
.get("session_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string()),
|
.map(|s| s.to_string()),
|
||||||
fallback_deliverable: data.get("fallback_deliverable").cloned(),
|
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -961,14 +960,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_failed(&self, reason: &str) -> Result<(), Error> {
|
async fn mark_failed(&self, reason: &str) -> Result<(), Error> {
|
||||||
// Build fallback deliverable from memory before transitioning.
|
|
||||||
let fallback = self.build_fallback(reason).await;
|
|
||||||
|
|
||||||
self.context_manager()
|
self.context_manager()
|
||||||
.update_context(self.job_id, |ctx| {
|
.update_context(self.job_id, |ctx| {
|
||||||
ctx.transition_to(JobState::Failed, Some(reason.to_string()))?;
|
ctx.transition_to(JobState::Failed, Some(reason.to_string()))
|
||||||
store_fallback_in_metadata(ctx, fallback.as_ref());
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
.map_err(|s| crate::error::JobError::ContextError {
|
.map_err(|s| crate::error::JobError::ContextError {
|
||||||
@@ -989,15 +983,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_stuck(&self, reason: &str) -> Result<(), Error> {
|
async fn mark_stuck(&self, reason: &str) -> Result<(), Error> {
|
||||||
// Build fallback deliverable from memory before transitioning.
|
|
||||||
let fallback = self.build_fallback(reason).await;
|
|
||||||
|
|
||||||
self.context_manager()
|
self.context_manager()
|
||||||
.update_context(self.job_id, |ctx| {
|
.update_context(self.job_id, |ctx| ctx.mark_stuck(reason))
|
||||||
ctx.mark_stuck(reason)?;
|
|
||||||
store_fallback_in_metadata(ctx, fallback.as_ref());
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await?
|
.await?
|
||||||
.map_err(|s| crate::error::JobError::ContextError {
|
.map_err(|s| crate::error::JobError::ContextError {
|
||||||
id: self.job_id,
|
id: self.job_id,
|
||||||
@@ -1015,57 +1002,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
self.persist_status(JobState::Stuck, Some(reason.to_string()));
|
self.persist_status(JobState::Stuck, Some(reason.to_string()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a [`FallbackDeliverable`] from the current job context and memory.
|
|
||||||
async fn build_fallback(&self, reason: &str) -> Option<crate::context::FallbackDeliverable> {
|
|
||||||
let memory = match self.context_manager().get_memory(self.job_id).await {
|
|
||||||
Ok(memory) => memory,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
job_id = %self.job_id,
|
|
||||||
"Failed to load memory while building fallback deliverable: {e}"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ctx = match self.context_manager().get_context(self.job_id).await {
|
|
||||||
Ok(ctx) => ctx,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
job_id = %self.job_id,
|
|
||||||
"Failed to load context while building fallback deliverable: {e}"
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(crate::context::FallbackDeliverable::build(
|
|
||||||
&ctx, &memory, reason,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a fallback deliverable in the job context's metadata.
|
|
||||||
fn store_fallback_in_metadata(
|
|
||||||
ctx: &mut crate::context::JobContext,
|
|
||||||
fallback: Option<&crate::context::FallbackDeliverable>,
|
|
||||||
) {
|
|
||||||
let Some(fb) = fallback else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match serde_json::to_value(fb) {
|
|
||||||
Ok(val) => {
|
|
||||||
if !ctx.metadata.is_object() {
|
|
||||||
ctx.metadata = serde_json::json!({});
|
|
||||||
}
|
|
||||||
ctx.metadata["fallback_deliverable"] = val;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Failed to serialize fallback deliverable for job {}: {e}",
|
|
||||||
ctx.job_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Job delegate: implements `LoopDelegate` for the background job context.
|
/// Job delegate: implements `LoopDelegate` for the background job context.
|
||||||
@@ -1504,7 +1440,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cm = Arc::new(crate::context::ContextManager::new(5));
|
let cm = Arc::new(crate::context::ContextManager::new(5));
|
||||||
let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
|
let job_id = cm.create_job("test", "test job").await.unwrap();
|
||||||
|
|
||||||
let deps = WorkerDeps {
|
let deps = WorkerDeps {
|
||||||
context_manager: cm,
|
context_manager: cm,
|
||||||
@@ -1536,9 +1472,8 @@ mod tests {
|
|||||||
tool_call_id: "call_abc123".to_string(),
|
tool_call_id: "call_abc123".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(selection.tool_call_id, "call_abc123"); // safety: test
|
assert_eq!(selection.tool_call_id, "call_abc123");
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
/* safety: test */
|
|
||||||
selection.tool_call_id, "tool_call_id",
|
selection.tool_call_id, "tool_call_id",
|
||||||
"tool_call_id must not be the hardcoded placeholder string"
|
"tool_call_id must not be the hardcoded placeholder string"
|
||||||
);
|
);
|
||||||
@@ -1574,12 +1509,11 @@ mod tests {
|
|||||||
let results = worker.execute_tools_parallel(&selections).await;
|
let results = worker.execute_tools_parallel(&selections).await;
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
assert_eq!(results.len(), 3); // safety: test
|
assert_eq!(results.len(), 3);
|
||||||
for r in &results {
|
for r in &results {
|
||||||
assert!(r.result.is_ok(), "Tool should succeed"); // safety: test
|
assert!(r.result.is_ok(), "Tool should succeed");
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
elapsed < Duration::from_millis(800),
|
elapsed < Duration::from_millis(800),
|
||||||
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
|
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
|
||||||
elapsed
|
elapsed
|
||||||
@@ -1631,9 +1565,9 @@ mod tests {
|
|||||||
|
|
||||||
let results = worker.execute_tools_parallel(&selections).await;
|
let results = worker.execute_tools_parallel(&selections).await;
|
||||||
|
|
||||||
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); // safety: test
|
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a"));
|
||||||
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); // safety: test
|
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b"));
|
||||||
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); // safety: test
|
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1649,9 +1583,8 @@ mod tests {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let results = worker.execute_tools_parallel(&selections).await;
|
let results = worker.execute_tools_parallel(&selections).await;
|
||||||
assert_eq!(results.len(), 1); // safety: test
|
assert_eq!(results.len(), 1);
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
results[0].result.is_err(),
|
results[0].result.is_err(),
|
||||||
"Missing tool should produce an error, not a panic"
|
"Missing tool should produce an error, not a panic"
|
||||||
);
|
);
|
||||||
@@ -1667,24 +1600,23 @@ mod tests {
|
|||||||
ctx.transition_to(JobState::InProgress, None)
|
ctx.transition_to(JobState::InProgress, None)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
worker.mark_completed().await.unwrap(); // safety: test
|
worker.mark_completed().await.unwrap();
|
||||||
|
|
||||||
let ctx = worker
|
let ctx = worker
|
||||||
.context_manager()
|
.context_manager()
|
||||||
.get_context(worker.job_id)
|
.get_context(worker.job_id)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
assert_eq!(ctx.state, JobState::Completed); // safety: test
|
assert_eq!(ctx.state, JobState::Completed);
|
||||||
|
|
||||||
// Second mark_completed should succeed (idempotent) rather than
|
// Second mark_completed should succeed (idempotent) rather than
|
||||||
// erroring, matching the fix for the execution_loop / worker wrapper
|
// erroring, matching the fix for the execution_loop / worker wrapper
|
||||||
// race condition.
|
// race condition.
|
||||||
let result = worker.mark_completed().await;
|
let result = worker.mark_completed().await;
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"Completed -> Completed transition should be idempotent"
|
"Completed -> Completed transition should be idempotent"
|
||||||
);
|
);
|
||||||
@@ -1709,7 +1641,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cm = Arc::new(crate::context::ContextManager::new(5));
|
let cm = Arc::new(crate::context::ContextManager::new(5));
|
||||||
let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
|
let job_id = cm.create_job("test", "test job").await.unwrap();
|
||||||
|
|
||||||
let deps = WorkerDeps {
|
let deps = WorkerDeps {
|
||||||
context_manager: cm,
|
context_manager: cm,
|
||||||
@@ -1808,7 +1740,6 @@ mod tests {
|
|||||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Should be blocked without approval context"
|
"Should be blocked without approval context"
|
||||||
);
|
);
|
||||||
@@ -1821,7 +1752,7 @@ mod tests {
|
|||||||
let result = worker_allowed
|
let result = worker_allowed
|
||||||
.execute_tool("needs_approval", &serde_json::json!({}))
|
.execute_tool("needs_approval", &serde_json::json!({}))
|
||||||
.await;
|
.await;
|
||||||
assert!(result.is_ok(), "Should be allowed with autonomous context"); // safety: test
|
assert!(result.is_ok(), "Should be allowed with autonomous context");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1835,7 +1766,6 @@ mod tests {
|
|||||||
.execute_tool("always_approval", &serde_json::json!({}))
|
.execute_tool("always_approval", &serde_json::json!({}))
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Always tool should be blocked without permission"
|
"Always tool should be blocked without permission"
|
||||||
);
|
);
|
||||||
@@ -1851,7 +1781,6 @@ mod tests {
|
|||||||
.execute_tool("always_approval", &serde_json::json!({}))
|
.execute_tool("always_approval", &serde_json::json!({}))
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"Always tool should be allowed with permission"
|
"Always tool should be allowed with permission"
|
||||||
);
|
);
|
||||||
@@ -1868,8 +1797,8 @@ mod tests {
|
|||||||
ctx.transition_to(JobState::InProgress, None)
|
ctx.transition_to(JobState::InProgress, None)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
// Set a token budget
|
// Set a token budget
|
||||||
worker
|
worker
|
||||||
@@ -1878,17 +1807,16 @@ mod tests {
|
|||||||
ctx.max_tokens = 100;
|
ctx.max_tokens = 100;
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
// Simulate adding tokens that exceed the budget
|
// Simulate adding tokens that exceed the budget
|
||||||
let budget_result = worker
|
let budget_result = worker
|
||||||
.context_manager()
|
.context_manager()
|
||||||
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
|
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
/* safety: test */
|
|
||||||
budget_result.is_err(),
|
budget_result.is_err(),
|
||||||
"Should return error when token budget exceeded"
|
"Should return error when token budget exceeded"
|
||||||
);
|
);
|
||||||
@@ -1897,13 +1825,13 @@ mod tests {
|
|||||||
worker
|
worker
|
||||||
.mark_failed(&budget_result.unwrap_err().to_string())
|
.mark_failed(&budget_result.unwrap_err().to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
let ctx = worker
|
let ctx = worker
|
||||||
.context_manager()
|
.context_manager()
|
||||||
.get_context(worker.job_id)
|
.get_context(worker.job_id)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
assert_eq!(ctx.state, JobState::Failed); // safety: test
|
assert_eq!(ctx.state, JobState::Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1917,22 +1845,21 @@ mod tests {
|
|||||||
ctx.transition_to(JobState::InProgress, None)
|
ctx.transition_to(JobState::InProgress, None)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap() // safety: test
|
.unwrap()
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
// Simulate what the execution loop does when max_iterations is exceeded
|
// Simulate what the execution loop does when max_iterations is exceeded
|
||||||
worker
|
worker
|
||||||
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
|
|
||||||
let ctx = worker
|
let ctx = worker
|
||||||
.context_manager()
|
.context_manager()
|
||||||
.get_context(worker.job_id)
|
.get_context(worker.job_id)
|
||||||
.await
|
.await
|
||||||
.unwrap(); // safety: test
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
/* safety: test */
|
|
||||||
ctx.state,
|
ctx.state,
|
||||||
JobState::Failed,
|
JobState::Failed,
|
||||||
"Iteration cap should transition to Failed, not Stuck"
|
"Iteration cap should transition to Failed, not Stuck"
|
||||||
@@ -2062,52 +1989,4 @@ mod tests {
|
|||||||
"Should skip empty first reasoning and return the first non-empty one"
|
"Should skip empty first reasoning and return the first non-empty one"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_fallback_in_metadata_roundtrip() {
|
|
||||||
use crate::context::FallbackDeliverable;
|
|
||||||
|
|
||||||
let mut ctx = JobContext::new("Test", "fallback roundtrip");
|
|
||||||
let memory = crate::context::Memory::new(ctx.job_id);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "test failure");
|
|
||||||
|
|
||||||
// Store into metadata
|
|
||||||
store_fallback_in_metadata(&mut ctx, Some(&fb));
|
|
||||||
|
|
||||||
// Verify it's stored and can be deserialized back
|
|
||||||
let stored = ctx.metadata.get("fallback_deliverable");
|
|
||||||
assert!(stored.is_some(), "fallback missing from metadata"); // safety: test
|
|
||||||
|
|
||||||
let recovered: FallbackDeliverable =
|
|
||||||
serde_json::from_value(stored.unwrap().clone()).expect("deserialize fallback"); // safety: test
|
|
||||||
assert_eq!(recovered.failure_reason, "test failure"); // safety: test
|
|
||||||
assert!(!recovered.partial); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_fallback_handles_non_object_metadata() {
|
|
||||||
use crate::context::FallbackDeliverable;
|
|
||||||
|
|
||||||
let mut ctx = JobContext::new("Test", "non-object metadata");
|
|
||||||
ctx.metadata = serde_json::json!("not an object");
|
|
||||||
|
|
||||||
let memory = crate::context::Memory::new(ctx.job_id);
|
|
||||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
|
||||||
|
|
||||||
store_fallback_in_metadata(&mut ctx, Some(&fb));
|
|
||||||
|
|
||||||
// Must normalize to object and store
|
|
||||||
assert!(ctx.metadata.is_object()); // safety: test
|
|
||||||
assert!(ctx.metadata.get("fallback_deliverable").is_some()); // safety: test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_store_fallback_none_is_noop() {
|
|
||||||
let mut ctx = JobContext::new("Test", "noop");
|
|
||||||
let original = ctx.metadata.clone();
|
|
||||||
|
|
||||||
store_fallback_in_metadata(&mut ctx, None);
|
|
||||||
|
|
||||||
assert_eq!(ctx.metadata, original); // safety: test
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,17 +38,12 @@ workspace/
|
|||||||
## Using the Workspace
|
## Using the Workspace
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use std::sync::Arc;
|
|
||||||
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
|
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
|
||||||
|
|
||||||
// Create workspace for a user (wraps embeddings in a default LRU cache)
|
// Create workspace for a user
|
||||||
let workspace = Workspace::new("user_123", pool)
|
let workspace = Workspace::new("user_123", pool)
|
||||||
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
|
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
|
||||||
|
|
||||||
// For tests: skip the cache layer (avoids unnecessary overhead with mocks)
|
|
||||||
// let workspace = Workspace::new("user_123", pool)
|
|
||||||
// .with_embeddings_uncached(Arc::new(MockEmbeddings::new(1536)));
|
|
||||||
|
|
||||||
// Read/write any path
|
// Read/write any path
|
||||||
let doc = workspace.read("projects/alpha/notes.md").await?;
|
let doc = workspace.read("projects/alpha/notes.md").await?;
|
||||||
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
|
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
|
||||||
|
|||||||
@@ -1,613 +0,0 @@
|
|||||||
//! LRU embedding cache wrapping any [`EmbeddingProvider`].
|
|
||||||
//!
|
|
||||||
//! Avoids redundant HTTP calls for identical texts by caching embeddings
|
|
||||||
//! in memory keyed by `SHA-256(model_name + "\0" + text)`.
|
|
||||||
//!
|
|
||||||
//! Follows the same cache pattern as `llm::response_cache::CachedProvider`:
|
|
||||||
//! `HashMap` + `last_accessed` tracking + manual LRU eviction.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::workspace::embeddings::{EmbeddingError, EmbeddingProvider};
|
|
||||||
|
|
||||||
/// Configuration for the embedding cache.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EmbeddingCacheConfig {
|
|
||||||
/// Maximum number of cached embeddings (default 10,000).
|
|
||||||
///
|
|
||||||
/// Approximate raw embedding payload: `max_entries × dimension × 4 bytes`.
|
|
||||||
/// At 10,000 entries × 1536 floats ≈ 58 MB (payload only; actual memory
|
|
||||||
/// is higher due to HashMap buckets, `[u8; 32]` hash keys, `Vec`/`Instant`
|
|
||||||
/// per-entry overhead).
|
|
||||||
pub max_entries: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EmbeddingCacheConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_entries: crate::config::DEFAULT_EMBEDDING_CACHE_SIZE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CacheEntry {
|
|
||||||
embedding: Vec<f32>,
|
|
||||||
last_accessed: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embedding provider wrapper that caches results in memory.
|
|
||||||
///
|
|
||||||
/// Thread-safe via `std::sync::Mutex`. The lock is **never held**
|
|
||||||
/// across `.await` points (all critical sections are scoped blocks),
|
|
||||||
/// so a synchronous mutex is cheaper than `tokio::sync::Mutex`.
|
|
||||||
pub struct CachedEmbeddingProvider {
|
|
||||||
inner: Arc<dyn EmbeddingProvider>,
|
|
||||||
cache: Mutex<HashMap<[u8; 32], CacheEntry>>,
|
|
||||||
config: EmbeddingCacheConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CachedEmbeddingProvider {
|
|
||||||
/// Wrap a provider with LRU caching.
|
|
||||||
///
|
|
||||||
/// `config.max_entries` is clamped to at least 1.
|
|
||||||
pub fn new(inner: Arc<dyn EmbeddingProvider>, config: EmbeddingCacheConfig) -> Self {
|
|
||||||
let config = EmbeddingCacheConfig {
|
|
||||||
max_entries: config.max_entries.max(1),
|
|
||||||
};
|
|
||||||
if config.max_entries > 100_000 {
|
|
||||||
tracing::warn!(
|
|
||||||
max_entries = config.max_entries,
|
|
||||||
"Embedding cache size exceeds 100,000 entries; memory usage may be significant"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
cache: Mutex::new(HashMap::with_capacity(config.max_entries.min(1024))),
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of entries currently in the cache.
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.cache.lock().unwrap_or_else(|e| e.into_inner()).len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the cache is empty.
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.cache
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all cached entries.
|
|
||||||
pub fn clear(&self) {
|
|
||||||
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a deterministic cache key: `SHA-256(model_name + "\0" + text)`.
|
|
||||||
///
|
|
||||||
/// Returns raw 32-byte hash to avoid a 64-char hex String allocation per lookup.
|
|
||||||
fn cache_key(&self, text: &str) -> [u8; 32] {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(self.inner.model_name().as_bytes());
|
|
||||||
hasher.update(b"\0");
|
|
||||||
hasher.update(text.as_bytes());
|
|
||||||
hasher.finalize().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evict the least-recently-used entry if at capacity (single-entry path).
|
|
||||||
// TODO: O(n) scan per eviction. If max_entries grows large, switch to
|
|
||||||
// an ordered data structure (e.g. `IndexMap` with swap_remove, or a
|
|
||||||
// linked-list LRU like the `lru` crate).
|
|
||||||
fn evict_lru(cache: &mut HashMap<[u8; 32], CacheEntry>, max_entries: usize) {
|
|
||||||
while cache.len() >= max_entries {
|
|
||||||
let oldest_key = cache
|
|
||||||
.iter()
|
|
||||||
.min_by_key(|(_, entry)| entry.last_accessed)
|
|
||||||
.map(|(k, _)| *k);
|
|
||||||
|
|
||||||
if let Some(k) = oldest_key {
|
|
||||||
cache.remove(&k);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evict the `k` oldest entries in O(n) average time via partial selection.
|
|
||||||
///
|
|
||||||
/// Used by `embed_batch` to avoid the O(n×m) cost of calling
|
|
||||||
/// `evict_lru` per insert.
|
|
||||||
fn evict_k_oldest(cache: &mut HashMap<[u8; 32], CacheEntry>, k: usize) {
|
|
||||||
if k == 0 || cache.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if k >= cache.len() {
|
|
||||||
cache.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Partial selection: find the k oldest in O(n) average via
|
|
||||||
// select_nth_unstable_by_key, then remove the first k entries.
|
|
||||||
let mut entries: Vec<([u8; 32], Instant)> = cache
|
|
||||||
.iter()
|
|
||||||
.map(|(key, entry)| (*key, entry.last_accessed))
|
|
||||||
.collect();
|
|
||||||
entries.select_nth_unstable_by_key(k - 1, |(_, t)| *t);
|
|
||||||
for (key, _) in entries.into_iter().take(k) {
|
|
||||||
cache.remove(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl EmbeddingProvider for CachedEmbeddingProvider {
|
|
||||||
fn dimension(&self) -> usize {
|
|
||||||
self.inner.dimension()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_name(&self) -> &str {
|
|
||||||
self.inner.model_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_input_length(&self) -> usize {
|
|
||||||
self.inner.max_input_length()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
||||||
let key = self.cache_key(text);
|
|
||||||
|
|
||||||
// Check cache (short critical section)
|
|
||||||
{
|
|
||||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
if let Some(entry) = guard.get_mut(&key) {
|
|
||||||
entry.last_accessed = Instant::now();
|
|
||||||
tracing::trace!("embedding cache hit");
|
|
||||||
return Ok(entry.embedding.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Lock released before HTTP call.
|
|
||||||
// NOTE: Thundering herd — multiple concurrent callers with the same
|
|
||||||
// uncached key will each call the inner provider. This is acceptable:
|
|
||||||
// embeddings are idempotent and the last writer wins in the HashMap.
|
|
||||||
|
|
||||||
let embedding = self.inner.embed(text).await?;
|
|
||||||
|
|
||||||
// Store result. Re-check under lock: another concurrent caller may
|
|
||||||
// have inserted this key while the lock was released for the HTTP call.
|
|
||||||
{
|
|
||||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
if let Some(entry) = guard.get_mut(&key) {
|
|
||||||
// Key already present (thundering herd) — just update, no eviction needed.
|
|
||||||
entry.embedding = embedding.clone();
|
|
||||||
entry.last_accessed = Instant::now();
|
|
||||||
} else {
|
|
||||||
Self::evict_lru(&mut guard, self.config.max_entries);
|
|
||||||
guard.insert(
|
|
||||||
key,
|
|
||||||
CacheEntry {
|
|
||||||
embedding: embedding.clone(),
|
|
||||||
last_accessed: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::trace!("embedding cache miss");
|
|
||||||
Ok(embedding)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
||||||
if texts.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partition into hits and misses
|
|
||||||
let keys: Vec<[u8; 32]> = texts.iter().map(|t| self.cache_key(t)).collect();
|
|
||||||
let mut results: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
|
|
||||||
let mut miss_indices: Vec<usize> = Vec::new();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let now = Instant::now();
|
|
||||||
for (i, key) in keys.iter().enumerate() {
|
|
||||||
if let Some(entry) = guard.get_mut(key) {
|
|
||||||
entry.last_accessed = now;
|
|
||||||
results[i] = Some(entry.embedding.clone());
|
|
||||||
} else {
|
|
||||||
miss_indices.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Lock released before HTTP call
|
|
||||||
|
|
||||||
if miss_indices.is_empty() {
|
|
||||||
tracing::trace!(count = texts.len(), "embedding batch: all cache hits");
|
|
||||||
// All slots populated from cache hits
|
|
||||||
return results
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, slot)| {
|
|
||||||
slot.ok_or_else(|| {
|
|
||||||
EmbeddingError::InvalidResponse(format!(
|
|
||||||
"embedding slot {i} was not populated"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch missing embeddings
|
|
||||||
let miss_texts: Vec<String> = miss_indices.iter().map(|&i| texts[i].clone()).collect();
|
|
||||||
let new_embeddings = self.inner.embed_batch(&miss_texts).await?;
|
|
||||||
|
|
||||||
if new_embeddings.len() != miss_indices.len() {
|
|
||||||
return Err(EmbeddingError::InvalidResponse(format!(
|
|
||||||
"embed_batch returned {} embeddings, expected {}",
|
|
||||||
new_embeddings.len(),
|
|
||||||
miss_indices.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::trace!(
|
|
||||||
hits = texts.len() - miss_indices.len(),
|
|
||||||
misses = miss_indices.len(),
|
|
||||||
"embedding batch: partial cache"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assemble results first (all misses, regardless of cache capacity).
|
|
||||||
for (orig_idx, emb) in miss_indices.iter().copied().zip(&new_embeddings) {
|
|
||||||
results[orig_idx] = Some(emb.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the new embeddings, respecting max_entries.
|
|
||||||
{
|
|
||||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
// When misses exceed capacity, clear and only cache the tail.
|
|
||||||
let cacheable = miss_indices.len().min(self.config.max_entries);
|
|
||||||
let skip = miss_indices.len() - cacheable;
|
|
||||||
let need_to_evict = (guard.len() + cacheable).saturating_sub(self.config.max_entries);
|
|
||||||
if need_to_evict > 0 {
|
|
||||||
Self::evict_k_oldest(&mut guard, need_to_evict);
|
|
||||||
}
|
|
||||||
let now = Instant::now();
|
|
||||||
for (&orig_idx, emb) in miss_indices[skip..].iter().zip(&new_embeddings[skip..]) {
|
|
||||||
guard.insert(
|
|
||||||
keys[orig_idx],
|
|
||||||
CacheEntry {
|
|
||||||
embedding: emb.clone(),
|
|
||||||
last_accessed: now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, slot)| {
|
|
||||||
slot.ok_or_else(|| {
|
|
||||||
EmbeddingError::InvalidResponse(format!("embedding slot {i} was not populated"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
|
|
||||||
/// Mock embedding provider that counts calls.
|
|
||||||
struct CountingMock {
|
|
||||||
dimension: usize,
|
|
||||||
model: String,
|
|
||||||
embed_calls: AtomicU32,
|
|
||||||
batch_calls: AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CountingMock {
|
|
||||||
fn new(dimension: usize, model: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
dimension,
|
|
||||||
model: model.to_string(),
|
|
||||||
embed_calls: AtomicU32::new(0),
|
|
||||||
batch_calls: AtomicU32::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn embed_calls(&self) -> u32 {
|
|
||||||
self.embed_calls.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_calls(&self) -> u32 {
|
|
||||||
self.batch_calls.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl EmbeddingProvider for CountingMock {
|
|
||||||
fn dimension(&self) -> usize {
|
|
||||||
self.dimension
|
|
||||||
}
|
|
||||||
fn model_name(&self) -> &str {
|
|
||||||
&self.model
|
|
||||||
}
|
|
||||||
fn max_input_length(&self) -> usize {
|
|
||||||
10_000
|
|
||||||
}
|
|
||||||
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
||||||
self.embed_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
// Simple deterministic embedding: val = text.len() / 100.0
|
|
||||||
let val = text.len() as f32 / 100.0;
|
|
||||||
Ok(vec![val; self.dimension])
|
|
||||||
}
|
|
||||||
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
||||||
self.batch_calls.fetch_add(1, Ordering::SeqCst);
|
|
||||||
texts
|
|
||||||
.iter()
|
|
||||||
.map(|t| {
|
|
||||||
let val = t.len() as f32 / 100.0;
|
|
||||||
Ok(vec![val; self.dimension])
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cache_hit_avoids_inner_call() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
let r1 = cached.embed("hello").await.unwrap();
|
|
||||||
assert_eq!(inner.embed_calls(), 1);
|
|
||||||
|
|
||||||
let r2 = cached.embed("hello").await.unwrap();
|
|
||||||
assert_eq!(inner.embed_calls(), 1); // still 1 -- cache hit
|
|
||||||
assert_eq!(r1, r2);
|
|
||||||
|
|
||||||
assert_eq!(cached.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cache_miss_calls_inner() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
cached.embed("hello").await.unwrap();
|
|
||||||
cached.embed("world").await.unwrap();
|
|
||||||
assert_eq!(inner.embed_calls(), 2);
|
|
||||||
assert_eq!(cached.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cache_key_includes_model() {
|
|
||||||
let inner_a = Arc::new(CountingMock::new(4, "model-a"));
|
|
||||||
let inner_b = Arc::new(CountingMock::new(4, "model-b"));
|
|
||||||
|
|
||||||
let cached_a = CachedEmbeddingProvider::new(
|
|
||||||
inner_a.clone(),
|
|
||||||
EmbeddingCacheConfig { max_entries: 100 },
|
|
||||||
);
|
|
||||||
let cached_b = CachedEmbeddingProvider::new(
|
|
||||||
inner_b.clone(),
|
|
||||||
EmbeddingCacheConfig { max_entries: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Same text, different models -> different cache keys
|
|
||||||
let key_a = cached_a.cache_key("hello");
|
|
||||||
let key_b = cached_b.cache_key("hello");
|
|
||||||
assert_ne!(key_a, key_b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn lru_eviction() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 2 });
|
|
||||||
|
|
||||||
cached.embed("first").await.unwrap();
|
|
||||||
cached.embed("second").await.unwrap();
|
|
||||||
assert_eq!(cached.len(), 2);
|
|
||||||
|
|
||||||
// Third entry should evict the oldest ("first")
|
|
||||||
cached.embed("third").await.unwrap();
|
|
||||||
assert_eq!(cached.len(), 2);
|
|
||||||
assert_eq!(inner.embed_calls(), 3);
|
|
||||||
|
|
||||||
// "first" should be a cache miss now
|
|
||||||
cached.embed("first").await.unwrap();
|
|
||||||
assert_eq!(inner.embed_calls(), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embed_batch_partial_hits() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
// Pre-cache one text
|
|
||||||
cached.embed("cached").await.unwrap();
|
|
||||||
assert_eq!(inner.embed_calls(), 1);
|
|
||||||
|
|
||||||
// Batch with 1 cached + 2 new
|
|
||||||
let texts = vec![
|
|
||||||
"cached".to_string(),
|
|
||||||
"new_one".to_string(),
|
|
||||||
"new_two".to_string(),
|
|
||||||
];
|
|
||||||
let results = cached.embed_batch(&texts).await.unwrap();
|
|
||||||
|
|
||||||
// Should have called embed_batch on inner for 2 misses
|
|
||||||
assert_eq!(inner.batch_calls(), 1);
|
|
||||||
assert_eq!(results.len(), 3);
|
|
||||||
assert_eq!(cached.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn batch_preserves_order() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
// Pre-cache "bb" (len 2)
|
|
||||||
cached.embed("bb").await.unwrap();
|
|
||||||
|
|
||||||
// Batch: "a" (miss, len 1), "bb" (hit, len 2), "ccc" (miss, len 3)
|
|
||||||
let texts = vec!["a".to_string(), "bb".to_string(), "ccc".to_string()];
|
|
||||||
let results = cached.embed_batch(&texts).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 3);
|
|
||||||
let expected_a = vec![1.0_f32 / 100.0; 4];
|
|
||||||
let expected_bb = vec![2.0_f32 / 100.0; 4];
|
|
||||||
let expected_ccc = vec![3.0_f32 / 100.0; 4];
|
|
||||||
assert_eq!(results[0], expected_a);
|
|
||||||
assert_eq!(results[1], expected_bb);
|
|
||||||
assert_eq!(results[2], expected_ccc);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn batch_exceeding_capacity_respects_max_entries() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 3 });
|
|
||||||
|
|
||||||
// Batch with 5 misses but cache capacity is 3
|
|
||||||
let texts: Vec<String> = (0..5).map(|i| format!("text_{i}")).collect();
|
|
||||||
let results = cached.embed_batch(&texts).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 5);
|
|
||||||
let len = cached.len();
|
|
||||||
assert!(len <= 3, "cache len {len} exceeds max 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mock embedding provider that fails the first N calls, then succeeds.
|
|
||||||
struct FailThenSucceedMock {
|
|
||||||
dimension: usize,
|
|
||||||
model: String,
|
|
||||||
remaining_failures: AtomicU32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailThenSucceedMock {
|
|
||||||
fn new(dimension: usize, fail_count: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
dimension,
|
|
||||||
model: "fail-mock".to_string(),
|
|
||||||
remaining_failures: AtomicU32::new(fail_count),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl EmbeddingProvider for FailThenSucceedMock {
|
|
||||||
fn dimension(&self) -> usize {
|
|
||||||
self.dimension
|
|
||||||
}
|
|
||||||
fn model_name(&self) -> &str {
|
|
||||||
&self.model
|
|
||||||
}
|
|
||||||
fn max_input_length(&self) -> usize {
|
|
||||||
10_000
|
|
||||||
}
|
|
||||||
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
||||||
let prev =
|
|
||||||
self.remaining_failures
|
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
|
|
||||||
if v > 0 { Some(v - 1) } else { None }
|
|
||||||
});
|
|
||||||
if prev.is_ok() {
|
|
||||||
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
|
|
||||||
}
|
|
||||||
let val = text.len() as f32 / 100.0;
|
|
||||||
Ok(vec![val; self.dimension])
|
|
||||||
}
|
|
||||||
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
||||||
let prev =
|
|
||||||
self.remaining_failures
|
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
|
|
||||||
if v > 0 { Some(v - 1) } else { None }
|
|
||||||
});
|
|
||||||
if prev.is_ok() {
|
|
||||||
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
|
|
||||||
}
|
|
||||||
texts
|
|
||||||
.iter()
|
|
||||||
.map(|t| {
|
|
||||||
let val = t.len() as f32 / 100.0;
|
|
||||||
Ok(vec![val; self.dimension])
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn error_does_not_pollute_cache() {
|
|
||||||
let inner = Arc::new(FailThenSucceedMock::new(4, 1));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
// First call fails
|
|
||||||
let err = cached.embed("hello").await;
|
|
||||||
assert!(err.is_err());
|
|
||||||
assert!(cached.is_empty(), "cache should be empty after error");
|
|
||||||
|
|
||||||
// Second call succeeds and should call the inner provider (not serve stale error)
|
|
||||||
let result = cached.embed("hello").await;
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(cached.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embed_batch_empty_input() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
let results = cached.embed_batch(&[]).await.unwrap();
|
|
||||||
assert!(results.is_empty());
|
|
||||||
assert_eq!(inner.batch_calls(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn embed_batch_all_misses() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
|
|
||||||
|
|
||||||
// Nothing cached — every text is a miss
|
|
||||||
let texts: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()];
|
|
||||||
let results = cached.embed_batch(&texts).await.unwrap();
|
|
||||||
assert_eq!(results.len(), 3);
|
|
||||||
assert_eq!(inner.batch_calls(), 1, "inner called once for misses");
|
|
||||||
assert_eq!(cached.len(), 3, "all results should be cached");
|
|
||||||
|
|
||||||
// Second call should be all hits — no new inner calls
|
|
||||||
let results2 = cached.embed_batch(&texts).await.unwrap();
|
|
||||||
assert_eq!(results2.len(), 3);
|
|
||||||
assert_eq!(inner.batch_calls(), 1, "no new inner calls");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn zero_max_entries_clamped_to_one() {
|
|
||||||
let inner = Arc::new(CountingMock::new(4, "test-model"));
|
|
||||||
let cached =
|
|
||||||
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 0 });
|
|
||||||
|
|
||||||
// Should behave as max_entries=1 (clamped in constructor)
|
|
||||||
cached.embed("hello").await.unwrap();
|
|
||||||
assert_eq!(cached.len(), 1);
|
|
||||||
|
|
||||||
// Second entry evicts the first
|
|
||||||
cached.embed("world").await.unwrap();
|
|
||||||
assert_eq!(cached.len(), 1);
|
|
||||||
assert_eq!(inner.embed_calls(), 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::llm::retry::cap_retry_after;
|
|
||||||
|
|
||||||
/// Error type for embedding operations.
|
/// Error type for embedding operations.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum EmbeddingError {
|
pub enum EmbeddingError {
|
||||||
@@ -234,7 +232,6 @@ impl EmbeddingProvider for OpenAiEmbeddings {
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
.map(std::time::Duration::from_secs)
|
.map(std::time::Duration::from_secs)
|
||||||
.map(cap_retry_after)
|
|
||||||
.or(Some(std::time::Duration::from_secs(60)));
|
.or(Some(std::time::Duration::from_secs(60)));
|
||||||
return Err(EmbeddingError::RateLimited { retry_after });
|
return Err(EmbeddingError::RateLimited { retry_after });
|
||||||
}
|
}
|
||||||
@@ -377,7 +374,6 @@ impl EmbeddingProvider for NearAiEmbeddings {
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
.map(std::time::Duration::from_secs)
|
.map(std::time::Duration::from_secs)
|
||||||
.map(cap_retry_after)
|
|
||||||
.or(Some(std::time::Duration::from_secs(60)));
|
.or(Some(std::time::Duration::from_secs(60)));
|
||||||
return Err(EmbeddingError::RateLimited { retry_after });
|
return Err(EmbeddingError::RateLimited { retry_after });
|
||||||
}
|
}
|
||||||
@@ -694,7 +690,6 @@ mod tests {
|
|||||||
.parse::<u64>()
|
.parse::<u64>()
|
||||||
.ok()
|
.ok()
|
||||||
.map(std::time::Duration::from_secs)
|
.map(std::time::Duration::from_secs)
|
||||||
.map(cap_retry_after)
|
|
||||||
.or(Some(std::time::Duration::from_secs(60)))
|
.or(Some(std::time::Duration::from_secs(60)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
|
|
||||||
mod chunker;
|
mod chunker;
|
||||||
mod document;
|
mod document;
|
||||||
mod embedding_cache;
|
|
||||||
mod embeddings;
|
mod embeddings;
|
||||||
pub mod hygiene;
|
pub mod hygiene;
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
@@ -51,7 +50,6 @@ mod search;
|
|||||||
|
|
||||||
pub use chunker::{ChunkConfig, chunk_document};
|
pub use chunker::{ChunkConfig, chunk_document};
|
||||||
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
|
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
|
||||||
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
|
|
||||||
pub use embeddings::{
|
pub use embeddings::{
|
||||||
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
|
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
|
||||||
};
|
};
|
||||||
@@ -373,33 +371,7 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the embedding provider for semantic search.
|
/// Set the embedding provider for semantic search.
|
||||||
///
|
|
||||||
/// The provider is automatically wrapped in a [`CachedEmbeddingProvider`]
|
|
||||||
/// with the default cache size (10,000 entries; payload ~58 MB for 1536-dim,
|
|
||||||
/// actual memory higher due to per-entry overhead).
|
|
||||||
pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
|
pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
|
||||||
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
|
|
||||||
provider,
|
|
||||||
EmbeddingCacheConfig::default(),
|
|
||||||
)));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the embedding provider with a custom cache configuration.
|
|
||||||
pub fn with_embeddings_cached(
|
|
||||||
mut self,
|
|
||||||
provider: Arc<dyn EmbeddingProvider>,
|
|
||||||
cache_config: EmbeddingCacheConfig,
|
|
||||||
) -> Self {
|
|
||||||
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
|
|
||||||
provider,
|
|
||||||
cache_config,
|
|
||||||
)));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the embedding provider **without** caching (for tests).
|
|
||||||
pub fn with_embeddings_uncached(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
|
|
||||||
self.embeddings = Some(provider);
|
self.embeddings = Some(provider);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
//! Integration tests for dispatched routine run tracking (#1317).
|
|
||||||
//!
|
|
||||||
//! Verifies:
|
|
||||||
//! 1. list_dispatched_routine_runs returns only running runs with linked jobs
|
|
||||||
//! 2. Completed jobs cause linked routine runs to be finalized as Ok
|
|
||||||
//! 3. Failed jobs cause linked routine runs to be finalized as Failed
|
|
||||||
//! 4. Active (InProgress) jobs are not finalized
|
|
||||||
//! 5. Orphaned runs (job_id set but no job record) are handled
|
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
mod tests {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use ironclaw::agent::routine::{
|
|
||||||
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
|
||||||
};
|
|
||||||
use ironclaw::context::{JobContext, JobState};
|
|
||||||
use ironclaw::db::Database;
|
|
||||||
|
|
||||||
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
|
|
||||||
use ironclaw::db::libsql::LibSqlBackend;
|
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let db_path = temp_dir.path().join("test.db");
|
|
||||||
let backend = LibSqlBackend::new_local(&db_path)
|
|
||||||
.await
|
|
||||||
.expect("LibSqlBackend");
|
|
||||||
backend.run_migrations().await.expect("migrations");
|
|
||||||
let db: Arc<dyn Database> = Arc::new(backend);
|
|
||||||
(db, temp_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_routine(id: Uuid) -> Routine {
|
|
||||||
Routine {
|
|
||||||
id,
|
|
||||||
name: format!("test-routine-{}", id),
|
|
||||||
description: "Test routine".to_string(),
|
|
||||||
user_id: "default".to_string(),
|
|
||||||
enabled: true,
|
|
||||||
trigger: Trigger::Manual,
|
|
||||||
action: RoutineAction::FullJob {
|
|
||||||
title: "Test job".to_string(),
|
|
||||||
description: "Test description".to_string(),
|
|
||||||
max_iterations: 5,
|
|
||||||
tool_permissions: vec![],
|
|
||||||
},
|
|
||||||
guardrails: RoutineGuardrails {
|
|
||||||
cooldown: std::time::Duration::from_secs(0),
|
|
||||||
max_concurrent: 1,
|
|
||||||
dedup_window: None,
|
|
||||||
},
|
|
||||||
notify: Default::default(),
|
|
||||||
last_run_at: None,
|
|
||||||
next_fire_at: None,
|
|
||||||
run_count: 0,
|
|
||||||
consecutive_failures: 0,
|
|
||||||
state: serde_json::json!({}),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
updated_at: Utc::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_run(routine_id: Uuid, job_id: Option<Uuid>) -> RoutineRun {
|
|
||||||
RoutineRun {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
routine_id,
|
|
||||||
trigger_type: "manual".to_string(),
|
|
||||||
trigger_detail: None,
|
|
||||||
started_at: Utc::now(),
|
|
||||||
completed_at: None,
|
|
||||||
status: RunStatus::Running,
|
|
||||||
result_summary: None,
|
|
||||||
tokens_used: None,
|
|
||||||
job_id,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 1: list_dispatched_routine_runs returns only running runs with jobs
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_dispatched_returns_only_running_with_job_id() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
// Create jobs first (FK constraint requires job records to exist)
|
|
||||||
let job1 = JobContext::new("Job 1", "Dispatched job");
|
|
||||||
db.save_job(&job1).await.expect("save job1");
|
|
||||||
let job2 = JobContext::new("Job 2", "Completed job");
|
|
||||||
db.save_job(&job2).await.expect("save job2");
|
|
||||||
|
|
||||||
// Create a running run WITH job_id (dispatched full_job)
|
|
||||||
let dispatched_run = make_run(routine_id, Some(job1.job_id));
|
|
||||||
db.create_routine_run(&dispatched_run)
|
|
||||||
.await
|
|
||||||
.expect("create dispatched run");
|
|
||||||
|
|
||||||
// Create a running run WITHOUT job_id (lightweight in-progress)
|
|
||||||
let lightweight_run = make_run(routine_id, None);
|
|
||||||
db.create_routine_run(&lightweight_run)
|
|
||||||
.await
|
|
||||||
.expect("create lightweight run");
|
|
||||||
|
|
||||||
// Create a completed run WITH job_id (already finalized)
|
|
||||||
let mut completed_run = make_run(routine_id, Some(job2.job_id));
|
|
||||||
completed_run.status = RunStatus::Ok;
|
|
||||||
completed_run.completed_at = Some(Utc::now());
|
|
||||||
db.create_routine_run(&completed_run)
|
|
||||||
.await
|
|
||||||
.expect("create completed run");
|
|
||||||
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
|
|
||||||
assert_eq!(dispatched.len(), 1, "Should return only the dispatched run");
|
|
||||||
assert_eq!(dispatched[0].id, dispatched_run.id);
|
|
||||||
assert_eq!(dispatched[0].job_id, Some(job1.job_id));
|
|
||||||
assert_eq!(dispatched[0].status, RunStatus::Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 2: Completed job linked to run can be detected
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dispatched_run_with_completed_job_can_be_finalized() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
// Create and save a job in Completed state
|
|
||||||
let mut job = JobContext::new("Test job", "Test description");
|
|
||||||
job.state = JobState::Completed;
|
|
||||||
db.save_job(&job).await.expect("save job");
|
|
||||||
|
|
||||||
// Create a dispatched run linked to that job
|
|
||||||
let run = make_run(routine_id, Some(job.job_id));
|
|
||||||
db.create_routine_run(&run).await.expect("create run");
|
|
||||||
|
|
||||||
// Verify the run is listed as dispatched
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
assert_eq!(dispatched.len(), 1);
|
|
||||||
|
|
||||||
// Verify we can fetch the linked job and see it's completed
|
|
||||||
let fetched_job = db
|
|
||||||
.get_job(job.job_id)
|
|
||||||
.await
|
|
||||||
.expect("get job")
|
|
||||||
.expect("job should exist");
|
|
||||||
assert_eq!(fetched_job.state, JobState::Completed);
|
|
||||||
|
|
||||||
// Simulate sync: complete the run
|
|
||||||
db.complete_routine_run(run.id, RunStatus::Ok, Some("Job completed"), None)
|
|
||||||
.await
|
|
||||||
.expect("complete run");
|
|
||||||
|
|
||||||
// Run should no longer appear in dispatched list
|
|
||||||
let dispatched_after = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched after");
|
|
||||||
assert!(
|
|
||||||
dispatched_after.is_empty(),
|
|
||||||
"Finalized run should not appear in dispatched list"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 3: Failed job causes run to be finalized as Failed
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dispatched_run_with_failed_job() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
let mut job = JobContext::new("Failing job", "Will fail");
|
|
||||||
job.state = JobState::Failed;
|
|
||||||
db.save_job(&job).await.expect("save job");
|
|
||||||
|
|
||||||
let run = make_run(routine_id, Some(job.job_id));
|
|
||||||
db.create_routine_run(&run).await.expect("create run");
|
|
||||||
|
|
||||||
// Verify job is failed
|
|
||||||
let fetched_job = db
|
|
||||||
.get_job(job.job_id)
|
|
||||||
.await
|
|
||||||
.expect("get job")
|
|
||||||
.expect("job should exist");
|
|
||||||
assert_eq!(fetched_job.state, JobState::Failed);
|
|
||||||
|
|
||||||
// Simulate sync: complete the run as failed
|
|
||||||
db.complete_routine_run(run.id, RunStatus::Failed, Some("Job failed"), None)
|
|
||||||
.await
|
|
||||||
.expect("complete run as failed");
|
|
||||||
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
assert!(dispatched.is_empty(), "Failed run should be finalized");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 4: Active (InProgress) job leaves run as running
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dispatched_run_with_active_job_stays_running() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
let mut job = JobContext::new("Active job", "Still running");
|
|
||||||
job.state = JobState::InProgress;
|
|
||||||
db.save_job(&job).await.expect("save job");
|
|
||||||
|
|
||||||
let run = make_run(routine_id, Some(job.job_id));
|
|
||||||
db.create_routine_run(&run).await.expect("create run");
|
|
||||||
|
|
||||||
// Verify job is still active
|
|
||||||
let fetched_job = db
|
|
||||||
.get_job(job.job_id)
|
|
||||||
.await
|
|
||||||
.expect("get job")
|
|
||||||
.expect("job should exist");
|
|
||||||
assert!(!fetched_job.state.is_terminal());
|
|
||||||
|
|
||||||
// Run should still be in dispatched list (not finalized)
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
assert_eq!(
|
|
||||||
dispatched.len(),
|
|
||||||
1,
|
|
||||||
"Run with active job should remain dispatched"
|
|
||||||
);
|
|
||||||
assert_eq!(dispatched[0].status, RunStatus::Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 5: Orphaned run (job_id set but job record missing)
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dispatched_run_orphan_detection() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
// Create a real job so the FK constraint is satisfied
|
|
||||||
let job = JobContext::new("Will be orphaned", "Test orphan detection");
|
|
||||||
db.save_job(&job).await.expect("save job");
|
|
||||||
|
|
||||||
let run = make_run(routine_id, Some(job.job_id));
|
|
||||||
db.create_routine_run(&run).await.expect("create run");
|
|
||||||
|
|
||||||
// The run appears in dispatched list
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
assert_eq!(dispatched.len(), 1);
|
|
||||||
|
|
||||||
// Verify orphan detection: a random UUID returns None from get_job
|
|
||||||
let nonexistent_id = Uuid::new_v4();
|
|
||||||
let missing = db
|
|
||||||
.get_job(nonexistent_id)
|
|
||||||
.await
|
|
||||||
.expect("get_job should not error");
|
|
||||||
assert!(
|
|
||||||
missing.is_none(),
|
|
||||||
"get_job for nonexistent ID should return None"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate sync handling of an orphaned run: mark as failed
|
|
||||||
db.complete_routine_run(
|
|
||||||
run.id,
|
|
||||||
RunStatus::Failed,
|
|
||||||
Some(&format!("Linked job {} not found (orphaned)", job.job_id)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("complete orphaned run");
|
|
||||||
|
|
||||||
let dispatched_after = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched after");
|
|
||||||
assert!(
|
|
||||||
dispatched_after.is_empty(),
|
|
||||||
"Finalized run should not appear in dispatched list"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 6: link_routine_run_to_job then list shows linked run
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn link_and_list_dispatched_run() {
|
|
||||||
let (db, _tmp) = create_test_db().await;
|
|
||||||
let routine_id = Uuid::new_v4();
|
|
||||||
let routine = make_routine(routine_id);
|
|
||||||
db.create_routine(&routine).await.expect("create routine");
|
|
||||||
|
|
||||||
// Create job record (FK constraint)
|
|
||||||
let job = JobContext::new("Linked job", "Test linking");
|
|
||||||
db.save_job(&job).await.expect("save job");
|
|
||||||
|
|
||||||
// Create a running run without job_id initially
|
|
||||||
let run = make_run(routine_id, None);
|
|
||||||
db.create_routine_run(&run).await.expect("create run");
|
|
||||||
|
|
||||||
// Should not appear in dispatched list yet
|
|
||||||
let dispatched = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched");
|
|
||||||
assert!(
|
|
||||||
dispatched.is_empty(),
|
|
||||||
"Run without job_id should not be dispatched"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Link the run to the job
|
|
||||||
db.link_routine_run_to_job(run.id, job.job_id)
|
|
||||||
.await
|
|
||||||
.expect("link run to job");
|
|
||||||
|
|
||||||
// Now it should appear
|
|
||||||
let dispatched_after = db
|
|
||||||
.list_dispatched_routine_runs()
|
|
||||||
.await
|
|
||||||
.expect("list dispatched after link");
|
|
||||||
assert_eq!(
|
|
||||||
dispatched_after.len(),
|
|
||||||
1,
|
|
||||||
"Linked run should appear in dispatched list"
|
|
||||||
);
|
|
||||||
assert_eq!(dispatched_after[0].job_id, Some(job.job_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-11
@@ -45,13 +45,12 @@ SEL = {
|
|||||||
"approval_always_btn": ".approval-actions button.always",
|
"approval_always_btn": ".approval-actions button.always",
|
||||||
"approval_deny_btn": ".approval-actions button.deny",
|
"approval_deny_btn": ".approval-actions button.deny",
|
||||||
"approval_resolved": ".approval-resolved",
|
"approval_resolved": ".approval-resolved",
|
||||||
# Settings subtabs
|
# Extensions tab – sections
|
||||||
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
|
|
||||||
"settings_subpanel": "#settings-{subtab}",
|
|
||||||
# Extensions section
|
|
||||||
"extensions_list": "#extensions-list",
|
"extensions_list": "#extensions-list",
|
||||||
"available_wasm_list": "#available-wasm-list",
|
"available_wasm_list": "#available-wasm-list",
|
||||||
"mcp_servers_list": "#mcp-servers-list",
|
"mcp_servers_list": "#mcp-servers-list",
|
||||||
|
"tools_tbody": "#tools-tbody",
|
||||||
|
"tools_empty": "#tools-empty",
|
||||||
# Extensions tab – cards
|
# Extensions tab – cards
|
||||||
"ext_card_installed": "#extensions-list .ext-card",
|
"ext_card_installed": "#extensions-list .ext-card",
|
||||||
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
|
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
|
||||||
@@ -93,12 +92,6 @@ SEL = {
|
|||||||
"ext_stepper": ".ext-stepper",
|
"ext_stepper": ".ext-stepper",
|
||||||
"stepper_step": ".stepper-step",
|
"stepper_step": ".stepper-step",
|
||||||
"stepper_circle": ".stepper-circle",
|
"stepper_circle": ".stepper-circle",
|
||||||
# Confirm modal (custom, replaces window.confirm)
|
|
||||||
"confirm_modal": "#confirm-modal",
|
|
||||||
"confirm_modal_btn": "#confirm-modal-btn",
|
|
||||||
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
|
|
||||||
# Channels subtab – cards
|
|
||||||
"channels_ext_card": "#settings-channels-content .ext-card",
|
|
||||||
# Toast notifications
|
# Toast notifications
|
||||||
"toast": ".toast",
|
"toast": ".toast",
|
||||||
"toast_success": ".toast.toast-success",
|
"toast_success": ".toast.toast-success",
|
||||||
@@ -113,7 +106,7 @@ SEL = {
|
|||||||
"routines_empty": "#routines-empty",
|
"routines_empty": "#routines-empty",
|
||||||
}
|
}
|
||||||
|
|
||||||
TABS = ["chat", "memory", "jobs", "routines", "settings"]
|
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||||
|
|
||||||
# Auth token used across all tests
|
# Auth token used across all tests
|
||||||
AUTH_TOKEN = "e2e-test-token"
|
AUTH_TOKEN = "e2e-test-token"
|
||||||
|
|||||||
+4
-14
@@ -267,24 +267,14 @@ async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.Str
|
|||||||
async def oauth_exchange(request: web.Request) -> web.Response:
|
async def oauth_exchange(request: web.Request) -> web.Response:
|
||||||
"""Mock OAuth token exchange proxy for E2E tests.
|
"""Mock OAuth token exchange proxy for E2E tests.
|
||||||
|
|
||||||
Accepts the generic hosted OAuth proxy contract used by IronClaw and
|
Accepts form params (code, redirect_uri, code_verifier) and returns
|
||||||
returns a fake token response. MCP callback tests assert that provider-
|
a fake token response. Called by ironclaw's exchange_via_proxy() when
|
||||||
specific token params such as RFC 8707 `resource` are forwarded here.
|
IRONCLAW_OAUTH_EXCHANGE_URL is set.
|
||||||
"""
|
"""
|
||||||
data = await request.post()
|
data = await request.post()
|
||||||
code = data.get("code", "")
|
code = data.get("code", "")
|
||||||
access_token_field = data.get("access_token_field", "access_token")
|
|
||||||
|
|
||||||
if code == "mock_mcp_code":
|
|
||||||
if not data.get("token_url", "").endswith("/oauth/token"):
|
|
||||||
return web.json_response({"error": "missing_token_url"}, status=400)
|
|
||||||
if not data.get("client_id"):
|
|
||||||
return web.json_response({"error": "missing_client_id"}, status=400)
|
|
||||||
if not data.get("resource"):
|
|
||||||
return web.json_response({"error": "missing_resource"}, status=400)
|
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
access_token_field: f"mock-token-{code}",
|
"access_token": f"mock-token-{code}",
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,21 +87,23 @@ _REGISTRY_MCP = {
|
|||||||
"installed": False,
|
"installed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
|
||||||
|
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
|
||||||
|
|
||||||
|
|
||||||
# ─── Navigation helpers ────────────────────────────────────────────────────────
|
# ─── Navigation helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def go_to_extensions(page):
|
async def go_to_extensions(page):
|
||||||
"""Navigate to Settings > Extensions subtab and wait for content.
|
"""Click the Extensions tab and wait for the panel to appear.
|
||||||
|
|
||||||
Waits for loadExtensions() to finish rendering by polling for the first
|
Waits for loadExtensions() to finish rendering by polling for the first
|
||||||
content signal (empty-state div or an installed card) rather than sleeping.
|
content signal (empty-state div or an installed card) rather than sleeping.
|
||||||
"""
|
"""
|
||||||
await page.locator(SEL["tab_button"].format(tab="settings")).click()
|
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
|
||||||
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
|
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
|
||||||
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
|
|
||||||
state="visible", timeout=5000
|
state="visible", timeout=5000
|
||||||
)
|
)
|
||||||
# loadExtensions() fires parallel fetches then renders. Wait for the
|
# loadExtensions() fires three parallel fetches then renders. Wait for the
|
||||||
# first concrete DOM signal instead of a hard sleep so the test is
|
# first concrete DOM signal instead of a hard sleep so the test is
|
||||||
# deterministic even under CI load.
|
# deterministic even under CI load.
|
||||||
await page.locator(
|
await page.locator(
|
||||||
@@ -109,39 +111,19 @@ async def go_to_extensions(page):
|
|||||||
).first.wait_for(state="visible", timeout=8000)
|
).first.wait_for(state="visible", timeout=8000)
|
||||||
|
|
||||||
|
|
||||||
async def go_to_channels(page):
|
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
|
||||||
"""Navigate to Settings > Channels subtab and wait for content."""
|
"""Intercept the three extension list APIs with fixture data.
|
||||||
await page.locator(SEL["tab_button"].format(tab="settings")).click()
|
|
||||||
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
|
|
||||||
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
|
|
||||||
state="visible", timeout=5000
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Must be called BEFORE navigating to the extensions tab.
|
||||||
async def go_to_mcp(page):
|
|
||||||
"""Navigate to Settings > MCP subtab and wait for content."""
|
|
||||||
await page.locator(SEL["tab_button"].format(tab="settings")).click()
|
|
||||||
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
|
|
||||||
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
|
|
||||||
state="visible", timeout=5000
|
|
||||||
)
|
|
||||||
await page.locator(
|
|
||||||
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
|
|
||||||
).first.wait_for(state="visible", timeout=8000)
|
|
||||||
|
|
||||||
|
|
||||||
async def mock_ext_apis(page, *, installed=None, registry=None):
|
|
||||||
"""Intercept the extension list APIs with fixture data.
|
|
||||||
|
|
||||||
Must be called BEFORE navigating to the extensions subtab.
|
|
||||||
"""
|
"""
|
||||||
ext_body = json.dumps({"extensions": installed or []})
|
ext_body = json.dumps({"extensions": installed or []})
|
||||||
|
tools_body = json.dumps({"tools": tools or []})
|
||||||
registry_body = json.dumps({"entries": registry or []})
|
registry_body = json.dumps({"entries": registry or []})
|
||||||
|
|
||||||
# Playwright evaluates route handlers in LIFO order (last-registered fires
|
# Playwright evaluates route handlers in LIFO order (last-registered fires
|
||||||
# first). Register the broad handler first so it is checked last; the
|
# first). Register the broad handler first so it is checked last; the
|
||||||
# specific /registry handler is registered after and therefore checked
|
# specific /tools and /registry handlers are registered after and therefore
|
||||||
# first — no continue_() fallthrough needed.
|
# checked first — no continue_() fallthrough needed.
|
||||||
async def handle_ext_list(route):
|
async def handle_ext_list(route):
|
||||||
path = route.request.url.split("?")[0]
|
path = route.request.url.split("?")[0]
|
||||||
if path.endswith("/api/extensions"):
|
if path.endswith("/api/extensions"):
|
||||||
@@ -151,9 +133,13 @@ async def mock_ext_apis(page, *, installed=None, registry=None):
|
|||||||
|
|
||||||
await page.route("**/api/extensions*", handle_ext_list)
|
await page.route("**/api/extensions*", handle_ext_list)
|
||||||
|
|
||||||
|
async def handle_tools(route):
|
||||||
|
await route.fulfill(status=200, content_type="application/json", body=tools_body)
|
||||||
|
|
||||||
async def handle_registry(route):
|
async def handle_registry(route):
|
||||||
await route.fulfill(status=200, content_type="application/json", body=registry_body)
|
await route.fulfill(status=200, content_type="application/json", body=registry_body)
|
||||||
|
|
||||||
|
await page.route("**/api/extensions/tools", handle_tools)
|
||||||
await page.route("**/api/extensions/registry", handle_registry)
|
await page.route("**/api/extensions/registry", handle_registry)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,17 +151,46 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
|
|||||||
# ─── Group A: Structural / empty state ────────────────────────────────────────
|
# ─── Group A: Structural / empty state ────────────────────────────────────────
|
||||||
|
|
||||||
async def test_extensions_empty_tab_layout(page):
|
async def test_extensions_empty_tab_layout(page):
|
||||||
"""Extensions subtab with no data shows sections with correct empty-state messages."""
|
"""Extensions tab with no data shows all three sections with correct empty-state messages."""
|
||||||
await mock_ext_apis(page)
|
await mock_ext_apis(page, tools=[])
|
||||||
await go_to_extensions(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
|
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
|
||||||
assert await panel.is_visible()
|
assert await panel.is_visible()
|
||||||
|
|
||||||
ext_list = page.locator(SEL["extensions_list"])
|
ext_list = page.locator(SEL["extensions_list"])
|
||||||
assert await ext_list.is_visible()
|
assert await ext_list.is_visible()
|
||||||
assert "No extensions installed" in await ext_list.text_content()
|
assert "No extensions installed" in await ext_list.text_content()
|
||||||
|
|
||||||
|
wasm_list = page.locator(SEL["available_wasm_list"])
|
||||||
|
assert await wasm_list.is_visible()
|
||||||
|
assert "No additional WASM extensions available" in await wasm_list.text_content()
|
||||||
|
|
||||||
|
mcp_list = page.locator(SEL["mcp_servers_list"])
|
||||||
|
assert await mcp_list.is_visible()
|
||||||
|
assert "No MCP servers available" in await mcp_list.text_content()
|
||||||
|
|
||||||
|
# Tools table should be empty
|
||||||
|
tbody = page.locator(SEL["tools_tbody"])
|
||||||
|
rows = await tbody.locator("tr").count()
|
||||||
|
empty_visible = await page.locator(SEL["tools_empty"]).is_visible()
|
||||||
|
assert empty_visible or rows == 0, "Expected tools table to be empty"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extensions_tools_table_populated(page):
|
||||||
|
"""Two mock tools produce two rows in the tools table."""
|
||||||
|
await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2])
|
||||||
|
await go_to_extensions(page)
|
||||||
|
|
||||||
|
tbody = page.locator(SEL["tools_tbody"])
|
||||||
|
rows = tbody.locator("tr")
|
||||||
|
await rows.first.wait_for(state="visible", timeout=5000)
|
||||||
|
assert await rows.count() == 2
|
||||||
|
|
||||||
|
text = await tbody.text_content()
|
||||||
|
assert "echo" in text
|
||||||
|
assert "time" in text
|
||||||
|
|
||||||
|
|
||||||
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
|
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -233,9 +248,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
|
|||||||
async def test_installed_mcp_server_active(page):
|
async def test_installed_mcp_server_active(page):
|
||||||
"""Active MCP server shows 'Active' label and no Activate button."""
|
"""Active MCP server shows 'Active' label and no Activate button."""
|
||||||
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
|
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
card = page.locator(SEL["ext_card_mcp"]).first
|
card = page.locator(SEL["ext_card_installed"]).first
|
||||||
await card.wait_for(state="visible", timeout=5000)
|
await card.wait_for(state="visible", timeout=5000)
|
||||||
assert await card.locator(SEL["ext_active_label"]).count() == 1
|
assert await card.locator(SEL["ext_active_label"]).count() == 1
|
||||||
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
|
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
|
||||||
@@ -245,9 +260,9 @@ async def test_installed_mcp_server_active(page):
|
|||||||
async def test_installed_mcp_server_inactive_shows_activate(page):
|
async def test_installed_mcp_server_inactive_shows_activate(page):
|
||||||
"""Inactive MCP server shows Activate button."""
|
"""Inactive MCP server shows Activate button."""
|
||||||
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
card = page.locator(SEL["ext_card_mcp"]).first
|
card = page.locator(SEL["ext_card_installed"]).first
|
||||||
await card.wait_for(state="visible", timeout=5000)
|
await card.wait_for(state="visible", timeout=5000)
|
||||||
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
|
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
|
||||||
|
|
||||||
@@ -255,7 +270,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
|
|||||||
async def test_mcp_server_in_registry_not_installed(page):
|
async def test_mcp_server_in_registry_not_installed(page):
|
||||||
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
|
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
|
||||||
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
|
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
mcp_list = page.locator(SEL["mcp_servers_list"])
|
mcp_list = page.locator(SEL["mcp_servers_list"])
|
||||||
card = mcp_list.locator(".ext-card").first
|
card = mcp_list.locator(".ext-card").first
|
||||||
@@ -270,7 +285,7 @@ async def test_mcp_server_installed_auth_dot(page):
|
|||||||
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
|
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
|
||||||
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
|
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
|
||||||
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
|
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
mcp_list = page.locator(SEL["mcp_servers_list"])
|
mcp_list = page.locator(SEL["mcp_servers_list"])
|
||||||
card = mcp_list.locator(".ext-card").first
|
card = mcp_list.locator(".ext-card").first
|
||||||
@@ -284,9 +299,8 @@ async def test_mcp_server_installed_auth_dot(page):
|
|||||||
async def _load_wasm_channel(page, activation_status, activation_error=None):
|
async def _load_wasm_channel(page, activation_status, activation_error=None):
|
||||||
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
|
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
|
||||||
await mock_ext_apis(page, installed=[ext])
|
await mock_ext_apis(page, installed=[ext])
|
||||||
await go_to_channels(page)
|
await go_to_extensions(page)
|
||||||
# Find the WASM channel card specifically (not built-in channel cards)
|
card = page.locator(SEL["ext_card_installed"]).first
|
||||||
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first
|
|
||||||
await card.wait_for(state="visible", timeout=5000)
|
await card.wait_for(state="visible", timeout=5000)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@@ -432,9 +446,9 @@ async def test_install_wasm_channel_triggers_configure(page):
|
|||||||
|
|
||||||
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
|
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
|
||||||
await page.route("**/api/extensions/install", handle_channel_install)
|
await page.route("**/api/extensions/install", handle_channel_install)
|
||||||
await go_to_channels(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first
|
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
|
||||||
await install_btn.wait_for(state="visible", timeout=5000)
|
await install_btn.wait_for(state="visible", timeout=5000)
|
||||||
await install_btn.click()
|
await install_btn.click()
|
||||||
|
|
||||||
@@ -509,14 +523,13 @@ async def test_remove_installed_extension_confirmed(page):
|
|||||||
# Override for subsequent calls
|
# Override for subsequent calls
|
||||||
await page.route("**/api/extensions*", handle_ext_empty)
|
await page.route("**/api/extensions*", handle_ext_empty)
|
||||||
|
|
||||||
|
# Auto-accept confirm dialog
|
||||||
|
await page.evaluate("window.confirm = () => true")
|
||||||
|
|
||||||
card = page.locator(SEL["ext_card_installed"]).first
|
card = page.locator(SEL["ext_card_installed"]).first
|
||||||
await card.wait_for(state="visible", timeout=5000)
|
await card.wait_for(state="visible", timeout=5000)
|
||||||
await card.locator(SEL["ext_remove_btn"]).click()
|
await card.locator(SEL["ext_remove_btn"]).click()
|
||||||
|
|
||||||
# Confirm via custom modal
|
|
||||||
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
|
|
||||||
await page.locator(SEL["confirm_modal_btn"]).click()
|
|
||||||
|
|
||||||
# Card should disappear
|
# Card should disappear
|
||||||
await page.wait_for_function(
|
await page.wait_for_function(
|
||||||
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
|
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
|
||||||
@@ -530,14 +543,13 @@ async def test_remove_cancelled_keeps_card(page):
|
|||||||
await mock_ext_apis(page, installed=[_WASM_TOOL])
|
await mock_ext_apis(page, installed=[_WASM_TOOL])
|
||||||
await go_to_extensions(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
|
# Reject the confirm dialog
|
||||||
|
await page.evaluate("window.confirm = () => false")
|
||||||
|
|
||||||
card = page.locator(SEL["ext_card_installed"]).first
|
card = page.locator(SEL["ext_card_installed"]).first
|
||||||
await card.wait_for(state="visible", timeout=5000)
|
await card.wait_for(state="visible", timeout=5000)
|
||||||
await card.locator(SEL["ext_remove_btn"]).click()
|
await card.locator(SEL["ext_remove_btn"]).click()
|
||||||
|
|
||||||
# Cancel via custom modal
|
|
||||||
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
|
|
||||||
await page.locator(SEL["confirm_modal_cancel"]).click()
|
|
||||||
|
|
||||||
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
|
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
|
||||||
|
|
||||||
|
|
||||||
@@ -961,10 +973,14 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
|||||||
else:
|
else:
|
||||||
await route.continue_()
|
await route.continue_()
|
||||||
|
|
||||||
|
async def handle_tools(route):
|
||||||
|
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
|
||||||
|
|
||||||
async def handle_registry(route):
|
async def handle_registry(route):
|
||||||
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||||
|
|
||||||
await page.route("**/api/extensions*", counting_handler)
|
await page.route("**/api/extensions*", counting_handler)
|
||||||
|
await page.route("**/api/extensions/tools", handle_tools)
|
||||||
await page.route("**/api/extensions/registry", handle_registry)
|
await page.route("**/api/extensions/registry", handle_registry)
|
||||||
|
|
||||||
await go_to_extensions(page)
|
await go_to_extensions(page)
|
||||||
@@ -973,9 +989,6 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
|||||||
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
|
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
|
||||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
|
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
|
||||||
|
|
||||||
# Inject a counter to confirm refreshCurrentSettingsTab is called
|
|
||||||
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
|
|
||||||
|
|
||||||
await page.evaluate("""
|
await page.evaluate("""
|
||||||
handleAuthCompleted({
|
handleAuthCompleted({
|
||||||
extension_name: 'gmail',
|
extension_name: 'gmail',
|
||||||
@@ -986,11 +999,14 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
|
|||||||
|
|
||||||
await wait_for_toast(page, "OAuth flow expired. Please try again.")
|
await wait_for_toast(page, "OAuth flow expired. Please try again.")
|
||||||
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
|
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
|
||||||
|
assert (
|
||||||
|
await page.locator(
|
||||||
|
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
|
||||||
|
).count()
|
||||||
|
>= 1
|
||||||
|
)
|
||||||
|
|
||||||
# Wait for the refresh to complete
|
await page.wait_for_timeout(600)
|
||||||
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
|
|
||||||
# Give the async fetch time to complete
|
|
||||||
await page.wait_for_timeout(1000)
|
|
||||||
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
|
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,9 +1026,9 @@ async def test_activate_mcp_server_success(page):
|
|||||||
|
|
||||||
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
|
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||||
await activate_btn.wait_for(state="visible", timeout=5000)
|
await activate_btn.wait_for(state="visible", timeout=5000)
|
||||||
|
|
||||||
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
|
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
|
||||||
@@ -1035,9 +1051,9 @@ async def test_activate_awaiting_token_opens_configure(page):
|
|||||||
|
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
|
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
|
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||||
await activate_btn.wait_for(state="visible", timeout=5000)
|
await activate_btn.wait_for(state="visible", timeout=5000)
|
||||||
await activate_btn.click()
|
await activate_btn.click()
|
||||||
|
|
||||||
@@ -1054,9 +1070,9 @@ async def test_activate_failure_shows_error_toast(page):
|
|||||||
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
|
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
|
||||||
|
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
|
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||||
await activate_btn.wait_for(state="visible", timeout=5000)
|
await activate_btn.wait_for(state="visible", timeout=5000)
|
||||||
await activate_btn.click()
|
await activate_btn.click()
|
||||||
|
|
||||||
@@ -1072,9 +1088,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
|||||||
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
|
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
|
||||||
|
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
|
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||||
await activate_btn.wait_for(state="visible", timeout=5000)
|
await activate_btn.wait_for(state="visible", timeout=5000)
|
||||||
await activate_btn.click()
|
await activate_btn.click()
|
||||||
|
|
||||||
@@ -1090,7 +1106,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
|
|||||||
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
|
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
|
||||||
|
|
||||||
async def test_extensions_tab_reloads_on_revisit(page):
|
async def test_extensions_tab_reloads_on_revisit(page):
|
||||||
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
|
"""loadExtensions() is called again when re-navigating to the extensions tab."""
|
||||||
call_count = []
|
call_count = []
|
||||||
|
|
||||||
async def counting_handler(route):
|
async def counting_handler(route):
|
||||||
@@ -1105,10 +1121,14 @@ async def test_extensions_tab_reloads_on_revisit(page):
|
|||||||
else:
|
else:
|
||||||
await route.continue_()
|
await route.continue_()
|
||||||
|
|
||||||
|
async def handle_tools(route):
|
||||||
|
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
|
||||||
|
|
||||||
async def handle_registry(route):
|
async def handle_registry(route):
|
||||||
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||||
|
|
||||||
await page.route("**/api/extensions*", counting_handler)
|
await page.route("**/api/extensions*", counting_handler)
|
||||||
|
await page.route("**/api/extensions/tools", handle_tools)
|
||||||
await page.route("**/api/extensions/registry", handle_registry)
|
await page.route("**/api/extensions/registry", handle_registry)
|
||||||
|
|
||||||
# First visit
|
# First visit
|
||||||
@@ -1128,6 +1148,48 @@ async def test_extensions_tab_reloads_on_revisit(page):
|
|||||||
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
|
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_completed_sse_triggers_extensions_reload(page):
|
||||||
|
"""auth_completed SSE event while on the extensions tab triggers a reload."""
|
||||||
|
reload_count = []
|
||||||
|
|
||||||
|
async def counting_handler(route):
|
||||||
|
path = route.request.url.split("?")[0]
|
||||||
|
if path.endswith("/api/extensions"):
|
||||||
|
reload_count.append(1)
|
||||||
|
await route.fulfill(
|
||||||
|
status=200,
|
||||||
|
content_type="application/json",
|
||||||
|
body=json.dumps({"extensions": []}),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
|
||||||
|
async def handle_tools(route):
|
||||||
|
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
|
||||||
|
|
||||||
|
async def handle_registry(route):
|
||||||
|
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
|
||||||
|
|
||||||
|
await page.route("**/api/extensions*", counting_handler)
|
||||||
|
await page.route("**/api/extensions/tools", handle_tools)
|
||||||
|
await page.route("**/api/extensions/registry", handle_registry)
|
||||||
|
|
||||||
|
await go_to_extensions(page)
|
||||||
|
count_before = len(reload_count)
|
||||||
|
|
||||||
|
# Simulate auth_completed via the shared handler.
|
||||||
|
await page.evaluate("""
|
||||||
|
handleAuthCompleted({
|
||||||
|
extension_name: 'reload-ext',
|
||||||
|
success: true,
|
||||||
|
message: 'Reloaded.',
|
||||||
|
});
|
||||||
|
""")
|
||||||
|
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed"
|
||||||
|
|
||||||
|
|
||||||
# ─── Regression tests ─────────────────────────────────────────────────────────
|
# ─── Regression tests ─────────────────────────────────────────────────────────
|
||||||
# Each test below is a regression for a specific bug found after the initial
|
# Each test below is a regression for a specific bug found after the initial
|
||||||
# test suite was written. The bug description is in the docstring.
|
# test suite was written. The bug description is in the docstring.
|
||||||
@@ -1205,9 +1267,9 @@ async def test_oauth_url_injection_blocked(page):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
|
||||||
await go_to_mcp(page)
|
await go_to_extensions(page)
|
||||||
|
|
||||||
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
|
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
|
||||||
await activate_btn.wait_for(state="visible", timeout=5000)
|
await activate_btn.wait_for(state="visible", timeout=5000)
|
||||||
await activate_btn.click()
|
await activate_btn.click()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user