Packages
oban_codex
0.1.0
Run Codex jobs on an Oban queue, with validated args, result helpers, telemetry, and an optional long-lived agent lifecycle.
Current section
Files
Jump to
Current section
Files
lib/oban_codex.ex
defmodule ObanCodex do
@moduledoc """
Run Codex CLI work on an Oban queue.
`oban_codex` is the thin seam between `codex_wrapper` and Oban:
* `run/2` accepts the string-keyed map Oban stores, executes a JSONL Codex
turn, and maps the outcome to an Oban return value.
* `ObanCodex.Worker` supplies the drop-in worker and two result hooks.
* `ObanCodex.Args` validates native Elixir options before enqueue.
* result helpers (`text/1`, `structured/1`, `session_id/1`, `usage/1`) hide
Codex's JSONL event representation.
The underlying successful payload stays a `%CodexWrapper.Result{}`. That
keeps the seam honest while the helpers offer the ergonomic fields available
directly in `oban_claude`.
## Example
defmodule MyApp.CodexJob do
use ObanCodex.Worker, queue: :codex, max_attempts: 3
end
ObanCodex.Args.new(
prompt: "summarize the repository",
working_dir: "/path/to/repo",
sandbox: :read_only
)
|> MyApp.CodexJob.new()
|> Oban.insert()
## Telemetry
Every run emits `[:oban_codex, :run, :start]`, followed by either
`[:oban_codex, :run, :stop]` when the CLI produced a result (including a
non-zero exit), or `[:oban_codex, :run, :exception]` when execution failed
before a result was available.
Measurements include `:duration` in native time units and `:cost_usd`, which
is always `0.0` because Codex's JSON event contract doesn't report price.
Metadata includes the stored args and a slim job identity map.
> #### Data handling {: .warning}
>
> Prompts, metadata, JSONL output, and CLI error text can ride on telemetry
> events. Redact them before exporting telemetry to a log aggregator.
"""
alias CodexWrapper.{JsonLineEvent, Result}
alias ObanCodex.Error
@typedoc """
An `c:Oban.Worker.perform/1` return. `:snooze` accepts Oban's period form.
"""
@type oban_return ::
:ok | {:ok, term()} | {:error, term()} | {:cancel, term()} | {:snooze, Oban.Period.t()}
@typedoc "The result of the Codex entrypoint."
@type wrapper_outcome :: {:ok, Result.t()} | {:error, Error.t() | term()}
@typedoc "Maps a wrapper outcome to the `{oban_return, payload}` envelope."
@type classifier :: (wrapper_outcome() -> {oban_return(), term()})
@typedoc "The injectable Codex entrypoint: `(prompt, query_opts) -> outcome`."
@type query_fun :: (String.t(), keyword() -> wrapper_outcome())
@passthrough ~w(model profile sandbox approval_policy full_auto
dangerously_bypass_approvals_and_sandbox
dangerously_bypass_hook_trust working_dir timeout verbose binary
add_dir search ephemeral output_schema output_last_message images
config_overrides enabled_features disabled_features strict_config
ignore_user_config ignore_rules color oss local_provider session_id)
@passthrough_atoms Map.new(@passthrough, &{&1, String.to_atom(&1)})
@enum_values %{
"sandbox" =>
Map.new(~w(read_only workspace_write danger_full_access), &{&1, String.to_atom(&1)}),
"approval_policy" => Map.new(~w(untrusted on_request never), &{&1, String.to_atom(&1)}),
"search" => Map.new(~w(cached indexed live disabled), &{&1, String.to_atom(&1)}),
"color" => Map.new(~w(always never auto), &{&1, String.to_atom(&1)})
}
@doc """
Run one Codex turn from a string-keyed args map.
The return is `{oban_return, payload}`: callers get both the Oban verdict and
the underlying `%CodexWrapper.Result{}` or `%ObanCodex.Error{}`.
Options:
* `:classifier` — maps the query outcome to the envelope; defaults to
`ObanCodex.Outcome.classify/1`.
* `:query_fun` — injectable `(prompt, opts)` entrypoint; defaults to
`ObanCodex.Query.run/2`.
* `:job` — the current `%Oban.Job{}` for telemetry attribution.
"""
@spec run(
ObanCodex.Args.t(),
[{:classifier, classifier()} | {:query_fun, query_fun()} | {:job, Oban.Job.t()}]
) :: {oban_return(), Result.t() | Error.t() | term()}
def run(args, opts \\ []) when is_map(args) do
classifier = Keyword.get(opts, :classifier, &ObanCodex.Outcome.classify/1)
query_fun = Keyword.get(opts, :query_fun, &ObanCodex.Query.run/2)
job = Keyword.get(opts, :job)
{prompt, query_opts} = build(args)
start = System.monotonic_time()
emit_start(args, job)
outcome = query_fun.(prompt, query_opts)
emit(outcome, start, args, job)
outcome
|> classifier.()
|> validate_classified!(classifier)
end
@doc "Parse the Codex JSONL stdout into event structs."
@spec events(Result.t()) :: [JsonLineEvent.t()]
def events(%Result{stdout: stdout}) when is_binary(stdout),
do: JsonLineEvent.parse_lines(stdout)
@doc """
Return the final agent message text, or `nil` when no message was emitted.
Query runs always force JSONL. For a custom query function that returns plain
stdout, a non-empty stdout string is used as a compatibility fallback.
"""
@spec text(Result.t()) :: String.t() | nil
def text(%Result{} = result) do
case final_agent_text(events(result)) do
nil -> fallback_text(result.stdout)
value -> value
end
end
@doc """
Decode the final agent message as a JSON object or array.
Codex's `--output-schema` validates the final message but still emits it as
text inside an `item.completed` event. Scalar JSON values intentionally
return `nil`; structured worker contracts should use an object or array.
"""
@spec structured(Result.t()) :: map() | list() | nil
def structured(%Result{} = result) do
with value when is_binary(value) <- text(result),
{:ok, decoded} when is_map(decoded) or is_list(decoded) <- Jason.decode(value) do
decoded
else
_ -> nil
end
end
@doc "Read the conventional top-level `\"outcome\"` string from structured output."
@spec outcome(Result.t()) :: String.t() | nil
def outcome(%Result{} = result) do
case structured(result) do
%{"outcome" => value} when is_binary(value) -> value
_ -> nil
end
end
@doc """
Return the Codex thread/session id carried by a result or normalized error.
Current Codex versions emit `thread_id`; `session_id` remains a defensive
fallback for older versions and forks.
"""
@spec session_id(Result.t() | Error.t() | term()) :: String.t() | nil
def session_id(%Result{} = result) do
Enum.find_value(events(result), fn event ->
JsonLineEvent.get(event, "thread_id") || JsonLineEvent.get(event, "session_id")
end)
end
def session_id(%Error{reason: reason}) when is_map(reason),
do: reason[:session_id] || reason["session_id"]
def session_id(_), do: nil
@doc """
Return token usage from the final `turn.completed` event, or `nil`.
"""
@spec usage(Result.t()) :: map() | nil
def usage(%Result{} = result) do
result
|> events()
|> Enum.reverse()
|> Enum.find_value(fn
%JsonLineEvent{event_type: "turn.completed", data: %{"usage" => usage}}
when is_map(usage) ->
usage
_ ->
nil
end)
end
@doc """
Return reported cost in USD.
Codex doesn't currently include price in its JSONL contract, so a normal
result returns `nil`. The error-map clause supports custom query functions.
"""
@spec cost_usd(Result.t() | Error.t() | term()) :: number() | nil
def cost_usd(%Result{}), do: nil
def cost_usd(%Error{reason: reason}) when is_map(reason),
do: reason[:cost_usd] || reason["cost_usd"]
def cost_usd(_), do: nil
defp build(args) do
prompt = Map.fetch!(args, "prompt")
if Map.has_key?(args, "resume") and Map.has_key?(args, "session_id") do
raise ArgumentError,
"set only one of resume or session_id; both identify the session to resume"
end
if (args["resume"] || args["session_id"]) && args["ephemeral"] do
raise ArgumentError, "ephemeral sessions cannot be resumed"
end
args =
case args["resume"] do
nil -> args
session_id -> Map.put(args, "session_id", session_id)
end
query_opts =
for key <- @passthrough, Map.has_key?(args, key) do
{Map.fetch!(@passthrough_atoms, key), coerce(key, args[key])}
end
{prompt, query_opts}
end
defp coerce(key, value) when is_binary(value) and is_map_key(@enum_values, key) do
case get_in(@enum_values, [key, value]) do
nil ->
expected = @enum_values |> Map.fetch!(key) |> Map.keys() |> Enum.sort()
raise ArgumentError,
"unknown #{key} #{inspect(value)}; expected one of #{inspect(expected)}"
atom ->
atom
end
end
defp coerce(_key, value), do: value
defp final_agent_text(events) do
events
|> Enum.reverse()
|> Enum.find_value(fn
%JsonLineEvent{
event_type: "item.completed",
data: %{"item" => %{"type" => "agent_message"} = item}
} ->
item["text"] || item["content"]
_ ->
nil
end)
end
defp fallback_text(stdout) when is_binary(stdout) do
case String.trim(stdout) do
"" -> nil
value -> value
end
end
defp validate_classified!(classified, classifier) do
if valid_return?(classified) do
classified
else
raise ArgumentError, """
classifier #{inspect(classifier)} returned #{inspect(classified)}, which \
is not the required {oban_return, payload} envelope.
The first element must be :ok, {:ok, term}, {:error, term}, \
{:cancel, term}, or {:snooze, period}. A flat {:cancel, reason} is a \
verdict, not the envelope; return {{:cancel, reason}, payload}.
"""
end
end
defp valid_return?({:ok, _payload}), do: true
defp valid_return?({{:ok, _}, _payload}), do: true
defp valid_return?({{:error, _}, _payload}), do: true
defp valid_return?({{:cancel, _}, _payload}), do: true
defp valid_return?({{:snooze, n}, _payload}) when is_integer(n) and n > 0, do: true
defp valid_return?({{:snooze, {n, _unit}}, _payload}) when is_integer(n) and n > 0, do: true
defp valid_return?(_other), do: false
defp emit_start(args, job) do
:telemetry.execute(
[:oban_codex, :run, :start],
%{system_time: System.system_time()},
%{args: args, job: job_meta(job)}
)
end
defp emit({:ok, %Result{} = result}, start, args, job) do
:telemetry.execute(
[:oban_codex, :run, :stop],
%{duration: System.monotonic_time() - start, cost_usd: 0.0},
%{result: result, args: args, job: job_meta(job)}
)
end
defp emit({:error, error}, start, args, job) do
:telemetry.execute(
[:oban_codex, :run, :exception],
%{duration: System.monotonic_time() - start, cost_usd: cost_usd(error) || 0.0},
%{error: error, args: args, job: job_meta(job)}
)
end
defp emit(_off_contract, _start, _args, _job), do: :ok
defp job_meta(%Oban.Job{} = job) do
Map.take(job, [:id, :queue, :worker, :attempt, :max_attempts, :meta])
end
defp job_meta(_), do: nil
end