tandem-server 0.7.0

HTTP server for Tandem engine APIs
// Copyright (c) 2026 Frumu LTD
// Licensed under the Business Source License 1.1

use super::*;

fn tenant_matches(left: &TenantContext, right: &TenantContext) -> bool {
    left.org_id == right.org_id
        && left.workspace_id == right.workspace_id
        && left.deployment_id == right.deployment_id
}

fn planner_url(session_id: &str) -> String {
    format!("/#/planner?session_id={session_id}")
}

pub(crate) async fn product_capabilities(
    state: &AppState,
    tenant: &TenantContext,
    chat_session: &Session,
) -> anyhow::Result<Value> {
    let providers = state.providers.list().await;
    let mcp_servers = state
        .mcp
        .list_public()
        .await
        .into_values()
        .map(|server| {
            json!({
                "name": server.name,
                "enabled": server.enabled,
                "purpose": server.purpose,
                "grounding_required": server.grounding_required,
            })
        })
        .collect::<Vec<_>>();
    let workspace = state.workspace_index.snapshot().await;
    Ok(json!({
        "ok": true,
        "action": "inspect_capabilities",
        "tenant": {
            "org_id": tenant.org_id,
            "workspace_id": tenant.workspace_id,
            "deployment_id": tenant.deployment_id,
        },
        "workspace": {
            "root": chat_session.workspace_root.as_deref().unwrap_or(&workspace.root),
            "project_id": chat_session.project_id,
        },
        "providers": providers,
        "mcp_servers": mcp_servers,
        "channels": ["control_panel", "slack", "discord", "telegram"],
        "memory": {
            "session_context": true,
            "project_memory": true,
            "global_memory": true,
            "embedding_search": true,
        },
        "authoring": {
            "workflow_planner": true,
            "automation_drafts": true,
            "orchestrations": true,
            "materialization_default": "disabled_draft",
        },
        "secrets_included": false,
        "blockers": [],
    }))
}

pub(crate) async fn operator_artifact_context(
    state: &AppState,
    tenant: &TenantContext,
    chat_session_id: &str,
) -> Value {
    let mut sessions = state
        .list_workflow_planner_sessions(None)
        .await
        .into_iter()
        .filter(|session| {
            tenant_matches(&session.tenant_context, tenant)
                && session.linked_chat_session_id.as_deref() == Some(chat_session_id)
        })
        .collect::<Vec<_>>();
    sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
    let latest_reference = sessions
        .iter()
        .filter_map(|session| session.last_referenced_at_ms)
        .max();
    let referenced = latest_reference.and_then(|latest| {
        let mut matches = sessions
            .iter()
            .filter(|session| session.last_referenced_at_ms == Some(latest));
        let selected = matches.next();
        (matches.next().is_none()).then_some(selected).flatten()
    });
    let active = referenced.or_else(|| (sessions.len() == 1).then(|| &sessions[0]));
    json!({
        "selection": if active.is_some() { "single_active" } else if sessions.is_empty() { "none" } else { "ambiguous" },
        "active": active.map(|session| json!({
            "planner_session_id": session.session_id,
            "plan_id": session.current_plan_id,
            "revision": session.draft.as_ref().map(|draft| draft.plan_revision),
            "url": planner_url(&session.session_id),
        })),
        "recent": sessions.into_iter().take(5).map(|session| json!({
            "planner_session_id": session.session_id,
            "title": session.title,
            "plan_id": session.current_plan_id,
            "revision": session.draft.as_ref().map(|draft| draft.plan_revision),
            "operation": session.operation,
            "artifact_links": session.artifact_links,
            "updated_at_ms": session.updated_at_ms,
            "url": planner_url(&session.session_id),
        })).collect::<Vec<_>>(),
    })
}