opensymphony 1.8.0

A Rust implementation of the OpenAI Symphony orchestration design
Documentation
use std::{process::Stdio, time::Duration};

use crate::opensymphony_testkit::FakeOpenHandsServer;
use axum::{Json, Router, routing::post};
use serde_json::json;
use tempfile::TempDir;
use tokio::{
    net::TcpListener,
    process::{Child, Command},
    task::JoinHandle,
    time::{Instant, sleep},
};

#[tokio::test]
async fn run_auto_detects_config_and_workflow_from_project_directory() {
    let openhands = FakeOpenHandsServer::start()
        .await
        .expect("fake OpenHands server should start");
    let linear = MockLinearGraphqlServer::start().await;
    let project = TempDir::new().expect("temp project should exist");
    let bind_addr = reserve_socket_addr();

    write_project_files(
        project.path(),
        linear.base_url(),
        openhands.base_url(),
        format!("control_plane:\n  bind: {bind_addr}\n"),
    );
    write_memory_config(project.path());

    let mut child = spawn_run_child(project.path(), &[]);

    wait_for_health(&format!("http://{bind_addr}/healthz"))
        .await
        .expect("run command should become healthy from the project directory");

    terminate_child(&mut child).await;
}

#[tokio::test]
async fn run_config_flag_overrides_auto_detected_config_file() {
    let openhands = FakeOpenHandsServer::start()
        .await
        .expect("fake OpenHands server should start");
    let linear = MockLinearGraphqlServer::start().await;
    let project = TempDir::new().expect("temp project should exist");
    let default_bind = reserve_socket_addr();
    let override_bind = reserve_socket_addr();

    write_project_files(
        project.path(),
        linear.base_url(),
        openhands.base_url(),
        format!("control_plane:\n  bind: {default_bind}\n"),
    );
    write_memory_config(project.path());
    std::fs::write(
        project.path().join("override.yaml"),
        format!("control_plane:\n  bind: {override_bind}\n"),
    )
    .expect("override config should be written");

    let mut child = spawn_run_child(project.path(), &["--config", "override.yaml"]);

    wait_for_health(&format!("http://{override_bind}/healthz"))
        .await
        .expect("explicit --config should control the bind address");
    assert!(
        !health_endpoint_ready(&format!("http://{default_bind}/healthz")).await,
        "default auto-detected config should not be used when --config is passed",
    );

    terminate_child(&mut child).await;
}

#[tokio::test]
async fn run_accepts_existing_repo_config_shape_with_extra_doctor_fields() {
    let openhands = FakeOpenHandsServer::start()
        .await
        .expect("fake OpenHands server should start");
    let linear = MockLinearGraphqlServer::start().await;
    let project = TempDir::new().expect("temp project should exist");
    let bind_addr = reserve_socket_addr();

    write_project_files(
        project.path(),
        linear.base_url(),
        openhands.base_url(),
        format!(
            "target_repo: .\ncontrol_plane:\n  bind: {bind_addr}\nopenhands:\n  probe_model: fake-model\n  probe_api_key_env: FAKE_API_KEY\nlinear:\n  enabled: false\n"
        ),
    );
    write_memory_config(project.path());

    let mut child = spawn_run_child(project.path(), &[]);

    wait_for_health(&format!("http://{bind_addr}/healthz"))
        .await
        .expect("run command should ignore doctor-only config fields");

    terminate_child(&mut child).await;
}

#[test]
fn run_fails_with_install_guidance_when_managed_local_tooling_is_missing() {
    let project = TempDir::new().expect("temp project should exist");
    let bind_addr = reserve_socket_addr();
    std::fs::write(
        project.path().join("WORKFLOW.md"),
        r#"---
tracker:
  kind: linear
  endpoint: http://127.0.0.1:9/graphql
  project_slug: test-project
  active_states:
    - In Progress
  terminal_states:
    - Done
workspace:
  root: ./var/workspaces
openhands:
  transport:
    base_url: http://127.0.0.1:8000
---

# Test Workflow

Run the scheduler.
"#,
    )
    .expect("workflow should be written");
    std::fs::write(
        project.path().join("config.yaml"),
        format!(
            "control_plane:\n  bind: {bind_addr}\nopenhands:\n  tool_dir: ./managed/openhands-server\nlinear:\n  enabled: false\n"
        ),
    )
    .expect("config should be written");
    write_memory_config(project.path());

    let output = std::process::Command::new(env!("CARGO_BIN_EXE_opensymphony"))
        .arg("run")
        .current_dir(project.path())
        .env("LINEAR_API_KEY", "test-linear-key")
        .output()
        .expect("run command should run");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        !output.status.success(),
        "run should fail when managed-local tooling is missing: stdout={stdout}, stderr={stderr}",
    );
    assert!(
        stderr.contains("opensymphony install openhands")
            && stderr.contains("opensymphony doctor --config <path>"),
        "run should explain how to provision the managed-local tooling: stderr={stderr}",
    );
}

fn spawn_run_child(project_root: &std::path::Path, extra_args: &[&str]) -> Child {
    let mut command = Command::new(env!("CARGO_BIN_EXE_opensymphony"));
    command
        .arg("run")
        .args(extra_args)
        .current_dir(project_root)
        .env("LINEAR_API_KEY", "test-linear-key")
        .env("OPENHANDS_API_KEY", "test-openhands-key")
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .kill_on_drop(true);
    command.spawn().expect("run command should spawn")
}

fn write_project_files(
    project_root: &std::path::Path,
    linear_base_url: &str,
    openhands_base_url: &str,
    config_contents: String,
) {
    std::fs::write(
        project_root.join("WORKFLOW.md"),
        format!(
            "---\ntracker:\n  kind: linear\n  endpoint: {linear_base_url}\n  project_slug: test-project\n  active_states:\n    - In Progress\n  terminal_states:\n    - Done\nworkspace:\n  root: ./var/workspaces\nopenhands:\n  transport:\n    base_url: {openhands_base_url}\n    session_api_key_env: OPENHANDS_API_KEY\n---\n\n# Test Workflow\n\nRun the scheduler.\n"
        ),
    )
    .expect("workflow should be written");
    std::fs::write(project_root.join("config.yaml"), config_contents)
        .expect("config should be written");
}

fn write_memory_config(project_root: &std::path::Path) {
    let memory_dir = project_root.join(".opensymphony/memory");
    std::fs::create_dir_all(&memory_dir).expect("memory dir should be written");
    std::fs::write(memory_dir.join("memory.yaml"), "areas: {}\n")
        .expect("memory config should be written");
}

fn reserve_socket_addr() -> std::net::SocketAddr {
    let listener =
        std::net::TcpListener::bind("127.0.0.1:0").expect("temporary listener should bind");
    let address = listener
        .local_addr()
        .expect("temporary listener should expose its address");
    drop(listener);
    address
}

async fn wait_for_health(url: &str) -> Result<(), String> {
    let deadline = Instant::now() + Duration::from_secs(5);
    while Instant::now() < deadline {
        if health_endpoint_ready(url).await {
            return Ok(());
        }
        sleep(Duration::from_millis(50)).await;
    }
    Err(format!("timed out waiting for {url}"))
}

async fn health_endpoint_ready(url: &str) -> bool {
    match reqwest::Client::new().get(url).send().await {
        Ok(response) => response.status().is_success(),
        Err(_) => false,
    }
}

async fn terminate_child(child: &mut Child) {
    let _ = child.kill().await;
    let _ = child.wait().await;
}

struct MockLinearGraphqlServer {
    base_url: String,
    task: JoinHandle<()>,
}

impl MockLinearGraphqlServer {
    async fn start() -> Self {
        let app = Router::new().route("/graphql", post(handle_graphql));
        let listener = TcpListener::bind("127.0.0.1:0")
            .await
            .expect("mock Linear listener should bind");
        let address = listener
            .local_addr()
            .expect("mock Linear listener should expose an address");
        let task = tokio::spawn(async move {
            axum::serve(listener, app)
                .await
                .expect("mock Linear server should run");
        });

        Self {
            base_url: format!("http://{address}/graphql"),
            task,
        }
    }

    fn base_url(&self) -> &str {
        &self.base_url
    }
}

impl Drop for MockLinearGraphqlServer {
    fn drop(&mut self) {
        self.task.abort();
    }
}

async fn handle_graphql() -> Json<serde_json::Value> {
    Json(json!({
        "data": {
            "issues": {
                "nodes": [],
                "pageInfo": {
                    "hasNextPage": false,
                    "endCursor": null
                }
            }
        }
    }))
}