How Symphony Manages Linear Issue State Transitions: A Deep Dive into the GraphQL Adapter
Symphony orchestrates Linear issue state transitions through a dedicated GraphQL adapter that resolves target state IDs via team-specific queries and executes atomic issueUpdate mutations, returning :ok on success or tagged error tuples on failure.
The openai/symphony repository treats Linear as a first-class issue tracker, enabling automated workflow progression through Elixir's SymphonyElixir.Tracker behaviour. When the orchestrator determines that a Linear issue must advance to a different workflow state—such as when a Codex run completes or a worker reports failure—it delegates the transition to the Linear adapter, a thin GraphQL wrapper that handles ID resolution and mutation execution.
The Five-Step Transition Architecture
The transition flow moves through distinct architectural layers, ensuring separation between orchestration logic and external API concerns.
-
Orchestrator Initiation – The orchestrator calls
SymphonyElixir.Tracker.update_issue_state/2with the Linear issue ID and desired state name (as defined inWORKFLOW.md). -
Tracker Delegation – The generic
Trackermodule forwards the call to the active tracker implementation. -
State ID Resolution – For Linear projects,
SymphonyElixir.Linear.Adapter.update_issue_state/2executes@state_lookup_queryto search the issue's team for a matching state name, returning the internalstateId. -
Mutation Execution – The adapter fires
@update_state_mutation(theissueUpdateGraphQL mutation) with the resolved state ID. -
Result Handling – The function returns
:okif the mutation reports"success": true, otherwise{:error, :issue_update_failed}.
All GraphQL interactions flow through Linear.Client.graphql/3, which builds payloads, injects the Authorization header from WORKFLOW.md configuration, and executes via Req.post/2.
Resolving State IDs from Workflow Names
Unlike hardcoded GraphQL IDs, Symphony uses human-readable state names configured in WORKFLOW.md. The adapter must map these names to Linear's internal identifiers before mutating the issue.
When SymphonyElixir.Linear.Adapter.update_issue_state/2 receives a transition request, it first calls an internal resolve_state_id/2 helper. This function executes the module's @state_lookup_query, which searches the specific team's workflow states for a name match:
# Internal resolution - performed automatically by the adapter
{:ok, state_id} = SymphonyElixir.Linear.Adapter.resolve_state_id(issue_id, "In Review")
# state_id now contains the GraphQL ID required by Linear's API
This lookup is defined in elixir/lib/symphony_elixir/linear/adapter.ex (lines 26-38), ensuring that workflow changes in Linear don't require code redeployments—only configuration updates.
Executing the State Change Mutation
With the resolved stateId, the adapter performs the actual transition using the @update_state_mutation constant. The mutation follows Linear's GraphQL schema, accepting issueId and stateId parameters:
mutation = """
mutation SymphonyUpdateIssueState($issueId: String!, $stateId: String!) {
issueUpdate(id: $issueId, input: {stateId: $stateId}) {
success
}
}
"""
The adapter calls SymphonyElixir.Linear.Client.graphql/3 to send this mutation, passing the issueId and resolved stateId as variables. Error handling distinguishes between network failures, GraphQL errors, and logical failures (like invalid state IDs), logging the HTTP status and truncated response body for debugging.
Practical Implementation Examples
Updating State from a Worker Process
After a successful Codex run, workers can trigger state transitions through the high-level Tracker API:
# Move issue to "Done" after completion
case SymphonyElixir.Tracker.update_issue_state(issue_id, "Done") do
:ok ->
IO.puts("Issue #{issue_id} successfully moved to Done")
{:error, reason} ->
IO.puts("Failed to update issue state: #{inspect(reason)}")
end
This call chains through Tracker to Linear.Adapter.update_issue_state/2 as implemented in elixir/lib/symphony_elixir/linear/adapter.ex (lines 61-74).
Direct GraphQL Debugging
For troubleshooting or custom scripts, bypass the adapter and use the client directly:
mutation = """
mutation SymphonyUpdateIssueState($issueId: String!, $stateId: String!) {
issueUpdate(id: $issueId, input: {stateId: $stateId}) {
success
}
}
"""
{:ok, resp} = SymphonyElixir.Linear.Client.graphql(mutation, %{
"issueId" => issue_id,
"stateId" => state_id
})
IO.inspect(resp)
This matches the @update_state_mutation used internally by the adapter (lines 18-24 in the adapter source).
Configuration and Authentication
The Linear adapter relies on credentials and workflow definitions stored in elixir/WORKFLOW.md. The Linear.Client module extracts the API key from this configuration to populate the Authorization header for all requests. This centralized config determines which states are considered active or terminal, driving the orchestrator's transition decisions.
Summary
- Symphony manages Linear issue state transitions through a behavior-based architecture separating orchestration from API implementation.
- State name resolution occurs via GraphQL queries against team-specific workflows in
elixir/lib/symphony_elixir/linear/adapter.ex, allowing configuration-driven state management without code changes. - The Linear adapter implements the tracker contract through
update_issue_state/2, handling both ID lookup and mutation execution before returning explicit:okor{:error, reason}tuples. - Authentication and logging are handled centrally in
elixir/lib/symphony_elixir/linear/client.ex, which injects API keys fromWORKFLOW.mdand captures truncated error responses for debugging.
Frequently Asked Questions
What happens if the requested state name doesn't exist in Linear?
The resolve_state_id/2 function fails to find a matching state in the team's workflow, causing update_issue_state/2 to return {:error, :issue_update_failed} before executing any mutation. The orchestrator can catch this error and handle the misconfiguration appropriately without corrupting the Linear issue state.
How does Symphony authenticate with the Linear API?
Authentication occurs through the Linear.Client.graphql/3 helper, which retrieves the API key from WORKFLOW.md configuration and injects it into the Authorization header of every HTTP request sent via Req.post/2 to Linear's GraphQL endpoint.
Can developers use the Linear adapter outside of the orchestrator?
Yes. While the orchestrator calls SymphonyElixir.Tracker.update_issue_state/2, developers can interact directly with SymphonyElixir.Linear.Adapter.update_issue_state/2 or the lower-level Linear.Client.graphql/3 for custom scripts and debugging, as shown in the direct GraphQL debugging example above.
What information is logged when a state transition fails?
The system logs the HTTP status code and a truncated version of the response body when GraphQL mutations fail, enabling rapid debugging of authentication issues, invalid state IDs, or Linear API outages without exposing full response payloads in production logs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →