tandem-server 0.4.29

HTTP server for Tandem engine APIs
Documentation
pub(crate) use super::*;
use std::collections::HashMap;
use std::sync::Arc;
use tandem_core::{
    AgentRegistry, CancellationRegistry, ConfigStore, EngineLoop, EventBus, PermissionManager,
    PluginRegistry, Storage,
};
use tandem_providers::ProviderRegistry;
use tandem_runtime::{LspManager, McpRegistry, PtyManager, WorkspaceIndex};
use tandem_tools::ToolRegistry;
use tandem_types::TenantContext;

#[allow(dead_code)]
pub(crate) struct AutomationNodeBuilder {
    node: AutomationFlowNode,
}

impl AutomationNodeBuilder {
    pub(crate) fn new(node_id: impl Into<String>) -> Self {
        let node_id = node_id.into();
        Self {
            node: AutomationFlowNode {
                knowledge: tandem_orchestrator::KnowledgeBinding::default(),
                node_id: node_id.clone(),
                agent_id: "agent-a".to_string(),
                objective: format!("Run {node_id}"),
                depends_on: Vec::new(),
                input_refs: Vec::new(),
                output_contract: None,
                retry_policy: None,
                timeout_ms: None,
                max_tool_calls: None,
                stage_kind: None,
                gate: None,
                metadata: None,
            },
        }
    }

    pub(crate) fn agent_id(mut self, agent_id: impl Into<String>) -> Self {
        self.node.agent_id = agent_id.into();
        self
    }

    pub(crate) fn objective(mut self, objective: impl Into<String>) -> Self {
        self.node.objective = objective.into();
        self
    }

    pub(crate) fn depends_on(mut self, depends_on: Vec<&str>) -> Self {
        self.node.depends_on = depends_on.into_iter().map(str::to_string).collect();
        self
    }

    pub(crate) fn output_contract(mut self, output_contract: AutomationFlowOutputContract) -> Self {
        self.node.output_contract = Some(output_contract);
        self
    }

    pub(crate) fn stage_kind(mut self, stage_kind: AutomationNodeStageKind) -> Self {
        self.node.stage_kind = Some(stage_kind);
        self
    }

    pub(crate) fn metadata(mut self, metadata: Value) -> Self {
        self.node.metadata = Some(metadata);
        self
    }

    pub(crate) fn build(self) -> AutomationFlowNode {
        self.node
    }
}

#[allow(dead_code)]
pub(crate) struct AutomationSpecBuilder {
    automation: AutomationV2Spec,
}

impl AutomationSpecBuilder {
    pub(crate) fn new(automation_id: impl Into<String>) -> Self {
        let automation_id = automation_id.into();
        Self {
            automation: AutomationV2Spec {
                automation_id,
                name: "Test Automation".to_string(),
                description: None,
                status: AutomationV2Status::Active,
                schedule: AutomationV2Schedule {
                    schedule_type: AutomationV2ScheduleType::Manual,
                    cron_expression: None,
                    interval_seconds: None,
                    timezone: "UTC".to_string(),
                    misfire_policy: RoutineMisfirePolicy::RunOnce,
                },
                knowledge: tandem_orchestrator::KnowledgeBinding::default(),
                agents: vec![AutomationAgentProfile {
                    agent_id: "agent-a".to_string(),
                    template_id: Some("template-a".to_string()),
                    display_name: "Agent A".to_string(),
                    avatar_url: None,
                    model_policy: None,
                    skills: Vec::new(),
                    tool_policy: AutomationAgentToolPolicy {
                        allowlist: Vec::new(),
                        denylist: Vec::new(),
                    },
                    mcp_policy: AutomationAgentMcpPolicy {
                        allowed_servers: Vec::new(),
                        allowed_tools: None,
                    },
                    approval_policy: None,
                }],
                flow: AutomationFlowSpec { nodes: Vec::new() },
                execution: AutomationExecutionPolicy {
                    max_parallel_agents: Some(2),
                    max_total_runtime_ms: None,
                    max_total_tool_calls: None,
                    max_total_tokens: None,
                    max_total_cost_usd: None,
                },
                output_targets: Vec::new(),
                created_at_ms: 1,
                updated_at_ms: 1,
                creator_id: "test".to_string(),
                workspace_root: Some(".".to_string()),
                metadata: None,
                next_fire_at_ms: None,
                last_fired_at_ms: None,
                scope_policy: None,
                watch_conditions: Vec::new(),
                handoff_config: None,
            },
        }
    }

    pub(crate) fn name(mut self, name: impl Into<String>) -> Self {
        self.automation.name = name.into();
        self
    }

    pub(crate) fn nodes(mut self, nodes: Vec<AutomationFlowNode>) -> Self {
        self.automation.flow.nodes = nodes;
        self
    }

    pub(crate) fn metadata(mut self, metadata: Value) -> Self {
        self.automation.metadata = Some(metadata);
        self
    }

    #[allow(dead_code)]
    pub(crate) fn workspace_root(mut self, workspace_root: impl Into<String>) -> Self {
        self.automation.workspace_root = Some(workspace_root.into());
        self
    }

    pub(crate) fn build(self) -> AutomationV2Spec {
        self.automation
    }
}

#[allow(dead_code)]
pub(crate) struct AutomationRunBuilder {
    run: AutomationV2RunRecord,
}

impl AutomationRunBuilder {
    pub(crate) fn new(run_id: impl Into<String>, automation_id: impl Into<String>) -> Self {
        Self {
            run: AutomationV2RunRecord {
                run_id: run_id.into(),
                automation_id: automation_id.into(),
                tenant_context: TenantContext::local_implicit(),
                trigger_type: "manual".to_string(),
                status: AutomationRunStatus::Queued,
                created_at_ms: 1,
                updated_at_ms: 1,
                started_at_ms: None,
                finished_at_ms: None,
                active_session_ids: Vec::new(),
                latest_session_id: None,
                active_instance_ids: Vec::new(),
                checkpoint: AutomationRunCheckpoint {
                    completed_nodes: Vec::new(),
                    pending_nodes: Vec::new(),
                    node_outputs: HashMap::new(),
                    node_attempts: HashMap::new(),
                    blocked_nodes: Vec::new(),
                    awaiting_gate: None,
                    gate_history: Vec::new(),
                    lifecycle_history: Vec::new(),
                    last_failure: None,
                },
                runtime_context: None,
                automation_snapshot: None,
                pause_reason: None,
                resume_reason: None,
                detail: None,
                stop_kind: None,
                stop_reason: None,
                prompt_tokens: 0,
                completion_tokens: 0,
                total_tokens: 0,
                estimated_cost_usd: 0.0,
                scheduler: None,
                trigger_reason: None,
                consumed_handoff_id: None,
            },
        }
    }

    #[allow(dead_code)]
    pub(crate) fn status(mut self, status: AutomationRunStatus) -> Self {
        self.run.status = status;
        self
    }

    pub(crate) fn pending_nodes(mut self, pending_nodes: Vec<&str>) -> Self {
        self.run.checkpoint.pending_nodes = pending_nodes.into_iter().map(str::to_string).collect();
        self
    }

    pub(crate) fn completed_nodes(mut self, completed_nodes: Vec<&str>) -> Self {
        self.run.checkpoint.completed_nodes =
            completed_nodes.into_iter().map(str::to_string).collect();
        self
    }

    pub(crate) fn build(self) -> AutomationV2RunRecord {
        self.run
    }
}

pub(crate) fn test_automation_node(
    node_id: &str,
    depends_on: Vec<&str>,
    phase_id: &str,
    priority: i64,
) -> AutomationFlowNode {
    AutomationNodeBuilder::new(node_id)
        .depends_on(depends_on)
        .stage_kind(AutomationNodeStageKind::Workstream)
        .metadata(json!({
            "builder": {
                "phase_id": phase_id,
                "priority": priority
            }
        }))
        .build()
}

pub(crate) fn test_phase_automation(
    phases: Value,
    nodes: Vec<AutomationFlowNode>,
) -> AutomationV2Spec {
    AutomationSpecBuilder::new("auto-phase-test")
        .name("Phase Test")
        .nodes(nodes)
        .metadata(json!({
            "mission": {
                "phases": phases
            }
        }))
        .build()
}

pub(crate) fn test_phase_run(
    pending_nodes: Vec<&str>,
    completed_nodes: Vec<&str>,
) -> AutomationV2RunRecord {
    AutomationRunBuilder::new("run-phase-test", "auto-phase-test")
        .pending_nodes(pending_nodes)
        .completed_nodes(completed_nodes)
        .build()
}

pub(crate) fn test_state_with_path(path: PathBuf) -> AppState {
    let mut state = AppState::new_starting("test-attempt".to_string(), true);
    state.shared_resources_path = path;
    state.routines_path = tmp_routines_file("shared-state");
    state.routine_history_path = tmp_routines_file("routine-history");
    state.routine_runs_path = tmp_routines_file("routine-runs");
    state.external_actions_path = tmp_routines_file("external-actions");
    state
}

pub(crate) async fn ready_test_state() -> AppState {
    let root = std::env::temp_dir().join(format!("tandem-state-test-{}", uuid::Uuid::new_v4()));
    let global = root.join("global-config.json");
    let tandem_home = root.join("tandem-home");
    let mcp_state = root.join("mcp.json");
    std::env::set_var("TANDEM_GLOBAL_CONFIG", &global);
    std::env::set_var("TANDEM_HOME", &tandem_home);
    if let Some(parent) = mcp_state.parent() {
        std::fs::create_dir_all(parent).expect("mcp state dir");
    }
    std::fs::write(&mcp_state, "{}").expect("write mcp state");

    let storage = Arc::new(Storage::new(root.join("storage")).await.expect("storage"));
    let config = ConfigStore::new(root.join("config.json"), None)
        .await
        .expect("config");
    let event_bus = EventBus::new();
    let app_config = config.get().await;
    let browser = crate::BrowserSubsystem::new(app_config.browser.clone());
    let _ = browser.refresh_status().await;
    let providers = ProviderRegistry::new(app_config.into());
    let plugins = PluginRegistry::new(".").await.expect("plugins");
    let agents = AgentRegistry::new(".").await.expect("agents");
    let tools = ToolRegistry::new();
    let permissions = PermissionManager::new(event_bus.clone());
    let mcp = McpRegistry::new_with_state_file(mcp_state);
    let pty = PtyManager::new();
    let lsp = LspManager::new(".");
    let auth = Arc::new(tokio::sync::RwLock::new(HashMap::new()));
    let logs = Arc::new(tokio::sync::RwLock::new(Vec::new()));
    let workspace_index = WorkspaceIndex::new(".").await;
    let cancellations = CancellationRegistry::new();
    let host_runtime_context = crate::detect_host_runtime_context();
    let engine_loop = EngineLoop::new(
        storage.clone(),
        event_bus.clone(),
        providers.clone(),
        plugins.clone(),
        agents.clone(),
        permissions.clone(),
        tools.clone(),
        cancellations.clone(),
        host_runtime_context.clone(),
    );
    let mut state = AppState::new_starting(uuid::Uuid::new_v4().to_string(), false);
    state.shared_resources_path = root.join("shared_resources.json");
    state
        .mark_ready(crate::RuntimeState {
            storage,
            config,
            event_bus,
            providers,
            plugins,
            agents,
            tools,
            permissions,
            mcp,
            pty,
            lsp,
            auth,
            logs,
            workspace_index,
            cancellations,
            engine_loop,
            host_runtime_context,
            browser,
        })
        .await
        .expect("runtime ready");
    state
}

pub(crate) fn tmp_resource_file(name: &str) -> PathBuf {
    std::env::temp_dir().join(format!(
        "tandem-server-{name}-{}.json",
        uuid::Uuid::new_v4()
    ))
}

pub(crate) fn tmp_routines_file(name: &str) -> PathBuf {
    std::env::temp_dir().join(format!(
        "tandem-server-routines-{name}-{}.json",
        uuid::Uuid::new_v4()
    ))
}

mod automations;
mod handoff;
mod routines;
mod shared_resources;
mod status_index;