tandem-server 0.6.2

HTTP server for Tandem engine APIs
use serde_json::{json, Value};
use std::path::PathBuf;

use tandem_bug_monitor::github::{BugMonitorGithubHost, GithubToolSet};
use tandem_runtime::mcp_ready::{EnsureReadyPolicy, McpReadyError};
use tandem_runtime::McpRemoteTool;
use tandem_types::{EngineEvent, ToolResult};

use crate::{
    AppState, BugMonitorConfig, BugMonitorDraftRecord, BugMonitorIncidentRecord,
    BugMonitorPostRecord, BugMonitorStatus, ExternalActionRecord,
};

pub use tandem_bug_monitor::github::{
    publish_draft, record_post_failure, PublishMode, PublishOutcome,
};

const BUG_MONITOR_LABEL: &str = "bug-monitor";

#[async_trait::async_trait]
impl BugMonitorGithubHost for AppState {
    async fn bug_monitor_status_snapshot(&self) -> BugMonitorStatus {
        AppState::bug_monitor_status_snapshot(self).await
    }

    async fn get_bug_monitor_draft(&self, draft_id: &str) -> Option<BugMonitorDraftRecord> {
        AppState::get_bug_monitor_draft(self, draft_id).await
    }

    async fn put_bug_monitor_draft(
        &self,
        draft: BugMonitorDraftRecord,
    ) -> anyhow::Result<BugMonitorDraftRecord> {
        AppState::put_bug_monitor_draft(self, draft).await
    }

    async fn get_bug_monitor_incident(
        &self,
        incident_id: &str,
    ) -> Option<BugMonitorIncidentRecord> {
        AppState::get_bug_monitor_incident(self, incident_id).await
    }

    async fn put_bug_monitor_post(
        &self,
        post: BugMonitorPostRecord,
    ) -> anyhow::Result<BugMonitorPostRecord> {
        AppState::put_bug_monitor_post(self, post).await
    }

    async fn list_bug_monitor_posts(&self, limit: usize) -> Vec<BugMonitorPostRecord> {
        AppState::list_bug_monitor_posts(self, limit).await
    }

    async fn list_bug_monitor_posts_by_draft(&self, draft_id: &str) -> Vec<BugMonitorPostRecord> {
        let mut rows = self
            .bug_monitor_posts
            .read()
            .await
            .values()
            .filter(|post| post.draft_id == draft_id)
            .cloned()
            .collect::<Vec<_>>();
        rows.sort_by_key(|post| std::cmp::Reverse(post.updated_at_ms));
        rows
    }

    async fn list_bug_monitor_posts_by_fingerprint(
        &self,
        repo: &str,
        fingerprint: &str,
    ) -> Vec<BugMonitorPostRecord> {
        let mut rows = self
            .bug_monitor_posts
            .read()
            .await
            .values()
            .filter(|post| post.repo == repo && post.fingerprint == fingerprint)
            .cloned()
            .collect::<Vec<_>>();
        rows.sort_by_key(|post| std::cmp::Reverse(post.updated_at_ms));
        rows
    }

    async fn list_bug_monitor_posts_by_idempotency_key(
        &self,
        idempotency_key: &str,
    ) -> Vec<BugMonitorPostRecord> {
        let mut rows = self
            .bug_monitor_posts
            .read()
            .await
            .values()
            .filter(|post| post.idempotency_key == idempotency_key)
            .cloned()
            .collect::<Vec<_>>();
        rows.sort_by_key(|post| std::cmp::Reverse(post.updated_at_ms));
        rows
    }

    async fn try_claim_bug_monitor_post_idempotency(
        &self,
        post: BugMonitorPostRecord,
    ) -> anyhow::Result<(bool, BugMonitorPostRecord)> {
        AppState::try_claim_bug_monitor_post_idempotency(self, post).await
    }

    async fn mirror_bug_monitor_post_as_external_action(
        &self,
        draft: &BugMonitorDraftRecord,
        post: &BugMonitorPostRecord,
    ) {
        let capability_id = match post.operation.as_str() {
            "comment_issue" => Some("github.comment_on_issue".to_string()),
            "create_issue" => Some("github.create_issue".to_string()),
            _ => None,
        };
        let action = ExternalActionRecord {
            action_id: post.post_id.clone(),
            operation: post.operation.clone(),
            status: post.status.clone(),
            source_kind: Some("bug_monitor".to_string()),
            source_id: Some(draft.draft_id.clone()),
            routine_run_id: None,
            context_run_id: draft.triage_run_id.clone(),
            capability_id,
            provider: Some(BUG_MONITOR_LABEL.to_string()),
            target: Some(draft.repo.clone()),
            approval_state: Some(if draft.status.eq_ignore_ascii_case("approval_required") {
                "approval_required".to_string()
            } else {
                "executed".to_string()
            }),
            idempotency_key: Some(post.idempotency_key.clone()),
            receipt: Some(json!({
                "post_id": post.post_id,
                "draft_id": post.draft_id,
                "incident_id": post.incident_id,
                "issue_number": post.issue_number,
                "issue_url": post.issue_url,
                "comment_id": post.comment_id,
                "comment_url": post.comment_url,
                "response_excerpt": post.response_excerpt,
            })),
            error: post.error.clone(),
            metadata: Some(json!({
                "repo": post.repo,
                "fingerprint": post.fingerprint,
                "evidence_digest": post.evidence_digest,
                "confidence": post.confidence,
                "risk_level": post.risk_level,
                "expected_destination": post.expected_destination,
                "evidence_refs": post.evidence_refs,
                "quality_gate": post.quality_gate,
                "bug_monitor_operation": post.operation,
            })),
            created_at_ms: post.created_at_ms,
            updated_at_ms: post.updated_at_ms,
        };
        if let Err(error) = AppState::record_external_action(self, action).await {
            tracing::warn!(
                "failed to persist external action mirror for bug monitor post {}: {}",
                post.post_id,
                error
            );
        }
    }

    async fn update_last_post_result(&self, result: String) {
        self.update_bug_monitor_runtime_status(|runtime| {
            runtime.last_post_result = Some(result);
        })
        .await;
    }

    fn publish_event(&self, event: EngineEvent) {
        self.event_bus.publish(event);
    }

    async fn ensure_bug_monitor_issue_draft(
        &self,
        draft_id: &str,
        force: bool,
    ) -> anyhow::Result<Value> {
        crate::http::bug_monitor::ensure_bug_monitor_issue_draft(self.clone(), draft_id, force)
            .await
    }

    async fn load_bug_monitor_issue_draft_artifact(&self, triage_run_id: &str) -> Option<Value> {
        crate::http::bug_monitor::load_bug_monitor_issue_draft_artifact(self, triage_run_id).await
    }

    async fn resolve_github_tool_set(
        &self,
        config: &BugMonitorConfig,
    ) -> anyhow::Result<GithubToolSet> {
        resolve_github_tool_set_for_state(self, config).await
    }

    async fn call_mcp_tool(
        &self,
        server_name: &str,
        tool_name: &str,
        payload: Value,
    ) -> anyhow::Result<ToolResult> {
        self.mcp
            .call_tool(server_name, tool_name, payload)
            .await
            .map_err(anyhow::Error::msg)
    }

    fn context_run_events_path(&self, run_id: &str) -> PathBuf {
        crate::http::context_runs::context_run_events_path(self, run_id)
    }
}

async fn resolve_github_tool_set_for_state(
    state: &AppState,
    config: &BugMonitorConfig,
) -> anyhow::Result<GithubToolSet> {
    let server_name = config
        .mcp_server
        .as_ref()
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| anyhow::anyhow!("Bug Monitor MCP server is not configured"))?
        .to_string();
    state
        .mcp
        .ensure_ready(&server_name, EnsureReadyPolicy::with_retries(3, 750))
        .await
        .map_err(|error| match error {
            McpReadyError::NotFound => {
                anyhow::anyhow!("Bug Monitor MCP server `{server_name}` was not found")
            }
            McpReadyError::Disabled => {
                anyhow::anyhow!("Bug Monitor MCP server `{server_name}` is disabled")
            }
            McpReadyError::PermanentlyFailed { last_error } => {
                let detail = last_error.unwrap_or_else(|| "connect failed".to_string());
                anyhow::anyhow!(
                    "Bug Monitor MCP server `{server_name}` was not ready for GitHub publish: {detail}"
                )
            }
        })?;
    let server_tools = state.mcp.server_tools(&server_name).await;
    if server_tools.is_empty() {
        anyhow::bail!("no MCP tools were discovered for selected Bug Monitor server");
    }
    let discovered = state
        .capability_resolver
        .discover_from_runtime(server_tools.clone(), Vec::new())
        .await;
    let mut resolved = state
        .capability_resolver
        .resolve(
            crate::capability_resolver::CapabilityResolveInput {
                workflow_id: Some("bug-monitor-github".to_string()),
                required_capabilities: vec![
                    "github.list_issues".to_string(),
                    "github.get_issue".to_string(),
                    "github.create_issue".to_string(),
                    "github.comment_on_issue".to_string(),
                ],
                optional_capabilities: Vec::new(),
                provider_preference: vec!["mcp".to_string()],
                available_tools: discovered,
            },
            Vec::new(),
        )
        .await?;
    if !resolved.missing_required.is_empty() {
        let _ = state.capability_resolver.refresh_builtin_bindings().await;
        let discovered = state
            .capability_resolver
            .discover_from_runtime(server_tools.clone(), Vec::new())
            .await;
        resolved = state
            .capability_resolver
            .resolve(
                crate::capability_resolver::CapabilityResolveInput {
                    workflow_id: Some("bug-monitor-github".to_string()),
                    required_capabilities: vec![
                        "github.list_issues".to_string(),
                        "github.get_issue".to_string(),
                        "github.create_issue".to_string(),
                        "github.comment_on_issue".to_string(),
                    ],
                    optional_capabilities: Vec::new(),
                    provider_preference: vec!["mcp".to_string()],
                    available_tools: discovered,
                },
                Vec::new(),
            )
            .await?;
    }
    let tool_name = |capability_id: &str| -> anyhow::Result<String> {
        let namespaced = resolved
            .resolved
            .iter()
            .find(|row| row.capability_id == capability_id)
            .map(|row| row.tool_name.clone())
            .ok_or_else(|| anyhow::anyhow!("missing resolved tool for {capability_id}"))?;
        map_namespaced_to_raw_tool(&server_tools, &namespaced)
    };
    let direct_tool_name_fallback = |candidates: &[&str]| -> Option<String> {
        server_tools
            .iter()
            .find(|row| {
                candidates.iter().any(|candidate| {
                    row.tool_name.eq_ignore_ascii_case(candidate)
                        || row.namespaced_name.eq_ignore_ascii_case(candidate)
                })
            })
            .map(|row| row.tool_name.clone())
    };
    let list_issues = tool_name("github.list_issues").or_else(|_| {
        direct_tool_name_fallback(&[
            "list_issues",
            "list_repository_issues",
            "mcp.github.list_issues",
            "mcp.githubcopilot.list_issues",
        ])
        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.list_issues"))
    })?;
    let get_issue = tool_name("github.get_issue").or_else(|_| {
        direct_tool_name_fallback(&[
            "get_issue",
            "issue_read",
            "mcp.github.get_issue",
            "mcp.github.issue_read",
            "mcp.githubcopilot.issue_read",
        ])
        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.get_issue"))
    })?;
    let create_issue = tool_name("github.create_issue").or_else(|_| {
        direct_tool_name_fallback(&[
            "create_issue",
            "issue_write",
            "mcp.github.create_issue",
            "mcp.github.issue_write",
            "mcp.githubcopilot.issue_write",
        ])
        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.create_issue"))
    })?;
    let comment_on_issue = tool_name("github.comment_on_issue").or_else(|_| {
        direct_tool_name_fallback(&[
            "add_issue_comment",
            "create_issue_comment",
            "mcp.github.add_issue_comment",
            "mcp.github.create_issue_comment",
            "mcp.githubcopilot.add_issue_comment",
            "github.comment_on_issue",
        ])
        .ok_or_else(|| anyhow::anyhow!("missing resolved tool for github.comment_on_issue"))
    })?;
    Ok(GithubToolSet {
        server_name,
        list_issues,
        get_issue,
        create_issue,
        comment_on_issue,
    })
}

fn map_namespaced_to_raw_tool(
    tools: &[McpRemoteTool],
    namespaced_name: &str,
) -> anyhow::Result<String> {
    tools
        .iter()
        .find(|row| row.namespaced_name == namespaced_name)
        .map(|row| row.tool_name.clone())
        .ok_or_else(|| anyhow::anyhow!("failed to map MCP tool `{namespaced_name}` to raw tool"))
}