mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bf5ce565 | ||
|
|
4d5ba6b7e0 | ||
|
|
df920b9651 |
Generated
+151
-23
@@ -628,13 +628,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-named-pipe",
|
||||
"hyper-rustls",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-util",
|
||||
"hyperlocal",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
@@ -2558,6 +2558,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"log",
|
||||
"rustls 0.22.4",
|
||||
"rustls-native-certs 0.7.3",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.25.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
@@ -2567,11 +2585,11 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
@@ -2905,14 +2923,15 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rig-core",
|
||||
"rust-analyzer",
|
||||
"rust_decimal",
|
||||
"rust_decimal_macros",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustyline",
|
||||
"secrecy",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3130,6 +3149,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64 0.21.7",
|
||||
"bincode",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
@@ -3137,14 +3157,18 @@ dependencies = [
|
||||
"futures",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"hyper-rustls 0.25.0",
|
||||
"libsql-hrana",
|
||||
"libsql-sqlite3-parser",
|
||||
"libsql-sys",
|
||||
"libsql_replication",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tonic",
|
||||
"tonic-web",
|
||||
"tower 0.4.13",
|
||||
@@ -3164,6 +3188,18 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsql-hrana"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeaf5d19e365465e1c23d687a28c805d7462531b3f619f0ba49d3cf369890a3e"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"prost",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsql-rusqlite"
|
||||
version = "0.33.0"
|
||||
@@ -3487,10 +3523,10 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -3737,6 +3773,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4261,7 +4303,7 @@ dependencies = [
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -4281,7 +4323,7 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls 0.23.37",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
@@ -4620,7 +4662,7 @@ dependencies = [
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-rustls",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
@@ -4631,8 +4673,8 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4640,7 +4682,7 @@ dependencies = [
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
@@ -4727,6 +4769,12 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-analyzer"
|
||||
version = "0.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11707871ffa56ce568d4f15dd34c2f891a2aa5e4b3435b99b8f99938492525c3"
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.40.0"
|
||||
@@ -4817,6 +4865,20 @@ dependencies = [
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -4826,21 +4888,34 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.103.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
|
||||
dependencies = [
|
||||
"openssl-probe 0.1.6",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4862,6 +4937,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
@@ -5030,6 +5116,19 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -5931,20 +6030,31 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls 0.23.37",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.26.4",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
|
||||
dependencies = [
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.23.37",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -7136,6 +7246,24 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
@@ -166,6 +166,7 @@ html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
readabilityrs = { version = "0.1.2", optional = true }
|
||||
ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
rust-analyzer = "0.0.1"
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
+2
-1
@@ -433,6 +433,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
@@ -551,7 +552,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ✅ Webhook trigger endpoint in web gateway (`/api/webhooks/github` -> `system_event` routines)
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: ironclaw-workflow-orchestrator
|
||||
description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes."
|
||||
---
|
||||
|
||||
# IronClaw Workflow Orchestrator
|
||||
|
||||
## Overview
|
||||
Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis.
|
||||
|
||||
## Workflow
|
||||
1. Gather workflow parameters.
|
||||
2. Verify runtime prerequisites.
|
||||
3. Install or update routine set from templates.
|
||||
4. Run a dry test with `event_emit`.
|
||||
5. Monitor outcomes and tune prompts/filters.
|
||||
|
||||
## Parameters
|
||||
Collect these values before creating routines:
|
||||
- `repository`: `owner/repo` (required)
|
||||
- `maintainers`: GitHub handles allowed to trigger implement/replan actions
|
||||
- `staging_branch`: default `staging`
|
||||
- `main_branch`: default `main`
|
||||
- `batch_interval_hours`: default `8`
|
||||
- `implementation_label`: default `autonomous-impl`
|
||||
|
||||
## Prerequisites
|
||||
Before installing routines, verify:
|
||||
- Routines system enabled.
|
||||
- GitHub tool authenticated (for issue/PR/comment/status operations).
|
||||
- GitHub webhook delivery configured to `POST /api/webhooks/github`.
|
||||
- Optional webhook secret configured (`GITHUB_WEBHOOK_SECRET` or gateway setting `github.webhook_secret`).
|
||||
|
||||
## Install Procedure
|
||||
1. Open [`workflow-routines.md`](references/workflow-routines.md).
|
||||
2. For each template block:
|
||||
- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names)
|
||||
- call `routine_create`
|
||||
3. If a routine already exists:
|
||||
- use `routine_update` instead of creating duplicates
|
||||
- keep names stable so long-lived metrics/history stay intact
|
||||
4. Confirm install with `routine_list` and `routine_history`.
|
||||
|
||||
## Routine Set
|
||||
Install these routines:
|
||||
- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist.
|
||||
- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation.
|
||||
- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch.
|
||||
- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates.
|
||||
- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main.
|
||||
- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory.
|
||||
|
||||
## Event Filters
|
||||
Prefer top-level filters for stability:
|
||||
- `repository` (string)
|
||||
- `sender` (string)
|
||||
- `issue_number` / `pr_number`
|
||||
- `ci_status`, `ci_conclusion`
|
||||
- `review_state`, `comment_author`
|
||||
|
||||
Use narrow filters to avoid accidental triggers across repos.
|
||||
|
||||
## Operating Rules
|
||||
- All implementation work must occur on non-main branches.
|
||||
- PR loop must resolve both human and AI review comments.
|
||||
- On conflicts with `origin/main`, refresh branch before continuing.
|
||||
- Staging-batch routine is the only path for bulk correctness verification before mainline merge.
|
||||
- Memory update routine runs only after successful merge.
|
||||
|
||||
## Validation
|
||||
After install, run:
|
||||
1. `event_emit` with a synthetic `issue.opened` payload for the target repo.
|
||||
2. Confirm at least one routine fired.
|
||||
3. Check corresponding `routine_history` entries.
|
||||
4. Confirm no unrelated routines fired.
|
||||
|
||||
## When To Update Templates
|
||||
Update this skill when:
|
||||
- GitHub event names/payload fields change.
|
||||
- Team review policy changes (e.g., staging cadence, maintainer gates).
|
||||
- New CI policy requires different failure routing.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "IronClaw Workflow Orchestrator"
|
||||
short_description: "Install and run event-driven GitHub workflow routines"
|
||||
default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers."
|
||||
@@ -0,0 +1,128 @@
|
||||
# Workflow Routine Templates
|
||||
|
||||
Replace `{{...}}` placeholders before use.
|
||||
|
||||
## 1) Issue -> Plan
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-issue-plan",
|
||||
"description": "Create implementation plan when a new issue arrives",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"event_filters": {
|
||||
"repository": "{{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.",
|
||||
"cooldown_secs": 30
|
||||
}
|
||||
```
|
||||
|
||||
## 2) Maintainer Comment Gate (Update Plan vs Implement)
|
||||
|
||||
Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-maintainer-comment-gate-{{maintainer}}",
|
||||
"description": "React to maintainer guidance comments on issues/PRs",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "pr.comment.created",
|
||||
"event_filters": {
|
||||
"repository": "{{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.",
|
||||
"cooldown_secs": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 3) PR Monitor Loop
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-pr-monitor-loop",
|
||||
"description": "Keep PR healthy: address review comments and refresh branch",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "pr.synchronize",
|
||||
"event_filters": {
|
||||
"repository": "{{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.",
|
||||
"cooldown_secs": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 4) CI Failure Fix Loop
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-ci-fix-loop",
|
||||
"description": "Fix failing CI checks on active PRs",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "ci.check_run.completed",
|
||||
"event_filters": {
|
||||
"repository": "{{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.",
|
||||
"cooldown_secs": 20
|
||||
}
|
||||
```
|
||||
|
||||
## 5) Staging Batch Review (Every 8h)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-staging-batch-review",
|
||||
"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.",
|
||||
"cooldown_secs": 120
|
||||
}
|
||||
```
|
||||
|
||||
## 6) Post-Merge Learning -> Common Memory
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "wf-learning-memory",
|
||||
"description": "Capture merge learnings into shared memory",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "pr.closed",
|
||||
"event_filters": {
|
||||
"repository": "{{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.",
|
||||
"cooldown_secs": 30
|
||||
}
|
||||
```
|
||||
|
||||
## Optional: Synthetic Event Test
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"payload": {
|
||||
"repository": "{{repository}}",
|
||||
"issue_number": 99999,
|
||||
"sender": "test-bot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use with `event_emit` after routine install.
|
||||
@@ -65,6 +65,16 @@ pub enum Trigger {
|
||||
/// Regex pattern to match against message content.
|
||||
pattern: String,
|
||||
},
|
||||
/// Fire when a structured system event is emitted.
|
||||
SystemEvent {
|
||||
/// Event source namespace (e.g. "github", "workflow", "tool").
|
||||
source: String,
|
||||
/// Event type within the source (e.g. "issue.opened").
|
||||
event_type: String,
|
||||
/// Optional exact-match filters against payload top-level fields.
|
||||
#[serde(default)]
|
||||
filters: std::collections::HashMap<String, String>,
|
||||
},
|
||||
/// Fire on incoming webhook POST to /hooks/routine/{id}.
|
||||
Webhook {
|
||||
/// Optional webhook path suffix (defaults to routine id).
|
||||
@@ -82,6 +92,7 @@ impl Trigger {
|
||||
match self {
|
||||
Trigger::Cron { .. } => "cron",
|
||||
Trigger::Event { .. } => "event",
|
||||
Trigger::SystemEvent { .. } => "system_event",
|
||||
Trigger::Webhook { .. } => "webhook",
|
||||
Trigger::Manual => "manual",
|
||||
}
|
||||
@@ -116,6 +127,38 @@ impl Trigger {
|
||||
.map(String::from);
|
||||
Ok(Trigger::Event { channel, pattern })
|
||||
}
|
||||
"system_event" => {
|
||||
let source = config
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "system_event trigger".into(),
|
||||
field: "source".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let event_type = config
|
||||
.get("event_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "system_event trigger".into(),
|
||||
field: "event_type".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let filters = config
|
||||
.get("filters")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(Trigger::SystemEvent {
|
||||
source,
|
||||
event_type,
|
||||
filters,
|
||||
})
|
||||
}
|
||||
"webhook" => {
|
||||
let path = config
|
||||
.get("path")
|
||||
@@ -142,6 +185,15 @@ impl Trigger {
|
||||
"pattern": pattern,
|
||||
"channel": channel,
|
||||
}),
|
||||
Trigger::SystemEvent {
|
||||
source,
|
||||
event_type,
|
||||
filters,
|
||||
} => serde_json::json!({
|
||||
"source": source,
|
||||
"event_type": event_type,
|
||||
"filters": filters,
|
||||
}),
|
||||
Trigger::Webhook { path, secret } => serde_json::json!({
|
||||
"path": path,
|
||||
"secret": secret,
|
||||
@@ -451,6 +503,24 @@ mod tests {
|
||||
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_event_trigger_roundtrip() {
|
||||
let mut filters = std::collections::HashMap::new();
|
||||
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
|
||||
filters.insert("action".to_string(), "opened".to_string());
|
||||
let trigger = Trigger::SystemEvent {
|
||||
source: "github".to_string(),
|
||||
event_type: "issue".to_string(),
|
||||
filters: filters.clone(),
|
||||
};
|
||||
let json = trigger.to_config_json();
|
||||
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
|
||||
assert!(
|
||||
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
|
||||
if source == "github" && event_type == "issue" && f == filters)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_lightweight_roundtrip() {
|
||||
let action = RoutineAction::Lightweight {
|
||||
@@ -552,6 +622,15 @@ mod tests {
|
||||
.type_tag(),
|
||||
"webhook"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::SystemEvent {
|
||||
source: String::new(),
|
||||
event_type: String::new(),
|
||||
filters: std::collections::HashMap::new(),
|
||||
}
|
||||
.type_tag(),
|
||||
"system_event"
|
||||
);
|
||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||
}
|
||||
}
|
||||
|
||||
+109
-6
@@ -31,6 +31,11 @@ use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::tools::ApprovalContext;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
enum EventMatcher {
|
||||
Message { routine: Routine, regex: Regex },
|
||||
System { routine: Routine },
|
||||
}
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
@@ -41,8 +46,8 @@ pub struct RoutineEngine {
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
/// Currently running routine count (across all routines).
|
||||
running_count: Arc<AtomicUsize>,
|
||||
/// Compiled event regex cache: routine_id -> compiled regex.
|
||||
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
|
||||
/// Cached matchers for all event-driven routines.
|
||||
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
|
||||
/// Scheduler for dispatching jobs (FullJob mode).
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
}
|
||||
@@ -74,9 +79,12 @@ impl RoutineEngine {
|
||||
Ok(routines) => {
|
||||
let mut cache = Vec::new();
|
||||
for routine in routines {
|
||||
if let Trigger::Event { ref pattern, .. } = routine.trigger {
|
||||
match Regex::new(pattern) {
|
||||
Ok(re) => cache.push((routine.id, routine.clone(), re)),
|
||||
match &routine.trigger {
|
||||
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
|
||||
Ok(re) => cache.push(EventMatcher::Message {
|
||||
routine: routine.clone(),
|
||||
regex: re,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
@@ -84,7 +92,13 @@ impl RoutineEngine {
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
},
|
||||
Trigger::SystemEvent { .. } => {
|
||||
cache.push(EventMatcher::System {
|
||||
routine: routine.clone(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let count = cache.len();
|
||||
@@ -105,7 +119,11 @@ impl RoutineEngine {
|
||||
let cache = self.event_cache.read().await;
|
||||
let mut fired = 0;
|
||||
|
||||
for (_, routine, re) in cache.iter() {
|
||||
for matcher in cache.iter() {
|
||||
let (routine, re) = match matcher {
|
||||
EventMatcher::Message { routine, regex } => (routine, regex),
|
||||
EventMatcher::System { .. } => continue,
|
||||
};
|
||||
// Channel filter
|
||||
if let Trigger::Event {
|
||||
channel: Some(ch), ..
|
||||
@@ -146,6 +164,82 @@ impl RoutineEngine {
|
||||
fired
|
||||
}
|
||||
|
||||
/// Emit a structured event to system-event routines.
|
||||
///
|
||||
/// Returns the number of routines that were fired.
|
||||
pub async fn emit_system_event(
|
||||
&self,
|
||||
source: &str,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
user_id: Option<&str>,
|
||||
) -> usize {
|
||||
let cache = self.event_cache.read().await;
|
||||
let mut fired = 0;
|
||||
|
||||
for matcher in cache.iter() {
|
||||
let routine = match matcher {
|
||||
EventMatcher::System { routine } => routine,
|
||||
EventMatcher::Message { .. } => continue,
|
||||
};
|
||||
|
||||
let Trigger::SystemEvent {
|
||||
source: expected_source,
|
||||
event_type: expected_event,
|
||||
filters,
|
||||
} = &routine.trigger
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if expected_source != source || expected_event != event_type {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(uid) = user_id
|
||||
&& routine.user_id != uid
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut matched = true;
|
||||
for (key, expected) in filters {
|
||||
let Some(actual) = payload.get(key).and_then(json_value_as_string) else {
|
||||
matched = false;
|
||||
break;
|
||||
};
|
||||
if actual != *expected {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.check_cooldown(routine) {
|
||||
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.check_concurrent(routine).await {
|
||||
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
|
||||
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
|
||||
let detail = truncate(&format!("{source}:{event_type}"), 200);
|
||||
self.spawn_fire(routine.clone(), "system_event", Some(detail));
|
||||
fired += 1;
|
||||
}
|
||||
|
||||
fired
|
||||
}
|
||||
|
||||
/// Check all due cron routines and fire them. Called by the cron ticker.
|
||||
pub async fn check_cron_triggers(&self) {
|
||||
let routines = match self.store.list_due_cron_routines().await {
|
||||
@@ -725,6 +819,15 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_as_string(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||
|
||||
@@ -273,6 +273,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
||||
let ch = channel.as_deref().unwrap_or("any");
|
||||
("event".to_string(), format!("on {} /{}/", ch, pattern))
|
||||
}
|
||||
crate::agent::routine::Trigger::SystemEvent {
|
||||
source, event_type, ..
|
||||
} => (
|
||||
"system_event".to_string(),
|
||||
format!("event: {}.{}", source, event_type),
|
||||
),
|
||||
crate::agent::routine::Trigger::Webhook { path, .. } => {
|
||||
let p = path.as_deref().unwrap_or("/");
|
||||
("webhook".to_string(), format!("webhook: {}", p))
|
||||
|
||||
+532
-1
@@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade},
|
||||
http::{StatusCode, header},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
middleware,
|
||||
response::{
|
||||
IntoResponse,
|
||||
@@ -200,6 +200,7 @@ pub async fn start_server(
|
||||
// Public routes (no auth)
|
||||
let public = Router::new()
|
||||
.route("/api/health", get(health_handler))
|
||||
.route("/api/webhooks/github", post(github_webhook_handler))
|
||||
.route("/oauth/callback", get(oauth_callback_handler));
|
||||
|
||||
// Protected routes (require auth)
|
||||
@@ -432,6 +433,335 @@ async fn health_handler() -> Json<HealthResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct GithubWebhookResponse {
|
||||
status: &'static str,
|
||||
source: &'static str,
|
||||
event_type: String,
|
||||
fired_routines: usize,
|
||||
}
|
||||
|
||||
/// PUBLIC webhook ingress for GitHub events.
|
||||
///
|
||||
/// Expects:
|
||||
/// - `X-GitHub-Event` header
|
||||
/// - JSON body payload
|
||||
/// - Optional `X-Hub-Signature-256` HMAC when secret is configured
|
||||
///
|
||||
/// Secret lookup order:
|
||||
/// 1. `GITHUB_WEBHOOK_SECRET` env var
|
||||
/// 2. Gateway setting `github.webhook_secret`
|
||||
async fn github_webhook_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<(StatusCode, Json<GithubWebhookResponse>), (StatusCode, String)> {
|
||||
let event = headers
|
||||
.get("x-github-event")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing X-GitHub-Event header".to_string(),
|
||||
))?;
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid JSON payload: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(secret) = github_webhook_secret(&state).await {
|
||||
let sig = headers
|
||||
.get("x-hub-signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Missing X-Hub-Signature-256 header".to_string(),
|
||||
))?;
|
||||
|
||||
if !verify_github_signature(&secret, &body, sig) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid signature".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let engine = {
|
||||
let guard = state.routine_engine.read().await;
|
||||
guard.as_ref().cloned().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Routine engine not available".to_string(),
|
||||
))?
|
||||
};
|
||||
|
||||
let event_type = github_event_type(event, &payload);
|
||||
let enriched_payload = github_enriched_payload(event, &headers, &payload, &event_type);
|
||||
let fired = engine
|
||||
.emit_system_event(
|
||||
"github",
|
||||
&event_type,
|
||||
&enriched_payload,
|
||||
Some(&state.user_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(GithubWebhookResponse {
|
||||
status: "accepted",
|
||||
source: "github",
|
||||
event_type,
|
||||
fired_routines: fired,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn github_webhook_secret(state: &GatewayState) -> Option<String> {
|
||||
if let Ok(secret) = std::env::var("GITHUB_WEBHOOK_SECRET")
|
||||
&& !secret.trim().is_empty()
|
||||
{
|
||||
return Some(secret);
|
||||
}
|
||||
|
||||
let store = state.store.as_ref()?;
|
||||
let value = store
|
||||
.get_setting(&state.user_id, "github.webhook_secret")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
value.as_str().map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn verify_github_signature(secret: &str, payload: &[u8], signature_header: &str) -> bool {
|
||||
use hmac::Mac;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
let Some(provided) = signature_header.strip_prefix("sha256=") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut mac = match hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(payload);
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
expected
|
||||
.as_bytes()
|
||||
.ct_eq(provided.to_ascii_lowercase().as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
fn github_event_type(event: &str, payload: &serde_json::Value) -> String {
|
||||
let base = match event {
|
||||
"issues" => "issue",
|
||||
"pull_request" => "pr",
|
||||
"issue_comment" => {
|
||||
if payload.pointer("/issue/pull_request").is_some() {
|
||||
"pr.comment"
|
||||
} else {
|
||||
"issue.comment"
|
||||
}
|
||||
}
|
||||
"pull_request_review" => "pr.review",
|
||||
"pull_request_review_comment" => "pr.review_comment",
|
||||
"pull_request_review_thread" => "pr.review_thread",
|
||||
"check_suite" => "ci.check_suite",
|
||||
"check_run" => "ci.check_run",
|
||||
"status" => "ci.status",
|
||||
other => other,
|
||||
};
|
||||
|
||||
if let Some(action) = payload.get("action").and_then(|v| v.as_str())
|
||||
&& !action.is_empty()
|
||||
{
|
||||
return format!("{base}.{action}");
|
||||
}
|
||||
|
||||
base.to_string()
|
||||
}
|
||||
|
||||
fn github_enriched_payload(
|
||||
raw_event: &str,
|
||||
headers: &HeaderMap,
|
||||
payload: &serde_json::Value,
|
||||
event_type: &str,
|
||||
) -> serde_json::Value {
|
||||
fn put_if_missing(
|
||||
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
val: Option<serde_json::Value>,
|
||||
) {
|
||||
if !obj.contains_key(key)
|
||||
&& let Some(v) = val
|
||||
{
|
||||
obj.insert(key.to_string(), v);
|
||||
}
|
||||
}
|
||||
|
||||
fn put_string_normalized(
|
||||
obj: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
val: Option<String>,
|
||||
) {
|
||||
let should_set = match obj.get(key) {
|
||||
None => true,
|
||||
Some(existing) => !existing.is_string(),
|
||||
};
|
||||
if should_set && let Some(v) = val {
|
||||
obj.insert(key.to_string(), serde_json::Value::String(v));
|
||||
}
|
||||
}
|
||||
|
||||
let mut obj = payload
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(serde_json::Map::new);
|
||||
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"event",
|
||||
Some(serde_json::Value::String(raw_event.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"event_type",
|
||||
Some(serde_json::Value::String(event_type.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"delivery_id",
|
||||
headers
|
||||
.get("x-github-delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"action",
|
||||
payload
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_string_normalized(
|
||||
&mut obj,
|
||||
"repository",
|
||||
payload
|
||||
.pointer("/repository/full_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"repository_owner",
|
||||
payload
|
||||
.pointer("/repository/owner/login")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_string_normalized(
|
||||
&mut obj,
|
||||
"sender",
|
||||
payload
|
||||
.pointer("/sender/login")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"issue_number",
|
||||
payload.pointer("/issue/number").cloned(),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"pr_number",
|
||||
payload.pointer("/pull_request/number").cloned(),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"comment_author",
|
||||
payload
|
||||
.pointer("/comment/user/login")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"comment_body",
|
||||
payload
|
||||
.pointer("/comment/body")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"review_state",
|
||||
payload
|
||||
.pointer("/review/state")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"pr_state",
|
||||
payload
|
||||
.pointer("/pull_request/state")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"pr_merged",
|
||||
payload.pointer("/pull_request/merged").cloned(),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"pr_draft",
|
||||
payload.pointer("/pull_request/draft").cloned(),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"base_branch",
|
||||
payload
|
||||
.pointer("/pull_request/base/ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"head_branch",
|
||||
payload
|
||||
.pointer("/pull_request/head/ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"ci_status",
|
||||
payload
|
||||
.pointer("/check_run/status")
|
||||
.or_else(|| payload.pointer("/check_suite/status"))
|
||||
.or_else(|| payload.pointer("/status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
put_if_missing(
|
||||
&mut obj,
|
||||
"ci_conclusion",
|
||||
payload
|
||||
.pointer("/check_run/conclusion")
|
||||
.or_else(|| payload.pointer("/check_suite/conclusion"))
|
||||
.or_else(|| payload.pointer("/state"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string())),
|
||||
);
|
||||
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
/// Return an OAuth error landing page response.
|
||||
fn oauth_error_page(label: &str) -> axum::response::Response {
|
||||
let html = crate::cli::oauth_defaults::landing_html(label, false);
|
||||
@@ -2124,6 +2454,12 @@ fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
|
||||
let ch = channel.as_deref().unwrap_or("any");
|
||||
("event".to_string(), format!("on {} /{}/", ch, pattern))
|
||||
}
|
||||
crate::agent::routine::Trigger::SystemEvent {
|
||||
source, event_type, ..
|
||||
} => (
|
||||
"system_event".to_string(),
|
||||
format!("event: {}.{}", source, event_type),
|
||||
),
|
||||
crate::agent::routine::Trigger::Webhook { path, .. } => {
|
||||
let p = path.as_deref().unwrap_or("/");
|
||||
("webhook".to_string(), format!("webhook: {}", p))
|
||||
@@ -2474,6 +2810,12 @@ mod tests {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn test_github_webhook_router(state: Arc<GatewayState>) -> Router {
|
||||
Router::new()
|
||||
.route("/api/webhooks/github", post(github_webhook_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_missing_params() {
|
||||
use axum::body::Body;
|
||||
@@ -2779,4 +3121,193 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_event_type_normalization() {
|
||||
assert_eq!(
|
||||
github_event_type("issues", &serde_json::json!({"action": "opened"})),
|
||||
"issue.opened"
|
||||
);
|
||||
assert_eq!(
|
||||
github_event_type(
|
||||
"pull_request",
|
||||
&serde_json::json!({"action": "synchronize"})
|
||||
),
|
||||
"pr.synchronize"
|
||||
);
|
||||
assert_eq!(
|
||||
github_event_type("push", &serde_json::json!({})),
|
||||
"push".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
github_event_type(
|
||||
"issue_comment",
|
||||
&serde_json::json!({
|
||||
"action": "created",
|
||||
"issue": { "pull_request": { "url": "https://api.github.com/repos/org/repo/pulls/1" } }
|
||||
})
|
||||
),
|
||||
"pr.comment.created"
|
||||
);
|
||||
assert_eq!(
|
||||
github_event_type("check_run", &serde_json::json!({"action": "completed"})),
|
||||
"ci.check_run.completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_github_signature_valid_and_invalid() {
|
||||
use hmac::Mac;
|
||||
|
||||
let secret = "test-secret";
|
||||
let payload = br#"{"action":"opened"}"#;
|
||||
|
||||
let mut mac =
|
||||
hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(payload);
|
||||
let digest = hex::encode(mac.finalize().into_bytes());
|
||||
let sig = format!("sha256={digest}");
|
||||
|
||||
assert!(verify_github_signature(secret, payload, &sig));
|
||||
assert!(!verify_github_signature(secret, payload, "sha256=deadbeef"));
|
||||
assert!(!verify_github_signature(secret, payload, "invalid-format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_webhook_missing_event_header_rejected() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = test_gateway_state(None);
|
||||
let app = test_github_webhook_router(state);
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/webhooks/github")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"action":"opened"}"#))
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_webhook_without_engine_returns_service_unavailable() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = test_gateway_state(None);
|
||||
let app = test_github_webhook_router(state);
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/webhooks/github")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-github-event", "issues")
|
||||
.body(Body::from(r#"{"action":"opened"}"#))
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_github_webhook_accepts_without_secret_when_engine_present() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = test_gateway_state(None);
|
||||
|
||||
let (db, _tmp) = crate::testing::test_db().await;
|
||||
let ws = Arc::new(crate::workspace::Workspace::new_with_db("test", db.clone()));
|
||||
let llm = Arc::new(crate::testing::StubLlm::new("ok"));
|
||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(8);
|
||||
let engine = Arc::new(crate::agent::routine_engine::RoutineEngine::new(
|
||||
crate::config::RoutineConfig::default(),
|
||||
db,
|
||||
llm,
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
));
|
||||
*state.routine_engine.write().await = Some(engine);
|
||||
|
||||
let app = test_github_webhook_router(state);
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/webhooks/github")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-github-event", "issues")
|
||||
.body(Body::from(r#"{"action":"opened"}"#))
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(
|
||||
json.get("event_type").and_then(|v| v.as_str()),
|
||||
Some("issue.opened")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_enriched_payload_extracts_common_fields() {
|
||||
let headers = HeaderMap::new();
|
||||
let payload = serde_json::json!({
|
||||
"action": "created",
|
||||
"repository": {
|
||||
"full_name": "nearai/ironclaw",
|
||||
"owner": { "login": "nearai" }
|
||||
},
|
||||
"sender": { "login": "maintainer1" },
|
||||
"issue": { "number": 77 },
|
||||
"comment": {
|
||||
"body": "Please update the implementation plan",
|
||||
"user": { "login": "maintainer1" }
|
||||
}
|
||||
});
|
||||
|
||||
let enriched =
|
||||
github_enriched_payload("issue_comment", &headers, &payload, "issue.comment.created");
|
||||
assert_eq!(
|
||||
enriched.get("repository").and_then(|v| v.as_str()),
|
||||
Some("nearai/ironclaw")
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("repository_owner").and_then(|v| v.as_str()),
|
||||
Some("nearai")
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("sender").and_then(|v| v.as_str()),
|
||||
Some("maintainer1")
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("issue_number").and_then(|v| v.as_i64()),
|
||||
Some(77)
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("comment_author").and_then(|v| v.as_str()),
|
||||
Some("maintainer1")
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("comment_body").and_then(|v| v.as_str()),
|
||||
Some("Please update the implementation plan")
|
||||
);
|
||||
assert_eq!(
|
||||
enriched.get("event_type").and_then(|v| v.as_str()),
|
||||
Some("issue.comment.created")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
&format!(
|
||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'",
|
||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')",
|
||||
ROUTINE_COLUMNS
|
||||
),
|
||||
(),
|
||||
|
||||
@@ -1081,7 +1081,7 @@ impl Store {
|
||||
let conn = self.conn().await?;
|
||||
let rows = conn
|
||||
.query(
|
||||
"SELECT * FROM routines WHERE enabled AND trigger_type = 'event'",
|
||||
"SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -32,8 +32,8 @@ pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTo
|
||||
pub use message::MessageTool;
|
||||
pub use restart::RestartTool;
|
||||
pub use routine::{
|
||||
RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, RoutineListTool,
|
||||
RoutineUpdateTool,
|
||||
EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool,
|
||||
RoutineListTool, RoutineUpdateTool,
|
||||
};
|
||||
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
|
||||
pub use shell::ShellTool;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! LLM-facing tools for managing routines.
|
||||
//!
|
||||
//! Six tools let the agent manage routines conversationally:
|
||||
//! Seven tools let the agent manage routines conversationally:
|
||||
//! - `routine_create` - Create a new routine
|
||||
//! - `routine_list` - List all routines with status
|
||||
//! - `routine_update` - Modify or toggle a routine
|
||||
//! - `routine_delete` - Remove a routine
|
||||
//! - `routine_fire` - Manually trigger a routine
|
||||
//! - `routine_history` - View past runs
|
||||
//! - `event_emit` - Emit a structured event to event-driven routines
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -62,7 +63,7 @@ impl Tool for RoutineCreateTool {
|
||||
},
|
||||
"trigger_type": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "event", "webhook", "manual"],
|
||||
"enum": ["cron", "event", "system_event", "webhook", "manual"],
|
||||
"description": "When the routine fires"
|
||||
},
|
||||
"schedule": {
|
||||
@@ -77,6 +78,19 @@ impl Tool for RoutineCreateTool {
|
||||
"type": "string",
|
||||
"description": "Optional channel filter for event trigger (e.g. 'telegram')"
|
||||
},
|
||||
"event_source": {
|
||||
"type": "string",
|
||||
"description": "Event source for system_event triggers (e.g. 'github')"
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"description": "Event type for system_event triggers (e.g. 'issue.opened')"
|
||||
},
|
||||
"event_filters": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Optional exact-match filters against payload fields for system_event triggers"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The prompt/instructions for the routine"
|
||||
@@ -172,6 +186,38 @@ impl Tool for RoutineCreateTool {
|
||||
pattern: pattern.to_string(),
|
||||
}
|
||||
}
|
||||
"system_event" => {
|
||||
let source = params
|
||||
.get("event_source")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters(
|
||||
"system_event trigger requires 'event_source'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let event_type = params
|
||||
.get("event_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters(
|
||||
"system_event trigger requires 'event_type'".to_string(),
|
||||
)
|
||||
})?;
|
||||
let filters = params
|
||||
.get("event_filters")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string())))
|
||||
.collect::<std::collections::HashMap<String, String>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Trigger::SystemEvent {
|
||||
source: source.to_string(),
|
||||
event_type: event_type.to_string(),
|
||||
filters,
|
||||
}
|
||||
}
|
||||
"webhook" => Trigger::Webhook {
|
||||
path: None,
|
||||
secret: None,
|
||||
@@ -274,7 +320,10 @@ impl Tool for RoutineCreateTool {
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?;
|
||||
|
||||
// Refresh event cache if this is an event trigger
|
||||
if routine.trigger.type_tag() == "event" {
|
||||
if matches!(
|
||||
routine.trigger,
|
||||
Trigger::Event { .. } | Trigger::SystemEvent { .. }
|
||||
) {
|
||||
self.engine.refresh_event_cache().await;
|
||||
}
|
||||
|
||||
@@ -648,6 +697,96 @@ pub struct RoutineHistoryTool {
|
||||
store: Arc<dyn Database>,
|
||||
}
|
||||
|
||||
// ==================== event_emit ====================
|
||||
|
||||
pub struct EventEmitTool {
|
||||
engine: Arc<RoutineEngine>,
|
||||
}
|
||||
|
||||
impl EventEmitTool {
|
||||
pub fn new(engine: Arc<RoutineEngine>) -> Self {
|
||||
Self { engine }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EventEmitTool {
|
||||
fn name(&self) -> &str {
|
||||
"event_emit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Emit a structured event to event-driven routines. \
|
||||
Use this to trigger routines from tool workflows without waiting for cron."
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Never
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Event source (e.g. 'github', 'workflow', 'tool')"
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"description": "Event type (e.g. 'issue.opened', 'pr.ready')"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Structured event payload"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "Optional target user id; defaults to current user"
|
||||
}
|
||||
},
|
||||
"required": ["source", "event_type"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let source = require_str(¶ms, "source")?;
|
||||
let event_type = require_str(¶ms, "event_type")?;
|
||||
let payload = params
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let user_id = params
|
||||
.get("user_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&ctx.user_id);
|
||||
|
||||
let fired = self
|
||||
.engine
|
||||
.emit_system_event(source, event_type, &payload, Some(user_id))
|
||||
.await;
|
||||
|
||||
let result = serde_json::json!({
|
||||
"source": source,
|
||||
"event_type": event_type,
|
||||
"target_user_id": user_id,
|
||||
"fired_routines": fired,
|
||||
});
|
||||
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutineHistoryTool {
|
||||
pub fn new(store: Arc<dyn Database>) -> Self {
|
||||
Self { store }
|
||||
|
||||
@@ -65,6 +65,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
"routine_delete",
|
||||
"routine_fire",
|
||||
"routine_history",
|
||||
"event_emit",
|
||||
"skill_list",
|
||||
"skill_search",
|
||||
"skill_install",
|
||||
@@ -425,8 +426,8 @@ impl ToolRegistry {
|
||||
engine: Arc<crate::agent::routine_engine::RoutineEngine>,
|
||||
) {
|
||||
use crate::tools::builtin::{
|
||||
RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool,
|
||||
RoutineListTool, RoutineUpdateTool,
|
||||
EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool,
|
||||
RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||
};
|
||||
self.register_sync(Arc::new(RoutineCreateTool::new(
|
||||
Arc::clone(&store),
|
||||
@@ -446,7 +447,8 @@ impl ToolRegistry {
|
||||
Arc::clone(&engine),
|
||||
)));
|
||||
self.register_sync(Arc::new(RoutineHistoryTool::new(store)));
|
||||
tracing::info!("Registered 6 routine management tools");
|
||||
self.register_sync(Arc::new(EventEmitTool::new(engine)));
|
||||
tracing::info!("Registered 7 routine management tools");
|
||||
}
|
||||
|
||||
/// Register message tool for sending messages to channels.
|
||||
|
||||
@@ -565,12 +565,19 @@ mod tests {
|
||||
"description": { "type": "string", "description": "What it does" },
|
||||
"trigger_type": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "event", "webhook", "manual"],
|
||||
"enum": ["cron", "event", "system_event", "webhook", "manual"],
|
||||
"description": "When the routine fires"
|
||||
},
|
||||
"schedule": { "type": "string", "description": "Cron expression" },
|
||||
"event_pattern": { "type": "string", "description": "Regex pattern" },
|
||||
"event_channel": { "type": "string", "description": "Channel filter" },
|
||||
"event_source": { "type": "string", "description": "System event source" },
|
||||
"event_type": { "type": "string", "description": "System event type" },
|
||||
"event_filters": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Exact-match payload filters"
|
||||
},
|
||||
"prompt": { "type": "string", "description": "Instructions" },
|
||||
"context_paths": {
|
||||
"type": "array",
|
||||
@@ -647,6 +654,19 @@ mod tests {
|
||||
"required": ["name"]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"event_emit",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": { "type": "string", "description": "Event source" },
|
||||
"event_type": { "type": "string", "description": "Event type" },
|
||||
"payload": { "type": "object", "description": "Event payload" },
|
||||
"user_id": { "type": "string", "description": "Optional target user id" }
|
||||
},
|
||||
"required": ["source", "event_type"]
|
||||
}),
|
||||
),
|
||||
// Job tools with complex deps
|
||||
(
|
||||
"job_events",
|
||||
|
||||
@@ -27,6 +27,8 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.with_skills()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -60,6 +62,8 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.with_skills()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -97,6 +101,8 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.with_skills()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -197,7 +203,107 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 6: job_create_status
|
||||
// Test 6: routine_system_event_emit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_system_event_emit() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_system_event_emit.json"
|
||||
))
|
||||
.expect("failed to load routine_system_event_emit.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a system-event routine and emit an event")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "event_emit" && *ok),
|
||||
"event_emit should succeed: {completed:?}"
|
||||
);
|
||||
|
||||
let results = rig.tool_results();
|
||||
let emit_result = results
|
||||
.iter()
|
||||
.find(|(n, _)| n == "event_emit")
|
||||
.expect("event_emit result missing");
|
||||
assert!(
|
||||
emit_result.1.contains("fired_routines"),
|
||||
"event_emit should report fired routine count: {:?}",
|
||||
emit_result.1
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 7: skill_install_routine_webhook_sim
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn skill_install_routine_webhook_sim() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json"
|
||||
))
|
||||
.expect("failed to load skill_install_routine_webhook_sim.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Install the workflow skill template and simulate a webhook routine run")
|
||||
.await;
|
||||
// `skill_install` is approval-gated in the interactive loop.
|
||||
// Approve once so the trace can proceed through the remaining steps.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
rig.send_message("always").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, _)| n == "skill_install"),
|
||||
"skill_install should be called: {completed:?}"
|
||||
);
|
||||
for tool in &["routine_create", "event_emit", "routine_history"] {
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == tool && *ok),
|
||||
"{tool} should succeed: {completed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let results = rig.tool_results();
|
||||
let emit_result = results
|
||||
.iter()
|
||||
.find(|(n, _)| n == "event_emit")
|
||||
.expect("event_emit result missing");
|
||||
assert!(
|
||||
emit_result.1.contains("fired_routines"),
|
||||
"event_emit should include fired_routines: {:?}",
|
||||
emit_result.1
|
||||
);
|
||||
|
||||
let _history_result = results
|
||||
.iter()
|
||||
.find(|(n, _)| n == "routine_history")
|
||||
.expect("routine_history result missing");
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8: job_create_status
|
||||
// -----------------------------------------------------------------------
|
||||
// Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from
|
||||
// create_job's result into job_status's arguments.
|
||||
@@ -256,7 +362,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 7: job_list_cancel
|
||||
// Test 9: job_list_cancel
|
||||
// -----------------------------------------------------------------------
|
||||
// Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from
|
||||
// create_job into cancel_job.
|
||||
|
||||
@@ -234,6 +234,105 @@ mod tests {
|
||||
// Test 3: routine_cooldown
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_event_trigger_matches_and_filters() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
let trace = LlmTrace::single_turn(
|
||||
"test-system-event-match",
|
||||
"event",
|
||||
vec![TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "System event handled".to_string(),
|
||||
input_tokens: 40,
|
||||
output_tokens: 8,
|
||||
},
|
||||
expected_tool_results: vec![],
|
||||
}],
|
||||
);
|
||||
let llm = Arc::new(TraceLlm::from_trace(trace));
|
||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||
|
||||
let engine = Arc::new(RoutineEngine::new(
|
||||
RoutineConfig::default(),
|
||||
db.clone(),
|
||||
llm,
|
||||
ws,
|
||||
notify_tx,
|
||||
None,
|
||||
));
|
||||
|
||||
let mut filters = std::collections::HashMap::new();
|
||||
filters.insert("repository".to_string(), "nearai/ironclaw".to_string());
|
||||
|
||||
let routine = make_routine(
|
||||
"github-issue-opened",
|
||||
Trigger::SystemEvent {
|
||||
source: "github".to_string(),
|
||||
event_type: "issue.opened".to_string(),
|
||||
filters,
|
||||
},
|
||||
"Summarize the issue and propose an implementation plan.",
|
||||
);
|
||||
db.create_routine(&routine).await.expect("create_routine");
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// Matching event should fire.
|
||||
let fired = engine
|
||||
.emit_system_event(
|
||||
"github",
|
||||
"issue.opened",
|
||||
&serde_json::json!({
|
||||
"repository": "nearai/ironclaw",
|
||||
"issue_number": 42
|
||||
}),
|
||||
Some("default"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(fired, 1, "Expected one routine to fire for matching event");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let runs = db
|
||||
.list_routine_runs(routine.id, 10)
|
||||
.await
|
||||
.expect("list runs");
|
||||
assert!(
|
||||
!runs.is_empty(),
|
||||
"Expected run history after matching event"
|
||||
);
|
||||
|
||||
// Wrong event type should not fire.
|
||||
let fired_wrong_type = engine
|
||||
.emit_system_event(
|
||||
"github",
|
||||
"issue.closed",
|
||||
&serde_json::json!({"repository": "nearai/ironclaw"}),
|
||||
Some("default"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
fired_wrong_type, 0,
|
||||
"Expected no routine for wrong event type"
|
||||
);
|
||||
|
||||
// Wrong filter value should not fire.
|
||||
let fired_wrong_filter = engine
|
||||
.emit_system_event(
|
||||
"github",
|
||||
"issue.opened",
|
||||
&serde_json::json!({"repository": "other/repo"}),
|
||||
Some("default"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
fired_wrong_filter, 0,
|
||||
"Expected no routine for filter mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_cooldown() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"model_name": "test-routine-system-event-emit",
|
||||
"expects": {
|
||||
"tools_used": ["event_emit"],
|
||||
"all_tools_succeeded": true,
|
||||
"tool_results_contain": {
|
||||
"event_emit": "fired_routines"
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_ee_1",
|
||||
"name": "event_emit",
|
||||
"arguments": {
|
||||
"source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"payload": {
|
||||
"repository": "nearai/ironclaw",
|
||||
"issue_number": 123,
|
||||
"title": "Support event-driven project workflow"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 90,
|
||||
"output_tokens": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Emitted a GitHub system event successfully.",
|
||||
"input_tokens": 140,
|
||||
"output_tokens": 14
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"model_name": "test-skill-install-routine-webhook-sim",
|
||||
"expects": {
|
||||
"tools_used": ["skill_install", "routine_create", "event_emit", "routine_history"],
|
||||
"tool_results_contain": {
|
||||
"event_emit": "fired_routines"
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_skill_install_1",
|
||||
"name": "skill_install",
|
||||
"arguments": {
|
||||
"name": "wf-orchestrator-trace-install-1",
|
||||
"content": "---\nname: wf-orchestrator-trace-install-1\ndescription: Minimal workflow skill for trace install validation\nactivation:\n keywords: [\"workflow\", \"orchestrator\"]\n---\n\nYou are a minimal workflow skill used for trace install validation.\n"
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 120,
|
||||
"output_tokens": 32
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_routine_create_1",
|
||||
"name": "routine_create",
|
||||
"arguments": {
|
||||
"name": "wf-webhook-sim-trace",
|
||||
"description": "Trace routine to simulate webhook event flow",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"event_filters": {
|
||||
"repository": "nearai/ironclaw"
|
||||
},
|
||||
"action_type": "full_job",
|
||||
"prompt": "When issue webhook event arrives, start implementation loop and create branch/PR updates."
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 170,
|
||||
"output_tokens": 36
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_event_emit_1",
|
||||
"name": "event_emit",
|
||||
"arguments": {
|
||||
"source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"payload": {
|
||||
"repository": "nearai/ironclaw",
|
||||
"issue_number": 4242,
|
||||
"sender": "trace-bot"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 210,
|
||||
"output_tokens": 28
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_routine_history_1",
|
||||
"name": "routine_history",
|
||||
"arguments": {
|
||||
"name": "wf-webhook-sim-trace",
|
||||
"limit": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 240,
|
||||
"output_tokens": 22
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Installed the skill template, created a system-event routine, emitted a webhook-equivalent event, and verified the routine run history.",
|
||||
"input_tokens": 280,
|
||||
"output_tokens": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Live-ish gateway workflow integration using an in-process mock OpenAI server.
|
||||
//! This exercises the same path as manual validation:
|
||||
//! - chat send through gateway
|
||||
//! - routine creation via tool call
|
||||
//! - system-event emission via tool call
|
||||
//! - webhook ingestion via gateway
|
||||
//! - status/runs checks via routines API
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
|
||||
use crate::support::mock_openai_server::{
|
||||
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_workflow_harness_chat_and_webhook() {
|
||||
let mock = MockOpenAiServerBuilder::new()
|
||||
.with_rule(MockOpenAiRule::on_user_contains(
|
||||
"create workflow routine",
|
||||
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
|
||||
"call_create_1",
|
||||
"routine_create",
|
||||
serde_json::json!({
|
||||
"name": "wf-ci-webhook-demo",
|
||||
"description": "CI webhook workflow demo",
|
||||
"trigger_type": "system_event",
|
||||
"event_source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"event_filters": {"repository": "nearai/ironclaw"},
|
||||
"action_type": "lightweight",
|
||||
"prompt": "Summarize webhook and report issue number"
|
||||
}),
|
||||
)]),
|
||||
))
|
||||
.with_rule(MockOpenAiRule::on_user_contains(
|
||||
"emit webhook event",
|
||||
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
|
||||
"call_emit_1",
|
||||
"event_emit",
|
||||
serde_json::json!({
|
||||
"source": "github",
|
||||
"event_type": "issue.opened",
|
||||
"payload": {
|
||||
"repository": "nearai/ironclaw",
|
||||
"issue": {"number": 777, "title": "Infra test"}
|
||||
}
|
||||
}),
|
||||
)]),
|
||||
))
|
||||
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
|
||||
.start()
|
||||
.await;
|
||||
|
||||
let harness =
|
||||
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
|
||||
.await;
|
||||
|
||||
let thread_id = harness.create_thread().await;
|
||||
harness
|
||||
.send_chat(&thread_id, "create workflow routine")
|
||||
.await;
|
||||
harness
|
||||
.wait_for_turns(&thread_id, 1, Duration::from_secs(10))
|
||||
.await;
|
||||
|
||||
let mut routine = None;
|
||||
for _ in 0..30 {
|
||||
routine = harness.routine_by_name("wf-ci-webhook-demo").await;
|
||||
if routine.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let routine = if let Some(r) = routine {
|
||||
r
|
||||
} else {
|
||||
let history_dbg = harness.history(&thread_id).await;
|
||||
let started_dbg = harness.test_channel.tool_calls_started();
|
||||
let requests_dbg = mock.requests().await;
|
||||
panic!(
|
||||
"routine not created; tool_calls_started={started_dbg:?}; history={history_dbg}; mock_requests={requests_dbg:?}"
|
||||
);
|
||||
};
|
||||
let routine_id = routine["id"].as_str().expect("routine id missing");
|
||||
|
||||
harness.send_chat(&thread_id, "emit webhook event").await;
|
||||
|
||||
let history = harness
|
||||
.wait_for_turns(&thread_id, 2, Duration::from_secs(10))
|
||||
.await;
|
||||
let turns = history["turns"].as_array().expect("turns array missing");
|
||||
assert!(turns.len() >= 2, "expected at least 2 turns");
|
||||
|
||||
let runs_before = harness.routine_runs(routine_id).await;
|
||||
let before_count = runs_before["runs"]
|
||||
.as_array()
|
||||
.map(|a| a.len())
|
||||
.unwrap_or_default();
|
||||
|
||||
let hook = harness
|
||||
.github_webhook(
|
||||
"issues",
|
||||
serde_json::json!({
|
||||
"action": "opened",
|
||||
"repository": {"full_name": "nearai/ironclaw"},
|
||||
"issue": {"number": 778, "title": "Webhook endpoint test"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(hook["status"], "accepted");
|
||||
assert_eq!(hook["event_type"], "issue.opened");
|
||||
assert!(
|
||||
hook["fired_routines"].as_u64().unwrap_or(0) >= 1,
|
||||
"expected webhook to fire at least one routine"
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let runs_after = harness.routine_runs(routine_id).await;
|
||||
let after_count = runs_after["runs"]
|
||||
.as_array()
|
||||
.map(|a| a.len())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
after_count > before_count,
|
||||
"expected routine runs to increase after webhook; before={before_count}, after={after_count}"
|
||||
);
|
||||
|
||||
let requests = mock.requests().await;
|
||||
assert!(
|
||||
requests.len() >= 2,
|
||||
"expected mock LLM server to receive requests"
|
||||
);
|
||||
|
||||
harness.shutdown().await;
|
||||
mock.shutdown().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::SecretString;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use ironclaw::agent::routine_engine::RoutineEngine;
|
||||
use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager};
|
||||
use ironclaw::app::{AppBuilder, AppBuilderFlags};
|
||||
use ironclaw::channels::web::log_layer::LogBroadcaster;
|
||||
use ironclaw::channels::web::server::{GatewayState, RateLimiter, start_server};
|
||||
use ironclaw::channels::web::sse::SseManager;
|
||||
use ironclaw::channels::web::ws::WsConnectionTracker;
|
||||
use ironclaw::channels::{Channel, IncomingMessage, MessageStream};
|
||||
use ironclaw::config::{Config, RegistryProviderConfig, RoutineConfig};
|
||||
use ironclaw::context::ContextManager;
|
||||
use ironclaw::db::Database;
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
use ironclaw::error::ChannelError;
|
||||
use ironclaw::llm::registry::ProviderProtocol;
|
||||
use ironclaw::llm::{
|
||||
SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider,
|
||||
};
|
||||
|
||||
use crate::support::test_channel::TestChannel;
|
||||
|
||||
struct TestChannelHandle {
|
||||
inner: Arc<TestChannel>,
|
||||
}
|
||||
|
||||
impl TestChannelHandle {
|
||||
fn new(inner: Arc<TestChannel>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TestChannelHandle {
|
||||
fn name(&self) -> &str {
|
||||
"gateway"
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
self.inner.start().await
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
msg: &IncomingMessage,
|
||||
response: ironclaw::channels::OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.inner.respond(msg, response).await
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
status: ironclaw::channels::StatusUpdate,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.inner.send_status(status, metadata).await
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
response: ironclaw::channels::OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.inner.broadcast(user_id, response).await
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
self.inner.health_check().await
|
||||
}
|
||||
|
||||
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
|
||||
self.inner.conversation_context(metadata)
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
self.inner.shutdown().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GatewayWorkflowHarness {
|
||||
pub addr: SocketAddr,
|
||||
pub auth_token: String,
|
||||
pub client: reqwest::Client,
|
||||
pub user_id: String,
|
||||
pub test_channel: Arc<TestChannel>,
|
||||
pub db: Arc<dyn Database>,
|
||||
gateway_state: Arc<GatewayState>,
|
||||
agent_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
bridge_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
_temp_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl GatewayWorkflowHarness {
|
||||
pub async fn start_openai_compatible(base_url: &str, model: &str) -> Self {
|
||||
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("gateway_workflow_harness.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("failed to create test db");
|
||||
backend
|
||||
.run_migrations()
|
||||
.await
|
||||
.expect("failed to run migrations");
|
||||
let db: Arc<dyn Database> = Arc::new(backend);
|
||||
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let installed_skills_dir = temp_dir.path().join("installed_skills");
|
||||
let _ = std::fs::create_dir_all(&skills_dir);
|
||||
let _ = std::fs::create_dir_all(&installed_skills_dir);
|
||||
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
|
||||
config.agent.auto_approve_tools = true;
|
||||
config.routines.enabled = true;
|
||||
config.routines.max_concurrent_routines = 4;
|
||||
config.llm.backend = "openai_compatible".to_string();
|
||||
config.llm.provider = Some(RegistryProviderConfig {
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
provider_id: "openai_compatible".to_string(),
|
||||
api_key: Some(SecretString::from("dummy".to_string())),
|
||||
base_url: base_url.to_string(),
|
||||
model: model.to_string(),
|
||||
extra_headers: Vec::new(),
|
||||
oauth_token: None,
|
||||
});
|
||||
|
||||
let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default()));
|
||||
let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session))
|
||||
.expect("failed to create openai-compatible provider");
|
||||
|
||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||
let mut app_builder = AppBuilder::new(
|
||||
config,
|
||||
AppBuilderFlags::default(),
|
||||
None,
|
||||
Arc::clone(&llm_session),
|
||||
log_broadcaster,
|
||||
);
|
||||
app_builder.with_database(Arc::clone(&db));
|
||||
app_builder.with_llm(llm);
|
||||
|
||||
let mut components = app_builder
|
||||
.build_all()
|
||||
.await
|
||||
.expect("failed to build app components");
|
||||
|
||||
let ctx_mgr = Arc::new(ContextManager::new(
|
||||
components.config.agent.max_parallel_jobs,
|
||||
));
|
||||
components.tools.register_job_tools(
|
||||
ctx_mgr,
|
||||
None,
|
||||
None,
|
||||
components.db.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let routine_slot: Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>> =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
if let (Some(db_arc), Some(ws)) = (&components.db, &components.workspace) {
|
||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||
let engine = Arc::new(RoutineEngine::new(
|
||||
RoutineConfig::default(),
|
||||
Arc::clone(db_arc),
|
||||
components.llm.clone(),
|
||||
Arc::clone(ws),
|
||||
notify_tx,
|
||||
None,
|
||||
));
|
||||
components
|
||||
.tools
|
||||
.register_routine_tools(Arc::clone(db_arc), engine);
|
||||
}
|
||||
|
||||
let test_channel = Arc::new(TestChannel::new());
|
||||
let handle = TestChannelHandle::new(Arc::clone(&test_channel));
|
||||
let channel_manager = ironclaw::channels::ChannelManager::new();
|
||||
channel_manager.add(Box::new(handle)).await;
|
||||
let channels = Arc::new(channel_manager);
|
||||
|
||||
let user_id = "gateway-test-user".to_string();
|
||||
let (gw_tx, mut gw_rx) = mpsc::channel::<IncomingMessage>(256);
|
||||
let forward_channel = Arc::clone(&test_channel);
|
||||
let bridge_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = gw_rx.recv().await {
|
||||
forward_channel.send_incoming(msg).await;
|
||||
}
|
||||
});
|
||||
|
||||
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
let agent_session_manager = Arc::new(AgentSessionManager::new());
|
||||
|
||||
let gateway_state = Arc::new(GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(Some(gw_tx)),
|
||||
sse: SseManager::new(),
|
||||
workspace: components.workspace.clone(),
|
||||
session_manager: Some(Arc::clone(&agent_session_manager)),
|
||||
log_broadcaster: None,
|
||||
log_level_handle: None,
|
||||
extension_manager: components.extension_manager.clone(),
|
||||
tool_registry: Some(Arc::clone(&components.tools)),
|
||||
store: components.db.clone(),
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: Some(scheduler_slot.clone()),
|
||||
user_id: user_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
llm_provider: Some(Arc::clone(&components.llm)),
|
||||
skill_registry: components.skill_registry.clone(),
|
||||
skill_catalog: components.skill_catalog.clone(),
|
||||
chat_rate_limiter: RateLimiter::new(120, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: Some(Arc::clone(&components.cost_guard)),
|
||||
routine_engine: Arc::clone(&routine_slot),
|
||||
startup_time: Instant::now(),
|
||||
});
|
||||
|
||||
let mut agent = Agent::new(
|
||||
components.config.agent.clone(),
|
||||
AgentDeps {
|
||||
store: components.db,
|
||||
llm: components.llm,
|
||||
cheap_llm: components.cheap_llm,
|
||||
safety: components.safety,
|
||||
tools: components.tools,
|
||||
workspace: components.workspace,
|
||||
extension_manager: components.extension_manager,
|
||||
skill_registry: components.skill_registry,
|
||||
skill_catalog: components.skill_catalog,
|
||||
skills_config: components.config.skills.clone(),
|
||||
hooks: components.hooks,
|
||||
cost_guard: components.cost_guard,
|
||||
sse_tx: Some(gateway_state.sse.sender()),
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
},
|
||||
channels,
|
||||
None,
|
||||
None,
|
||||
Some(RoutineConfig {
|
||||
enabled: true,
|
||||
cron_check_interval_secs: 60,
|
||||
max_concurrent_routines: 4,
|
||||
default_cooldown_secs: 300,
|
||||
max_lightweight_tokens: 4096,
|
||||
}),
|
||||
Some(Arc::clone(&components.context_manager)),
|
||||
Some(Arc::clone(&agent_session_manager)),
|
||||
);
|
||||
agent.set_routine_engine_slot(Arc::clone(&routine_slot));
|
||||
*scheduler_slot.write().await = Some(agent.scheduler());
|
||||
|
||||
let agent_handle = tokio::spawn(async move {
|
||||
let _ = agent.run().await;
|
||||
});
|
||||
|
||||
if let Some(rx) = test_channel.take_ready_rx().await {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
|
||||
}
|
||||
|
||||
let auth_token = "gateway-test-token".to_string();
|
||||
let addr = start_server(
|
||||
"127.0.0.1:0".parse().expect("valid localhost addr"),
|
||||
Arc::clone(&gateway_state),
|
||||
auth_token.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("failed to start gateway server");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
Self {
|
||||
addr,
|
||||
auth_token,
|
||||
client,
|
||||
user_id,
|
||||
test_channel,
|
||||
db,
|
||||
gateway_state,
|
||||
agent_handle: Some(agent_handle),
|
||||
bridge_handle: Some(bridge_handle),
|
||||
_temp_dir: temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
pub async fn create_thread(&self) -> String {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/chat/thread/new", self.base_url()))
|
||||
.bearer_auth(&self.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("create thread request failed")
|
||||
.error_for_status()
|
||||
.expect("create thread non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid thread response");
|
||||
resp.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("thread id missing")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn send_chat(&self, thread_id: &str, content: &str) {
|
||||
let _ = self
|
||||
.client
|
||||
.post(format!("{}/api/chat/send", self.base_url()))
|
||||
.bearer_auth(&self.auth_token)
|
||||
.json(&serde_json::json!({"thread_id": thread_id, "content": content}))
|
||||
.send()
|
||||
.await
|
||||
.expect("chat send failed")
|
||||
.error_for_status()
|
||||
.expect("chat send non-2xx");
|
||||
}
|
||||
|
||||
pub async fn history(&self, thread_id: &str) -> serde_json::Value {
|
||||
self.client
|
||||
.get(format!(
|
||||
"{}/api/chat/history?thread_id={thread_id}",
|
||||
self.base_url()
|
||||
))
|
||||
.bearer_auth(&self.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("history request failed")
|
||||
.error_for_status()
|
||||
.expect("history non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid history response")
|
||||
}
|
||||
|
||||
pub async fn wait_for_turns(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
min_turns: usize,
|
||||
timeout: Duration,
|
||||
) -> serde_json::Value {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let history = self.history(thread_id).await;
|
||||
let turns = history
|
||||
.get("turns")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or_default();
|
||||
if turns >= min_turns {
|
||||
return history;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "timed out waiting for turns");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_routines(&self) -> serde_json::Value {
|
||||
self.client
|
||||
.get(format!("{}/api/routines", self.base_url()))
|
||||
.bearer_auth(&self.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("routines request failed")
|
||||
.error_for_status()
|
||||
.expect("routines non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid routines response")
|
||||
}
|
||||
|
||||
pub async fn routine_by_name(&self, name: &str) -> Option<serde_json::Value> {
|
||||
let routines = self.list_routines().await;
|
||||
routines
|
||||
.get("routines")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter()
|
||||
.find(|r| r.get("name").and_then(|v| v.as_str()) == Some(name))
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn routine_runs(&self, routine_id: &str) -> serde_json::Value {
|
||||
self.client
|
||||
.get(format!(
|
||||
"{}/api/routines/{routine_id}/runs",
|
||||
self.base_url()
|
||||
))
|
||||
.bearer_auth(&self.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("routine runs request failed")
|
||||
.error_for_status()
|
||||
.expect("routine runs non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid routine runs response")
|
||||
}
|
||||
|
||||
pub async fn github_webhook(
|
||||
&self,
|
||||
event: &str,
|
||||
payload: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
self.client
|
||||
.post(format!("{}/api/webhooks/github", self.base_url()))
|
||||
.header("x-github-event", event)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("webhook request failed")
|
||||
.error_for_status()
|
||||
.expect("webhook non-2xx")
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.expect("invalid webhook response")
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) {
|
||||
self.test_channel.signal_shutdown();
|
||||
|
||||
if let Some(tx) = self.gateway_state.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
if let Some(handle) = self.bridge_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
if let Some(handle) = self.agent_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GatewayWorkflowHarness {
|
||||
fn drop(&mut self) {
|
||||
self.test_channel.signal_shutdown();
|
||||
if let Some(handle) = self.bridge_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
if let Some(handle) = self.agent_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, oneshot};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MockOpenAiRule {
|
||||
contains: String,
|
||||
response: MockOpenAiResponse,
|
||||
}
|
||||
|
||||
impl MockOpenAiRule {
|
||||
pub fn on_user_contains(contains: impl Into<String>, response: MockOpenAiResponse) -> Self {
|
||||
Self {
|
||||
contains: contains.into(),
|
||||
response,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum MockOpenAiResponse {
|
||||
Text(String),
|
||||
ToolCalls(Vec<MockToolCall>),
|
||||
Raw(Value),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MockToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
impl MockToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockOpenAiServerBuilder {
|
||||
models: Vec<String>,
|
||||
rules: Vec<MockOpenAiRule>,
|
||||
default_response: Option<MockOpenAiResponse>,
|
||||
}
|
||||
|
||||
impl MockOpenAiServerBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
models: vec!["mock-model".to_string()],
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_models(mut self, models: Vec<String>) -> Self {
|
||||
self.models = models;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rule(mut self, rule: MockOpenAiRule) -> Self {
|
||||
self.rules.push(rule);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_default_response(mut self, response: MockOpenAiResponse) -> Self {
|
||||
self.default_response = Some(response);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn start(self) -> MockOpenAiServer {
|
||||
let state = Arc::new(MockOpenAiState {
|
||||
models: self.models,
|
||||
rules: self.rules,
|
||||
default_response: self
|
||||
.default_response
|
||||
.unwrap_or_else(|| MockOpenAiResponse::Text("OK".to_string())),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
response_counter: AtomicU64::new(1),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/models", get(models_handler))
|
||||
.route("/v1/chat/completions", post(chat_completions_handler))
|
||||
.with_state(Arc::clone(&state));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind mock openai server");
|
||||
let addr = listener.local_addr().expect("failed to read bound addr");
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
MockOpenAiServer {
|
||||
addr,
|
||||
state,
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
server_task: Some(handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockOpenAiServer {
|
||||
addr: SocketAddr,
|
||||
state: Arc<MockOpenAiState>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
server_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MockOpenAiServer {
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://{}", self.addr)
|
||||
}
|
||||
|
||||
pub fn openai_base_url(&self) -> String {
|
||||
format!("{}/v1", self.base_url())
|
||||
}
|
||||
|
||||
pub async fn requests(&self) -> Vec<Value> {
|
||||
self.state.requests.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = self.server_task.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockOpenAiServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = self.server_task.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockOpenAiState {
|
||||
models: Vec<String>,
|
||||
rules: Vec<MockOpenAiRule>,
|
||||
default_response: MockOpenAiResponse,
|
||||
requests: Mutex<Vec<Value>>,
|
||||
response_counter: AtomicU64,
|
||||
}
|
||||
|
||||
async fn models_handler(State(state): State<Arc<MockOpenAiState>>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": state
|
||||
.models
|
||||
.iter()
|
||||
.map(|id| json!({"id": id, "object": "model"}))
|
||||
.collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn chat_completions_handler(
|
||||
State(state): State<Arc<MockOpenAiState>>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Json<Value>, (StatusCode, String)> {
|
||||
state.requests.lock().await.push(body.clone());
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mock-model");
|
||||
let last_role = body
|
||||
.pointer("/messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|arr| arr.last())
|
||||
.and_then(|v| v.get("role"))
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
fn extract_text_content(msg: &Value) -> Option<String> {
|
||||
let content = msg.get("content")?;
|
||||
if let Some(s) = content.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(parts) = content.as_array() {
|
||||
let mut out = String::new();
|
||||
for part in parts {
|
||||
if part.get("type").and_then(|v| v.as_str()) == Some("text")
|
||||
&& let Some(text) = part.get("text").and_then(|v| v.as_str())
|
||||
{
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(text);
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
let latest_user = body
|
||||
.pointer("/messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().rev().find_map(|msg| {
|
||||
if msg.get("role").and_then(|r| r.as_str()) == Some("user") {
|
||||
extract_text_content(msg)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let selected = if last_role == "user" {
|
||||
let latest_user_lower = latest_user.to_ascii_lowercase();
|
||||
state
|
||||
.rules
|
||||
.iter()
|
||||
.find(|r| latest_user_lower.contains(&r.contains.to_ascii_lowercase()))
|
||||
.map(|r| r.response.clone())
|
||||
.unwrap_or_else(|| state.default_response.clone())
|
||||
} else {
|
||||
state.default_response.clone()
|
||||
};
|
||||
|
||||
let n = state.response_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let response = match selected {
|
||||
MockOpenAiResponse::Text(content) => json!({
|
||||
"id": format!("chatcmpl-mock-{n}"),
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
}),
|
||||
MockOpenAiResponse::ToolCalls(tool_calls) => {
|
||||
let calls = tool_calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
json!({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": tc.arguments.to_string()
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"id": format!("chatcmpl-mock-{n}"),
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": serde_json::Value::Null,
|
||||
"tool_calls": calls
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
})
|
||||
}
|
||||
MockOpenAiResponse::Raw(v) => v,
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod assertions;
|
||||
pub mod cleanup;
|
||||
pub mod gateway_workflow_harness;
|
||||
pub mod instrumented_llm;
|
||||
pub mod metrics;
|
||||
pub mod mock_openai_server;
|
||||
pub mod test_channel;
|
||||
pub mod test_rig;
|
||||
pub mod trace_llm;
|
||||
|
||||
@@ -379,6 +379,8 @@ pub struct TestRigBuilder {
|
||||
llm: Option<Arc<dyn LlmProvider>>,
|
||||
max_tool_iterations: usize,
|
||||
injection_check: bool,
|
||||
auto_approve_tools: Option<bool>,
|
||||
enable_skills: bool,
|
||||
enable_routines: bool,
|
||||
http_exchanges: Vec<HttpExchange>,
|
||||
extra_tools: Vec<Arc<dyn Tool>>,
|
||||
@@ -392,6 +394,8 @@ impl TestRigBuilder {
|
||||
llm: None,
|
||||
max_tool_iterations: 10,
|
||||
injection_check: false,
|
||||
auto_approve_tools: None,
|
||||
enable_skills: false,
|
||||
enable_routines: false,
|
||||
http_exchanges: Vec::new(),
|
||||
extra_tools: Vec::new(),
|
||||
@@ -432,6 +436,18 @@ impl TestRigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override agent-level automatic approval of `UnlessAutoApproved` tools.
|
||||
pub fn with_auto_approve_tools(mut self, enable: bool) -> Self {
|
||||
self.auto_approve_tools = Some(enable);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable skill discovery and registration for this test rig.
|
||||
pub fn with_skills(mut self) -> Self {
|
||||
self.enable_skills = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable the routines system so the scheduler is wired with a `RoutineEngine`,
|
||||
/// allowing routine jobs to actually execute. Routine tools are always registered
|
||||
/// but require the engine to dispatch jobs.
|
||||
@@ -466,6 +482,8 @@ impl TestRigBuilder {
|
||||
llm,
|
||||
max_tool_iterations,
|
||||
injection_check,
|
||||
auto_approve_tools,
|
||||
enable_skills,
|
||||
enable_routines,
|
||||
http_exchanges: explicit_http_exchanges,
|
||||
extra_tools,
|
||||
@@ -491,6 +509,10 @@ impl TestRigBuilder {
|
||||
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
|
||||
config.agent.max_tool_iterations = max_tool_iterations;
|
||||
config.safety.injection_check_enabled = injection_check;
|
||||
config.skills.enabled = enable_skills;
|
||||
if let Some(v) = auto_approve_tools {
|
||||
config.agent.auto_approve_tools = v;
|
||||
}
|
||||
|
||||
// 3. Create SessionManager + LogBroadcaster.
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
@@ -540,7 +562,7 @@ impl TestRigBuilder {
|
||||
);
|
||||
builder.with_database(Arc::clone(&db));
|
||||
builder.with_llm(llm);
|
||||
let components = builder
|
||||
let mut components = builder
|
||||
.build_all()
|
||||
.await
|
||||
.expect("AppBuilder::build_all() failed in test rig");
|
||||
@@ -583,6 +605,21 @@ impl TestRigBuilder {
|
||||
.register_routine_tools(Arc::clone(db_arc), engine);
|
||||
}
|
||||
|
||||
// Skills tools: ensure tests use temp skill dirs (sandbox-safe) even if
|
||||
// AppBuilder did not wire them for this environment.
|
||||
if enable_skills {
|
||||
let registry = Arc::new(std::sync::RwLock::new(
|
||||
ironclaw::skills::SkillRegistry::new(temp_dir.path().join("skills"))
|
||||
.with_installed_dir(temp_dir.path().join("installed_skills")),
|
||||
));
|
||||
let catalog = ironclaw::skills::catalog::shared_catalog();
|
||||
components
|
||||
.tools
|
||||
.register_skill_tools(Arc::clone(®istry), Arc::clone(&catalog));
|
||||
components.skill_registry = Some(registry);
|
||||
components.skill_catalog = Some(catalog);
|
||||
}
|
||||
|
||||
// Register any extra test-specific tools.
|
||||
for tool in extra_tools {
|
||||
components.tools.register(tool).await;
|
||||
|
||||
@@ -5,8 +5,8 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
## Features
|
||||
|
||||
- **Repository Info** - Get repo details, list user repos
|
||||
- **Issues** - List, create, and get issue details
|
||||
- **Pull Requests** - List PRs, get PR details, review files, create reviews
|
||||
- **Issues** - List/create/get issues, list/add issue comments
|
||||
- **Pull Requests** - List/create/get PRs, review files, create reviews, list/reply review comments, merge PRs
|
||||
- **File Content** - Read files from repos
|
||||
- **Workflows** - Trigger GitHub Actions, check run status
|
||||
|
||||
@@ -82,6 +82,32 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
}
|
||||
```
|
||||
|
||||
### Create Pull Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_pull_request",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"title": "feat: add event-driven routines",
|
||||
"head": "feat/event-routines",
|
||||
"base": "main",
|
||||
"body": "Implements system_event trigger + event_emit tool."
|
||||
}
|
||||
```
|
||||
|
||||
### Merge Pull Request
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "merge_pull_request",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"pr_number": 42,
|
||||
"merge_method": "squash"
|
||||
}
|
||||
```
|
||||
|
||||
### Get File Content
|
||||
|
||||
```json
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"path_prefix": "/",
|
||||
"methods": [
|
||||
"GET",
|
||||
"POST"
|
||||
"POST",
|
||||
"PUT"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -56,4 +57,4 @@
|
||||
"default_limit": 30,
|
||||
"max_limit": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,21 @@ enum GitHubAction {
|
||||
repo: String,
|
||||
issue_number: u32,
|
||||
},
|
||||
#[serde(rename = "list_issue_comments")]
|
||||
ListIssueComments {
|
||||
owner: String,
|
||||
repo: String,
|
||||
issue_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "create_issue_comment")]
|
||||
CreateIssueComment {
|
||||
owner: String,
|
||||
repo: String,
|
||||
issue_number: u32,
|
||||
body: String,
|
||||
},
|
||||
#[serde(rename = "list_pull_requests")]
|
||||
ListPullRequests {
|
||||
owner: String,
|
||||
@@ -101,6 +116,16 @@ enum GitHubAction {
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "create_pull_request")]
|
||||
CreatePullRequest {
|
||||
owner: String,
|
||||
repo: String,
|
||||
title: String,
|
||||
head: String,
|
||||
base: String,
|
||||
body: Option<String>,
|
||||
draft: Option<bool>,
|
||||
},
|
||||
#[serde(rename = "get_pull_request")]
|
||||
GetPullRequest {
|
||||
owner: String,
|
||||
@@ -121,6 +146,44 @@ enum GitHubAction {
|
||||
body: String,
|
||||
event: String,
|
||||
},
|
||||
#[serde(rename = "list_pull_request_comments")]
|
||||
ListPullRequestComments {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "reply_pull_request_comment")]
|
||||
ReplyPullRequestComment {
|
||||
owner: String,
|
||||
repo: String,
|
||||
comment_id: u32,
|
||||
body: String,
|
||||
},
|
||||
#[serde(rename = "get_pull_request_reviews")]
|
||||
GetPullRequestReviews {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "get_combined_status")]
|
||||
GetCombinedStatus {
|
||||
owner: String,
|
||||
repo: String,
|
||||
r#ref: String,
|
||||
},
|
||||
#[serde(rename = "merge_pull_request")]
|
||||
MergePullRequest {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
commit_title: Option<String>,
|
||||
commit_message: Option<String>,
|
||||
merge_method: Option<String>,
|
||||
},
|
||||
#[serde(rename = "list_repos")]
|
||||
ListRepos {
|
||||
username: String,
|
||||
@@ -208,6 +271,19 @@ fn execute_inner(params: &str) -> Result<String, String> {
|
||||
repo,
|
||||
issue_number,
|
||||
} => get_issue(&owner, &repo, issue_number),
|
||||
GitHubAction::ListIssueComments {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
page,
|
||||
limit,
|
||||
} => list_issue_comments(&owner, &repo, issue_number, page, limit),
|
||||
GitHubAction::CreateIssueComment {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
} => create_issue_comment(&owner, &repo, issue_number, &body),
|
||||
GitHubAction::ListPullRequests {
|
||||
owner,
|
||||
repo,
|
||||
@@ -215,6 +291,23 @@ fn execute_inner(params: &str) -> Result<String, String> {
|
||||
page,
|
||||
limit,
|
||||
} => list_pull_requests(&owner, &repo, state.as_deref(), page, limit),
|
||||
GitHubAction::CreatePullRequest {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
head,
|
||||
base,
|
||||
body,
|
||||
draft,
|
||||
} => create_pull_request(
|
||||
&owner,
|
||||
&repo,
|
||||
&title,
|
||||
&head,
|
||||
&base,
|
||||
body.as_deref(),
|
||||
draft.unwrap_or(false),
|
||||
),
|
||||
GitHubAction::GetPullRequest {
|
||||
owner,
|
||||
repo,
|
||||
@@ -232,6 +325,44 @@ fn execute_inner(params: &str) -> Result<String, String> {
|
||||
body,
|
||||
event,
|
||||
} => create_pr_review(&owner, &repo, pr_number, &body, &event),
|
||||
GitHubAction::ListPullRequestComments {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
page,
|
||||
limit,
|
||||
} => list_pull_request_comments(&owner, &repo, pr_number, page, limit),
|
||||
GitHubAction::ReplyPullRequestComment {
|
||||
owner,
|
||||
repo,
|
||||
comment_id,
|
||||
body,
|
||||
} => reply_pull_request_comment(&owner, &repo, comment_id, &body),
|
||||
GitHubAction::GetPullRequestReviews {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
page,
|
||||
limit,
|
||||
} => get_pull_request_reviews(&owner, &repo, pr_number, page, limit),
|
||||
GitHubAction::GetCombinedStatus { owner, repo, r#ref } => {
|
||||
get_combined_status(&owner, &repo, &r#ref)
|
||||
}
|
||||
GitHubAction::MergePullRequest {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
commit_title,
|
||||
commit_message,
|
||||
merge_method,
|
||||
} => merge_pull_request(
|
||||
&owner,
|
||||
&repo,
|
||||
pr_number,
|
||||
commit_title.as_deref(),
|
||||
commit_message.as_deref(),
|
||||
merge_method.as_deref(),
|
||||
),
|
||||
GitHubAction::ListRepos {
|
||||
username,
|
||||
page,
|
||||
@@ -451,6 +582,49 @@ fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result<String, Strin
|
||||
)
|
||||
}
|
||||
|
||||
fn list_issue_comments(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
issue_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let limit = limit.unwrap_or(30).min(100);
|
||||
let mut path = format!(
|
||||
"/repos/{}/{}/issues/{}/comments?per_page={}",
|
||||
encoded_owner, encoded_repo, issue_number, limit
|
||||
);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn create_issue_comment(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
issue_number: u32,
|
||||
body: &str,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(body, "body")?;
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/issues/{}/comments",
|
||||
encoded_owner, encoded_repo, issue_number
|
||||
);
|
||||
let req_body = serde_json::json!({ "body": body });
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn list_pull_requests(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
@@ -478,6 +652,40 @@ fn list_pull_requests(
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn create_pull_request(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
head: &str,
|
||||
base: &str,
|
||||
body: Option<&str>,
|
||||
draft: bool,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(title, "title")?;
|
||||
validate_input_length(head, "head")?;
|
||||
validate_input_length(base, "base")?;
|
||||
if let Some(b) = body {
|
||||
validate_input_length(b, "body")?;
|
||||
}
|
||||
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!("/repos/{}/{}/pulls", encoded_owner, encoded_repo);
|
||||
let mut req_body = serde_json::json!({
|
||||
"title": title,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"draft": draft,
|
||||
});
|
||||
if let Some(body) = body {
|
||||
req_body["body"] = serde_json::json!(body);
|
||||
}
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
@@ -543,6 +751,132 @@ fn create_pr_review(
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn list_pull_request_comments(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let limit = limit.unwrap_or(30).min(100);
|
||||
let mut path = format!(
|
||||
"/repos/{}/{}/pulls/{}/comments?per_page={}",
|
||||
encoded_owner, encoded_repo, pr_number, limit
|
||||
);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn reply_pull_request_comment(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
comment_id: u32,
|
||||
body: &str,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(body, "body")?;
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/pulls/comments/{}/replies",
|
||||
encoded_owner, encoded_repo, comment_id
|
||||
);
|
||||
let req_body = serde_json::json!({ "body": body });
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn get_pull_request_reviews(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u32,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let limit = limit.unwrap_or(30).min(100);
|
||||
let mut path = format!(
|
||||
"/repos/{}/{}/pulls/{}/reviews?per_page={}",
|
||||
encoded_owner, encoded_repo, pr_number, limit
|
||||
);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn get_combined_status(owner: &str, repo: &str, r#ref: &str) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(r#ref, "ref")?;
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let encoded_ref = url_encode_path(r#ref);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/commits/{}/status",
|
||||
encoded_owner, encoded_repo, encoded_ref
|
||||
);
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn merge_pull_request(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u32,
|
||||
commit_title: Option<&str>,
|
||||
commit_message: Option<&str>,
|
||||
merge_method: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
if let Some(v) = commit_title {
|
||||
validate_input_length(v, "commit_title")?;
|
||||
}
|
||||
if let Some(v) = commit_message {
|
||||
validate_input_length(v, "commit_message")?;
|
||||
}
|
||||
let method = merge_method.unwrap_or("merge");
|
||||
let valid_methods = ["merge", "squash", "rebase"];
|
||||
if !valid_methods.contains(&method) {
|
||||
return Err(format!(
|
||||
"Invalid merge_method: '{}'. Must be one of: {}",
|
||||
method,
|
||||
valid_methods.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/pulls/{}/merge",
|
||||
encoded_owner, encoded_repo, pr_number
|
||||
);
|
||||
let mut req_body = serde_json::json!({
|
||||
"merge_method": method,
|
||||
});
|
||||
if let Some(v) = commit_title {
|
||||
req_body["commit_title"] = serde_json::json!(v);
|
||||
}
|
||||
if let Some(v) = commit_message {
|
||||
req_body["commit_message"] = serde_json::json!(v);
|
||||
}
|
||||
github_request("PUT", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn list_repos(username: &str, page: Option<u32>, limit: Option<u32>) -> Result<String, String> {
|
||||
if !validate_path_segment(username) {
|
||||
return Err("Invalid username".into());
|
||||
@@ -723,6 +1057,27 @@ const SCHEMA: &str = r#"{
|
||||
},
|
||||
"required": ["action", "owner", "repo", "issue_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_issue_comments" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"issue_number": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "issue_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_issue_comment" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"issue_number": { "type": "integer" },
|
||||
"body": { "type": "string" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "issue_number", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_pull_requests" },
|
||||
@@ -733,6 +1088,19 @@ const SCHEMA: &str = r#"{
|
||||
},
|
||||
"required": ["action", "owner", "repo"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_pull_request" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"head": { "type": "string" },
|
||||
"base": { "type": "string" },
|
||||
"body": { "type": "string" },
|
||||
"draft": { "type": "boolean", "default": false }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "title", "head", "base"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_pull_request" },
|
||||
@@ -762,6 +1130,59 @@ const SCHEMA: &str = r#"{
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number", "body", "event"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_pull_request_comments" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "reply_pull_request_comment" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"comment_id": { "type": "integer" },
|
||||
"body": { "type": "string" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "comment_id", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_pull_request_reviews" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" },
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_combined_status" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"ref": { "type": "string" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "ref"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "merge_pull_request" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" },
|
||||
"commit_title": { "type": "string" },
|
||||
"commit_message": { "type": "string" },
|
||||
"merge_method": { "type": "string", "enum": ["merge", "squash", "rebase"], "default": "merge" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_repos" },
|
||||
@@ -835,6 +1256,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_merge_method() {
|
||||
let valid = ["merge", "squash", "rebase"];
|
||||
assert!(valid.contains(&"merge"));
|
||||
assert!(!valid.contains(&"invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_length_validation() {
|
||||
assert!(validate_input_length("short", "test").is_ok());
|
||||
|
||||
Reference in New Issue
Block a user