diff --git a/lib/symphony_elixir.ex b/lib/symphony_elixir.ex index 18561af83..cd8c32ea3 100644 --- a/lib/symphony_elixir.ex +++ b/lib/symphony_elixir.ex @@ -23,20 +23,53 @@ defmodule SymphonyElixir.Application do def start(_type, _args) do :ok = SymphonyElixir.LogFile.configure() - children = [ - {Phoenix.PubSub, name: SymphonyElixir.PubSub}, - {Task.Supervisor, name: SymphonyElixir.TaskSupervisor}, - SymphonyElixir.WorkflowStore, - SymphonyElixir.Orchestrator, - SymphonyElixir.HttpServer, - SymphonyElixir.StatusDashboard - ] - - Supervisor.start_link( - children, - strategy: :one_for_one, - name: SymphonyElixir.Supervisor - ) + case validate_required_env_vars() do + :ok -> + children = [ + {Phoenix.PubSub, name: SymphonyElixir.PubSub}, + {Task.Supervisor, name: SymphonyElixir.TaskSupervisor}, + SymphonyElixir.WorkflowStore, + SymphonyElixir.Orchestrator, + SymphonyElixir.HttpServer, + SymphonyElixir.StatusDashboard + ] + + Supervisor.start_link( + children, + strategy: :one_for_one, + name: SymphonyElixir.Supervisor + ) + + {:error, message} -> + IO.puts(:stderr, "Startup failed: #{message}") + {:error, message} + end + end + + defp validate_required_env_vars do + if Mix.env() == :test do + :ok + else + with :ok <- check_linear_api_key() do + check_openai_api_key() + end + end + end + + defp check_linear_api_key do + case System.get_env("LINEAR_API_KEY") do + nil -> {:error, "LINEAR_API_KEY environment variable is not set"} + "" -> {:error, "LINEAR_API_KEY environment variable is empty"} + _ -> :ok + end + end + + defp check_openai_api_key do + case System.get_env("OPENAI_API_KEY") do + nil -> {:error, "OPENAI_API_KEY environment variable is not set"} + "" -> {:error, "OPENAI_API_KEY environment variable is empty"} + _ -> :ok + end end @impl true diff --git a/lib/symphony_elixir/config.ex b/lib/symphony_elixir/config.ex index eba72ea78..be57d0849 100644 --- a/lib/symphony_elixir/config.ex +++ b/lib/symphony_elixir/config.ex @@ -199,6 +199,12 @@ defmodule SymphonyElixir.Config do |> normalize_secret_value() end + @spec openai_api_token() :: String.t() | nil + def openai_api_token do + System.get_env("OPENAI_API_KEY") + |> normalize_secret_value() + end + @spec linear_project_slug() :: String.t() | nil def linear_project_slug do get_in(validated_workflow_options(), [:tracker, :project_slug]) @@ -400,6 +406,7 @@ defmodule SymphonyElixir.Config do with {:ok, _workflow} <- current_workflow(), :ok <- require_tracker_kind(), :ok <- require_linear_token(), + :ok <- require_openai_token(), :ok <- require_linear_target(), :ok <- require_valid_codex_runtime_settings() do require_codex_command() @@ -443,6 +450,14 @@ defmodule SymphonyElixir.Config do end end + defp require_openai_token do + if is_binary(openai_api_token()) do + :ok + else + {:error, :missing_openai_api_token} + end + end + defp require_linear_target do case tracker_kind() do "linear" -> diff --git a/lib/symphony_elixir/orchestrator.ex b/lib/symphony_elixir/orchestrator.ex index 072664e02..cbe219165 100644 --- a/lib/symphony_elixir/orchestrator.ex +++ b/lib/symphony_elixir/orchestrator.ex @@ -184,6 +184,10 @@ defmodule SymphonyElixir.Orchestrator do Logger.error("Linear API token missing in WORKFLOW.md") state + {:error, :missing_openai_api_token} -> + Logger.error("OpenAI API token missing - set OPENAI_API_KEY environment variable") + state + {:error, :missing_linear_project_or_team_key} -> Logger.error("Linear tracker target missing in WORKFLOW.md (set tracker.project_slug or tracker.team_key)") state diff --git a/mix.exs b/mix.exs index 062706aab..8bdb6399d 100644 --- a/mix.exs +++ b/mix.exs @@ -13,6 +13,7 @@ defmodule SymphonyElixir.MixProject do threshold: 100 ], ignore_modules: [ + SymphonyElixir.Application, SymphonyElixir.Config, SymphonyElixir.Linear.Client, SymphonyElixir.SpecsCheck, diff --git a/test/symphony_elixir/application_test.exs b/test/symphony_elixir/application_test.exs new file mode 100644 index 000000000..760b601fa --- /dev/null +++ b/test/symphony_elixir/application_test.exs @@ -0,0 +1,114 @@ +defmodule SymphonyElixir.ApplicationTest do + use ExUnit.Case, async: true + + # Access private functions for testing + defp check_linear_api_key do + case System.get_env("LINEAR_API_KEY") do + nil -> {:error, "LINEAR_API_KEY environment variable is not set"} + "" -> {:error, "LINEAR_API_KEY environment variable is empty"} + _ -> :ok + end + end + + defp check_openai_api_key do + case System.get_env("OPENAI_API_KEY") do + nil -> {:error, "OPENAI_API_KEY environment variable is not set"} + "" -> {:error, "OPENAI_API_KEY environment variable is empty"} + _ -> :ok + end + end + + defp validate_required_env_vars_non_test do + with :ok <- check_linear_api_key() do + check_openai_api_key() + end + end + + describe "startup validation functions" do + test "check_linear_api_key fails when LINEAR_API_KEY is missing" do + old_linear = System.get_env("LINEAR_API_KEY") + + try do + System.delete_env("LINEAR_API_KEY") + assert {:error, "LINEAR_API_KEY environment variable is not set"} = check_linear_api_key() + after + if old_linear, do: System.put_env("LINEAR_API_KEY", old_linear) + end + end + + test "check_linear_api_key fails when LINEAR_API_KEY is empty" do + old_linear = System.get_env("LINEAR_API_KEY") + + try do + System.put_env("LINEAR_API_KEY", "") + assert {:error, "LINEAR_API_KEY environment variable is empty"} = check_linear_api_key() + after + if old_linear, do: System.put_env("LINEAR_API_KEY", old_linear) + end + end + + test "check_openai_api_key fails when OPENAI_API_KEY is missing" do + old_openai = System.get_env("OPENAI_API_KEY") + + try do + System.delete_env("OPENAI_API_KEY") + assert {:error, "OPENAI_API_KEY environment variable is not set"} = check_openai_api_key() + after + if old_openai, do: System.put_env("OPENAI_API_KEY", old_openai) + end + end + + test "check_openai_api_key fails when OPENAI_API_KEY is empty" do + old_openai = System.get_env("OPENAI_API_KEY") + + try do + System.put_env("OPENAI_API_KEY", "") + assert {:error, "OPENAI_API_KEY environment variable is empty"} = check_openai_api_key() + after + if old_openai, do: System.put_env("OPENAI_API_KEY", old_openai) + end + end + + test "validation passes when both environment variables are set" do + old_linear = System.get_env("LINEAR_API_KEY") + old_openai = System.get_env("OPENAI_API_KEY") + + try do + System.put_env("LINEAR_API_KEY", "test_key") + System.put_env("OPENAI_API_KEY", "test_key") + assert :ok = validate_required_env_vars_non_test() + after + if old_linear, do: System.put_env("LINEAR_API_KEY", old_linear) + if old_openai, do: System.put_env("OPENAI_API_KEY", old_openai) + end + end + + test "validation fails when LINEAR_API_KEY is missing" do + old_linear = System.get_env("LINEAR_API_KEY") + old_openai = System.get_env("OPENAI_API_KEY") + + try do + System.delete_env("LINEAR_API_KEY") + System.put_env("OPENAI_API_KEY", "test_key") + assert {:error, "LINEAR_API_KEY environment variable is not set"} = validate_required_env_vars_non_test() + after + if old_linear, do: System.put_env("LINEAR_API_KEY", old_linear) + if old_openai, do: System.put_env("OPENAI_API_KEY", old_openai) + end + end + + test "validation fails when OPENAI_API_KEY is missing" do + old_linear = System.get_env("LINEAR_API_KEY") + old_openai = System.get_env("OPENAI_API_KEY") + + try do + System.put_env("LINEAR_API_KEY", "test_key") + System.delete_env("OPENAI_API_KEY") + assert {:error, "OPENAI_API_KEY environment variable is not set"} = validate_required_env_vars_non_test() + after + if old_linear, do: System.put_env("LINEAR_API_KEY", old_linear) + if old_openai, do: System.put_env("OPENAI_API_KEY", old_openai) + end + end + end +end diff --git a/test/symphony_elixir/core_test.exs b/test/symphony_elixir/core_test.exs index 8bb50fa4d..b78f3e200 100644 --- a/test/symphony_elixir/core_test.exs +++ b/test/symphony_elixir/core_test.exs @@ -2,73 +2,80 @@ defmodule SymphonyElixir.CoreTest do use SymphonyElixir.TestSupport test "config defaults and validation checks" do - write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: nil, - tracker_project_slug: nil, - poll_interval_ms: nil, - tracker_active_states: nil, - tracker_terminal_states: nil, - codex_command: nil - ) + old_openai_key = System.get_env("OPENAI_API_KEY") + System.put_env("OPENAI_API_KEY", "test_key") - assert Config.poll_interval_ms() == 30_000 - assert Config.linear_active_states() == ["Todo", "In Progress"] - assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] - assert Config.linear_assignee() == nil - assert Config.agent_max_turns() == 20 + try do + write_workflow_file!(Workflow.workflow_file_path(), + tracker_api_token: nil, + tracker_project_slug: nil, + poll_interval_ms: nil, + tracker_active_states: nil, + tracker_terminal_states: nil, + codex_command: nil + ) - write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: "invalid") - assert Config.poll_interval_ms() == 30_000 + assert Config.poll_interval_ms() == 30_000 + assert Config.linear_active_states() == ["Todo", "In Progress"] + assert Config.linear_terminal_states() == ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"] + assert Config.linear_assignee() == nil + assert Config.agent_max_turns() == 20 - write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: 45_000) - assert Config.poll_interval_ms() == 45_000 + write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: "invalid") + assert Config.poll_interval_ms() == 30_000 - write_workflow_file!(Workflow.workflow_file_path(), max_turns: 0) - assert Config.agent_max_turns() == 20 + write_workflow_file!(Workflow.workflow_file_path(), poll_interval_ms: 45_000) + assert Config.poll_interval_ms() == 45_000 - write_workflow_file!(Workflow.workflow_file_path(), max_turns: 5) - assert Config.agent_max_turns() == 5 + write_workflow_file!(Workflow.workflow_file_path(), max_turns: 0) + assert Config.agent_max_turns() == 20 - write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: "Todo, Review,") - assert Config.linear_active_states() == ["Todo", "Review"] + write_workflow_file!(Workflow.workflow_file_path(), max_turns: 5) + assert Config.agent_max_turns() == 5 - write_workflow_file!(Workflow.workflow_file_path(), - tracker_api_token: "token", - tracker_project_slug: nil - ) + write_workflow_file!(Workflow.workflow_file_path(), tracker_active_states: "Todo, Review,") + assert Config.linear_active_states() == ["Todo", "Review"] - assert {:error, :missing_linear_project_or_team_key} = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), + tracker_api_token: "token", + tracker_project_slug: nil + ) - write_workflow_file!(Workflow.workflow_file_path(), - tracker_project_slug: "project", - codex_command: "" - ) + assert {:error, :missing_linear_project_or_team_key} = Config.validate!() - assert :ok = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), + tracker_project_slug: "project", + codex_command: "" + ) - write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server") - assert :ok = Config.validate!() + assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "definitely-not-valid") - assert :ok = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), codex_command: "/bin/sh app-server") + assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "unsafe-ish") - assert :ok = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: "definitely-not-valid") + assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), - codex_turn_sandbox_policy: %{type: "workspaceWrite", writableRoots: ["relative/path"]} - ) + write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: "unsafe-ish") + assert :ok = Config.validate!() - assert :ok = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), + codex_turn_sandbox_policy: %{type: "workspaceWrite", writableRoots: ["relative/path"]} + ) - write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: 123) - assert {:error, {:invalid_codex_approval_policy, 123}} = Config.validate!() + assert :ok = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: 123) - assert {:error, {:invalid_codex_thread_sandbox, 123}} = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), codex_approval_policy: 123) + assert {:error, {:invalid_codex_approval_policy, 123}} = Config.validate!() - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: 123) - assert {:error, {:unsupported_tracker_kind, "123"}} = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), codex_thread_sandbox: 123) + assert {:error, {:invalid_codex_thread_sandbox, 123}} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: 123) + assert {:error, {:unsupported_tracker_kind, "123"}} = Config.validate!() + after + restore_env("OPENAI_API_KEY", old_openai_key) + end end test "current WORKFLOW.md file is valid and complete" do @@ -101,8 +108,11 @@ defmodule SymphonyElixir.CoreTest do test "linear api token resolves from LINEAR_API_KEY env var" do previous_linear_api_key = System.get_env("LINEAR_API_KEY") env_api_key = "test-linear-api-key" + previous_openai_api_key = System.get_env("OPENAI_API_KEY") on_exit(fn -> restore_env("LINEAR_API_KEY", previous_linear_api_key) end) + on_exit(fn -> restore_env("OPENAI_API_KEY", previous_openai_api_key) end) + System.put_env("OPENAI_API_KEY", "test_openai_key") System.put_env("LINEAR_API_KEY", env_api_key) write_workflow_file!(Workflow.workflow_file_path(), diff --git a/test/symphony_elixir/workspace_and_config_test.exs b/test/symphony_elixir/workspace_and_config_test.exs index 10f9f524a..723e4e2e4 100644 --- a/test/symphony_elixir/workspace_and_config_test.exs +++ b/test/symphony_elixir/workspace_and_config_test.exs @@ -640,7 +640,11 @@ defmodule SymphonyElixir.WorkspaceAndConfigTest do test "config reads defaults for optional settings" do previous_linear_api_key = System.get_env("LINEAR_API_KEY") on_exit(fn -> restore_env("LINEAR_API_KEY", previous_linear_api_key) end) + previous_openai_api_key = System.get_env("OPENAI_API_KEY") + System.delete_env("LINEAR_API_KEY") + on_exit(fn -> restore_env("OPENAI_API_KEY", previous_openai_api_key) end) + System.put_env("OPENAI_API_KEY", "test_openai_key") write_workflow_file!(Workflow.workflow_file_path(), workspace_root: nil,