Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 47 additions & 14 deletions lib/symphony_elixir.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/symphony_elixir/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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" ->
Expand Down
4 changes: 4 additions & 0 deletions lib/symphony_elixir/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule SymphonyElixir.MixProject do
threshold: 100
],
ignore_modules: [
SymphonyElixir.Application,
SymphonyElixir.Config,
SymphonyElixir.Linear.Client,
SymphonyElixir.SpecsCheck,
Expand Down
114 changes: 114 additions & 0 deletions test/symphony_elixir/application_test.exs
Original file line number Diff line number Diff line change
@@ -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
108 changes: 59 additions & 49 deletions test/symphony_elixir/core_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions test/symphony_elixir/workspace_and_config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading