tandem-server 0.6.2

HTTP server for Tandem engine APIs
fn approval_policy_test_automation(id: &str) -> AutomationV2Spec {
    AutomationV2Spec {
        automation_id: id.to_string(),
        name: "Approval Gate Policy Test".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::new(),
        flow: AutomationFlowSpec { nodes: Vec::new() },
        execution: AutomationExecutionPolicy {
            profile: None,
            max_parallel_agents: Some(1),
            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(format!("/tmp/{id}-workspace")),
        metadata: None,
        next_fire_at_ms: None,
        last_fired_at_ms: None,
        scope_policy: None,
        watch_conditions: Vec::new(),
        handoff_config: None,
    }
}

async fn insert_awaiting_policy_gate_run(
    state: &crate::AppState,
    automation: &AutomationV2Spec,
    gate: AutomationPendingGate,
) -> String {
    state
        .put_automation_v2(automation.clone())
        .await
        .expect("put automation");
    let mut run = state
        .create_automation_v2_run(automation, "manual")
        .await
        .expect("create run");
    let run_id = run.run_id.clone();
    run.status = AutomationRunStatus::AwaitingApproval;
    run.detail = Some(format!("awaiting approval for gate `{}`", gate.node_id));
    run.checkpoint.pending_nodes = vec![gate.node_id.clone()];
    run.checkpoint.blocked_nodes = vec![gate.node_id.clone()];
    run.checkpoint.awaiting_gate = Some(gate);
    {
        let mut runs = state.automation_v2_runs.write().await;
        runs.insert(run_id.clone(), run);
    }
    run_id
}

#[tokio::test]
async fn approval_gate_expiry_policy_auto_cancels_with_expired_record() {
    let state = ready_test_state().await;
    let automation = approval_policy_test_automation("auto-gate-expiry-cancel");
    let gate = AutomationPendingGate {
        node_id: "approval".to_string(),
        title: "Approval".to_string(),
        instructions: None,
        decisions: vec!["approve".to_string(), "cancel".to_string()],
        rework_targets: Vec::new(),
        requested_at_ms: now_ms().saturating_sub(10_000),
        upstream_node_ids: Vec::new(),
        metadata: None,
        expiry_policy: Some(AutomationGateExpiryPolicy {
            expires_after_ms: Some(1),
            on_expiry: Some(AutomationGateExpiryAction::Cancel),
            escalate_to: None,
            remind_every_ms: None,
        }),
    };
    let run_id = insert_awaiting_policy_gate_run(&state, &automation, gate).await;

    assert_eq!(state.process_awaiting_approval_gate_policies().await, 1);
    assert_eq!(state.process_awaiting_approval_gate_policies().await, 0);

    let updated = state
        .get_automation_v2_run(&run_id)
        .await
        .expect("updated run");
    assert_eq!(updated.status, AutomationRunStatus::Cancelled);
    assert!(updated.checkpoint.awaiting_gate.is_none());
    assert_eq!(updated.checkpoint.gate_history.len(), 1);
    assert_eq!(updated.checkpoint.gate_history[0].decision, "expired");
    assert!(updated
        .checkpoint
        .lifecycle_history
        .iter()
        .any(|entry| entry.event == "approval_gate_expired"));
}

#[tokio::test]
async fn approval_gate_reminder_policy_updates_notification_key() {
    let state = ready_test_state().await;
    let automation = approval_policy_test_automation("auto-gate-reminder");
    let requested_at_ms = now_ms().saturating_sub(120_000);
    let gate = AutomationPendingGate {
        node_id: "approval".to_string(),
        title: "Approval".to_string(),
        instructions: None,
        decisions: vec!["approve".to_string(), "cancel".to_string()],
        rework_targets: Vec::new(),
        requested_at_ms,
        upstream_node_ids: Vec::new(),
        metadata: None,
        expiry_policy: Some(AutomationGateExpiryPolicy {
            expires_after_ms: Some(3_600_000),
            on_expiry: Some(AutomationGateExpiryAction::Cancel),
            escalate_to: None,
            remind_every_ms: Some(60_000),
        }),
    };
    let run_id = insert_awaiting_policy_gate_run(&state, &automation, gate).await;

    assert_eq!(state.process_awaiting_approval_gate_policies().await, 1);
    assert_eq!(state.process_awaiting_approval_gate_policies().await, 0);

    let updated = state
        .get_automation_v2_run(&run_id)
        .await
        .expect("updated run");
    assert_eq!(updated.status, AutomationRunStatus::AwaitingApproval);
    let state_metadata = updated
        .checkpoint
        .awaiting_gate
        .as_ref()
        .and_then(|gate| gate.metadata.as_ref())
        .and_then(|metadata| metadata.get("gate_policy_state"))
        .expect("gate policy state");
    assert_eq!(
        state_metadata
            .get("reminder_count")
            .and_then(Value::as_u64),
        Some(1)
    );
    let notification_key = state_metadata
        .get("notification_key")
        .and_then(Value::as_str)
        .expect("notification key");
    assert!(notification_key.contains(":reminder:1"));

    let approvals = crate::http::approvals::list_pending_approvals(
        &state,
        &tandem_types::ApprovalListFilter::default(),
    )
    .await;
    let approval = approvals
        .iter()
        .find(|approval| approval.run_id == run_id)
        .expect("pending approval");
    assert_eq!(
        approval.expires_at_ms,
        Some(requested_at_ms.saturating_add(3_600_000))
    );
    assert_eq!(
        approval
            .surface_payload
            .as_ref()
            .and_then(|payload| payload.get("notification_key"))
            .and_then(Value::as_str),
        Some(notification_key)
    );
}

#[tokio::test]
async fn approval_gate_escalation_policy_updates_notification_key() {
    let state = ready_test_state().await;
    let automation = approval_policy_test_automation("auto-gate-escalation");
    let requested_at_ms = now_ms().saturating_sub(120_000);
    let gate = AutomationPendingGate {
        node_id: "approval".to_string(),
        title: "Approval".to_string(),
        instructions: None,
        decisions: vec!["approve".to_string(), "cancel".to_string()],
        rework_targets: Vec::new(),
        requested_at_ms,
        upstream_node_ids: Vec::new(),
        metadata: None,
        expiry_policy: Some(AutomationGateExpiryPolicy {
            expires_after_ms: Some(1),
            on_expiry: Some(AutomationGateExpiryAction::Escalate),
            escalate_to: Some("risk-lead".to_string()),
            remind_every_ms: None,
        }),
    };
    let run_id = insert_awaiting_policy_gate_run(&state, &automation, gate).await;

    assert_eq!(state.process_awaiting_approval_gate_policies().await, 1);
    assert_eq!(state.process_awaiting_approval_gate_policies().await, 0);

    let updated = state
        .get_automation_v2_run(&run_id)
        .await
        .expect("updated run");
    assert_eq!(updated.status, AutomationRunStatus::AwaitingApproval);
    assert!(updated
        .checkpoint
        .lifecycle_history
        .iter()
        .any(|entry| entry.event == "approval_gate_escalated"));
    let state_metadata = updated
        .checkpoint
        .awaiting_gate
        .as_ref()
        .and_then(|gate| gate.metadata.as_ref())
        .and_then(|metadata| metadata.get("gate_policy_state"))
        .expect("gate policy state");
    assert_eq!(
        state_metadata.get("escalated_to").and_then(Value::as_str),
        Some("risk-lead")
    );
    assert_eq!(
        state_metadata
            .get("reminder_count")
            .and_then(Value::as_u64),
        Some(1)
    );
    let notification_key = state_metadata
        .get("notification_key")
        .and_then(Value::as_str)
        .expect("notification key");
    assert!(notification_key.contains(":escalated:1"));

    let approvals = crate::http::approvals::list_pending_approvals(
        &state,
        &tandem_types::ApprovalListFilter::default(),
    )
    .await;
    let approval = approvals
        .iter()
        .find(|approval| approval.run_id == run_id)
        .expect("pending approval");
    assert_eq!(
        approval
            .surface_payload
            .as_ref()
            .and_then(|payload| payload.get("notification_key"))
            .and_then(Value::as_str),
        Some(notification_key)
    );
}

#[test]
fn expired_cancel_policy_rejects_late_human_decision() {
    let mut gate = AutomationPendingGate {
        node_id: "approval".to_string(),
        title: "Approval".to_string(),
        instructions: None,
        decisions: vec!["approve".to_string(), "cancel".to_string()],
        rework_targets: Vec::new(),
        requested_at_ms: 10,
        upstream_node_ids: Vec::new(),
        metadata: None,
        expiry_policy: Some(AutomationGateExpiryPolicy {
            expires_after_ms: Some(5),
            on_expiry: Some(AutomationGateExpiryAction::Cancel),
            escalate_to: None,
            remind_every_ms: None,
        }),
    };

    assert!(crate::app::state::automation_gate_rejects_late_human_decision(
        &gate, 15
    ));
    assert!(!crate::app::state::automation_gate_rejects_late_human_decision(
        &gate, 14
    ));

    gate.expiry_policy = Some(AutomationGateExpiryPolicy {
        expires_after_ms: Some(5),
        on_expiry: Some(AutomationGateExpiryAction::Escalate),
        escalate_to: Some("risk-lead".to_string()),
        remind_every_ms: None,
    });
    assert!(!crate::app::state::automation_gate_rejects_late_human_decision(
        &gate, 15
    ));

    gate.expiry_policy = Some(AutomationGateExpiryPolicy {
        expires_after_ms: Some(5),
        on_expiry: Some(AutomationGateExpiryAction::Remind),
        escalate_to: None,
        remind_every_ms: Some(60_000),
    });
    assert!(!crate::app::state::automation_gate_rejects_late_human_decision(
        &gate, 15
    ));
}