mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(gateway): full settings page polish with all tiers - Backend: add ActiveConfigSnapshot to expose resolved LLM backend, model, and enabled channels via /api/gateway/status - Add missing Agent settings (daily cost cap, actions/hour, local tools) - Add Sandbox, Routines, Safety, Skills, and Search setting groups - Settings import/export (JSON download + file upload) - Active env defaults shown as placeholders in Inference settings - Styled confirmation modals replace window.confirm() for remove actions - Global restart banner persists across settings subtab switches - Client-side validation with min/max constraints on number inputs - Accessibility: aria-label on inputs, role=status on save indicators - Settings search filters rows across current subtab - Smooth CSS transitions for conditional field visibility (showWhen) - Tunnel settings in Channels subtab - Mobile responsive settings layout at 768px breakpoint - i18n keys for toolbar, search, and import/export in en + zh-CN Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(gateway): polish settings page and remove registered tools debug section Remove the "Registered Tools" table from the extensions tab (debug info not useful to end users), clean up associated CSS/i18n/JS. Additional settings page UI polish: extension card state styling, layout refinements. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address PR review feedback [skip-regression-check] - Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication - Remove unused formatGroupName/formatSettingLabel helpers - Use i18n keys for MCP Configure/Reconfigure buttons - Add data-i18n-placeholder to settings search input - Remove data-i18n from confirm modal button (set dynamically by showConfirmModal) - Fix cargo fmt in main.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): update tests for unified settings tab layout [skip-regression-check] - Update TABS list: replace extensions/skills with settings - Add settings_subtab/settings_subpanel selectors to helpers - Update test_connection, test_skills, test_extensions, test_wasm_lifecycle to navigate via Settings > subtab instead of top-level tabs - Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab) - Remove tools table tests and mock_ext_apis tools= parameter - Fix CSP violation: replace inline onclick on confirm modal cancel button Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address second round of PR review feedback [skip-regression-check] - Use I18n.t() for MCP empty state, export/import toasts, confirm modal - Fix CLI channel card using wrong channel key ('repl' -> 'cli') - Fix settings search counting hidden rows as visible - Add aria-label i18n for settings search input - Add common.loadFailed i18n key (en + zh-CN) - Update E2E tests: WASM channel tests use Channels subtab, remove tests use custom confirm modal instead of window.confirm Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check] - WASM channel tests: filter by display name to avoid matching built-in channel cards in the Channels subtab - Skills remove test: click confirm modal button instead of using window.confirm (skill removal now uses custom confirm modal) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): address third round of PR review feedback [skip-regression-check] - approval_needed SSE: refresh any active settings subtab, not just Extensions — approvals can surface from Channels/MCP setup flows too - renderCardsSkeleton: remove nested .extensions-list wrapper that caused skeleton cards to render constrained inside grid cells Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): fix auth_completed reload test race condition [skip-regression-check] Use expect_response to deterministically wait for the /api/extensions reload triggered by handleAuthCompleted → refreshCurrentSettingsTab, instead of a fixed 600ms sleep that was too short under CI load. Also remove stale /api/extensions/tools route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(e2e): debug auth_completed reload test with function counter [skip-regression-check] Inject a counter wrapper around refreshCurrentSettingsTab to verify it's actually called, and wait for the async fetch to complete before asserting the reload count. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check] Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS, AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n keys. Render functions now resolve labels via I18n.t() so the settings page translates when switching locales. Covers: group titles, setting labels/descriptions, built-in channel names/descriptions, and the "No settings found" empty state. Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): localize remaining hardcoded UI strings [skip-regression-check] - Fix export error toast using wrong i18n key (importFailed → exportFailed) - Replace "Failed to load settings:" with I18n.t('common.loadFailed') - Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive" - Localize settings placeholders: "env: ", "env default", "use env default" - Localize "✓ Saved" indicator - Add new i18n keys to both en.js and zh-CN.js Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check] - Add role="dialog", aria-modal="true", aria-labelledby to confirm modal - Focus confirm button when modal opens - Close modal on Escape key or overlay click - Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check] Address PR review feedback: - Boolean settings now use a tri-state select (env default / On / Off) instead of a checkbox, matching the pattern used by other select settings and allowing users to revert to the env default - Clear search input when switching settings subtabs so stale filters don't carry over to the new panel - Always assign model suggestions (even empty array) so stale IDs from a previous successful /v1/models fetch don't persist when the endpoint later returns empty Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check] Address PR review feedback: - auth_completed SSE listener now delegates to handleAuthCompleted(data) instead of inlining logic with a bare closeConfigureModal() call, so only the matching extension's modal is dismissed - bedrock_cross_region changed from free text to select with the four valid values (us/eu/apac/global), matching backend validation - Number settings now use step=1 and parseInt() instead of parseFloat(), preventing fractional values that the backend (u32/u64) would reject Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
534 lines
19 KiB
Rust
534 lines
19 KiB
Rust
#![allow(dead_code)]
|
|
|
|
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 tokio::sync::oneshot;
|
|
|
|
use ironclaw::agent::routine_engine::RoutineEngine;
|
|
use ironclaw::agent::{Agent, AgentDeps, SessionManager as AgentSessionManager};
|
|
use ironclaw::app::{AppBuilder, AppBuilderFlags};
|
|
use ironclaw::channels::IncomingMessage;
|
|
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::config::{Config, RegistryProviderConfig, RoutineConfig};
|
|
use ironclaw::db::Database;
|
|
use ironclaw::db::libsql::LibSqlBackend;
|
|
use ironclaw::llm::registry::ProviderProtocol;
|
|
use ironclaw::llm::{
|
|
SessionConfig as LlmSessionConfig, SessionManager as LlmSessionManager, create_llm_provider,
|
|
};
|
|
use ironclaw::secrets::SecretsStore;
|
|
use ironclaw::tools::{Tool, ToolError, ToolOutput};
|
|
|
|
use crate::support::test_channel::{TestChannel, TestChannelHandle};
|
|
|
|
struct MockGithubWebhookTool;
|
|
|
|
#[async_trait]
|
|
impl Tool for MockGithubWebhookTool {
|
|
fn name(&self) -> &str {
|
|
"github"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Mock GitHub webhook parser for integration harness"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({"type":"object"})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &ironclaw::context::JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let event = params
|
|
.pointer("/webhook/headers/x-github-event")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ToolError::InvalidParameters("missing x-github-event".to_string()))?;
|
|
|
|
let action = params
|
|
.pointer("/webhook/body_json/action")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown");
|
|
let mut payload = params
|
|
.pointer("/webhook/body_json")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
if payload.get("repository").and_then(|v| v.as_str()).is_none()
|
|
&& let Some(full_name) = payload
|
|
.pointer("/repository/full_name")
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
payload["repository"] = serde_json::json!(full_name);
|
|
}
|
|
let event_type = format!(
|
|
"{}.{}",
|
|
if event == "issues" { "issue" } else { event },
|
|
action
|
|
);
|
|
|
|
Ok(ToolOutput::success(
|
|
serde_json::json!({
|
|
"emit_events": [{
|
|
"source": "github",
|
|
"event_type": event_type,
|
|
"payload": payload
|
|
}]
|
|
}),
|
|
Duration::from_millis(1),
|
|
))
|
|
}
|
|
|
|
fn webhook_capability(&self) -> Option<ironclaw::tools::wasm::WebhookCapability> {
|
|
Some(ironclaw::tools::wasm::WebhookCapability {
|
|
secret_name: Some("github_webhook_secret".to_string()),
|
|
secret_header: Some("x-webhook-secret".to_string()),
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct GatewayWorkflowHarness {
|
|
pub addr: SocketAddr,
|
|
pub webhook_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<()>>,
|
|
webhook_shutdown_tx: Option<oneshot::Sender<()>>,
|
|
webhook_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,
|
|
is_codex_chatgpt: false,
|
|
refresh_token: None,
|
|
auth_path: None,
|
|
cache_retention: Default::default(),
|
|
unsupported_params: Vec::new(),
|
|
});
|
|
|
|
let llm_session = Arc::new(LlmSessionManager::new(LlmSessionConfig::default()));
|
|
let llm = create_llm_provider(&config.llm, Arc::clone(&llm_session))
|
|
.await
|
|
.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 components = app_builder
|
|
.build_all()
|
|
.await
|
|
.expect("failed to build app components");
|
|
components
|
|
.tools
|
|
.register(Arc::new(MockGithubWebhookTool))
|
|
.await;
|
|
|
|
components.tools.register_job_tools(
|
|
Arc::clone(&components.context_manager),
|
|
None,
|
|
None,
|
|
components.db.clone(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
|
|
// Agent::run() creates its own RoutineEngine and populates this slot.
|
|
let routine_slot: Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>> =
|
|
Arc::new(tokio::sync::RwLock::new(None));
|
|
|
|
let test_channel = Arc::new(TestChannel::new());
|
|
let handle = TestChannelHandle::with_name(Arc::clone(&test_channel), "gateway");
|
|
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),
|
|
oauth_rate_limiter: RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: Some(Arc::clone(&components.cost_guard)),
|
|
routine_engine: Arc::clone(&routine_slot),
|
|
startup_time: Instant::now(),
|
|
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
|
});
|
|
|
|
let mut agent = Agent::new(
|
|
components.config.agent.clone(),
|
|
AgentDeps {
|
|
owner_id: components.config.owner_id.clone(),
|
|
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,
|
|
lightweight_tools_enabled: true,
|
|
lightweight_max_iterations: 3,
|
|
}),
|
|
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 webhook_secrets = Arc::new(ironclaw::secrets::InMemorySecretsStore::new(Arc::new(
|
|
ironclaw::secrets::SecretsCrypto::new(SecretString::from(
|
|
"test-key-at-least-32-chars-long!!".to_string(),
|
|
))
|
|
.expect("crypto"),
|
|
)));
|
|
webhook_secrets
|
|
.create(
|
|
&user_id,
|
|
ironclaw::secrets::CreateSecretParams::new(
|
|
"github_webhook_secret",
|
|
"test-webhook-secret",
|
|
),
|
|
)
|
|
.await
|
|
.expect("store webhook secret");
|
|
let webhook_state = ironclaw::webhooks::ToolWebhookState {
|
|
tools: Arc::clone(gateway_state.tool_registry.as_ref().expect("tool registry")),
|
|
routine_engine: Arc::clone(&routine_slot),
|
|
user_id: user_id.clone(),
|
|
secrets_store: Some(
|
|
webhook_secrets as Arc<dyn ironclaw::secrets::SecretsStore + Send + Sync>,
|
|
),
|
|
};
|
|
let webhook_app = ironclaw::webhooks::routes(webhook_state);
|
|
let webhook_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("failed to bind webhook listener");
|
|
let webhook_addr = webhook_listener.local_addr().expect("webhook local addr");
|
|
let (webhook_shutdown_tx, webhook_shutdown_rx) = oneshot::channel();
|
|
let webhook_handle = tokio::spawn(async move {
|
|
let _ = axum::serve(webhook_listener, webhook_app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = webhook_shutdown_rx.await;
|
|
})
|
|
.await;
|
|
});
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()
|
|
.expect("failed to build reqwest client");
|
|
|
|
Self {
|
|
addr,
|
|
webhook_addr,
|
|
auth_token,
|
|
client,
|
|
user_id,
|
|
test_channel,
|
|
db,
|
|
gateway_state,
|
|
agent_handle: Some(agent_handle),
|
|
bridge_handle: Some(bridge_handle),
|
|
webhook_shutdown_tx: Some(webhook_shutdown_tx),
|
|
webhook_handle: Some(webhook_handle),
|
|
_temp_dir: temp_dir,
|
|
}
|
|
}
|
|
|
|
pub fn base_url(&self) -> String {
|
|
format!("http://{}", self.addr)
|
|
}
|
|
|
|
pub fn webhook_base_url(&self) -> String {
|
|
format!("http://{}", self.webhook_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!("{}/webhook/tools/github", self.webhook_base_url()))
|
|
.header("x-github-event", event)
|
|
.header("x-webhook-secret", "test-webhook-secret")
|
|
.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(tx) = self.webhook_shutdown_tx.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
|
|
if let Some(handle) = self.bridge_handle.take() {
|
|
handle.abort();
|
|
}
|
|
if let Some(handle) = self.webhook_handle.take() {
|
|
let _ = handle.await;
|
|
}
|
|
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.webhook_handle.take() {
|
|
handle.abort();
|
|
}
|
|
if let Some(handle) = self.agent_handle.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
}
|