defmodule OpenAgents.Forge.BuildExecutor do
@moduledoc """
Behaviour for the isolated forge build lane.
A successful executor returns a complete, independently verified artifact;
it does not return an unbound list of tar entries. Production uses the
versioned JSON file protocol in `OpenAgents.Forge.BuildExecutor.Sidecar`.
"""
@typedoc "One verified normalized BEAM, still named by a string."
@type beam :: %{module: String.t(), binary: binary()}
@typedoc "A successful build and its content-addressed artifact."
@type build_result :: %{
artifact_bytes: binary(),
artifact_digest: String.t(),
manifest: map(),
beams: [beam()],
warnings: String.t(),
tests: String.t() | nil,
duration_ms: non_neg_integer(),
output_digest: String.t() | nil,
output_ref: String.t() | nil
}
@type build_error ::
String.t() | %{required(:code) => String.t(), required(:output) => String.t()}
@callback build(repo :: String.t(), sha :: String.t(), opts :: keyword()) ::
{:ok, build_result()} | {:error, build_error()}
@max_output_bytes 8_192
@doc "Bound and redact compiler, test, or git output before persistence."
@spec bound_output(String.t(), pos_integer()) :: String.t()
def bound_output(output, max_bytes \\ @max_output_bytes) when is_binary(output) do
output = OpenAgents.LogSafety.redact(output)
if byte_size(output) <= max_bytes do
output
else
binary_part(output, 0, max_bytes) <> "\n[truncated]"
end
end
end
defmodule OpenAgents.Forge.BuildExecutor.Sidecar do
@moduledoc """
Production adapter for the isolated forge builder.
The serving release atomically writes a strictly validated
`requests/<build-id>.json`. The sidecar claims it by rename and atomically
writes `responses/<build-id>.json`; artifacts are immutable
`artifacts/<sha256>.tar` objects. Credentials belong only to the builder's
mounted askpass helper or workload identity and never appear in JSON, a URL,
argv output, or the serving release.
"""
@behaviour OpenAgents.Forge.BuildExecutor
import OpenAgents.Forge.BuildExecutor, only: [bound_output: 1]
alias OpenAgents.Forge.BuildArtifact
alias OpenAgents.Forge.BuildProtocol
@default_timeout_ms 300_000
@poll_interval_ms 250
@impl true
def build(repo, sha, opts) do
build_id = Keyword.fetch!(opts, :build_id)
target_id = Keyword.fetch!(opts, :target_id)
timeout_ms =
Keyword.get(
opts,
:timeout_ms,
Application.get_env(:openagents, :forge_build_timeout_ms, @default_timeout_ms)
)
baseline_manifest = Keyword.get(opts, :baseline_manifest)
queue = queue_dir()
request_path = request_path(queue, build_id)
response_path = response_path(queue, build_id)
expires_at =
DateTime.utc_now()
|> DateTime.add(timeout_ms, :millisecond)
|> DateTime.to_iso8601()
request =
BuildProtocol.request!(%{
build_id: build_id,
repo: repo,
source_sha: sha,
target_id: target_id,
repo_url: repo_url(repo),
baseline_manifest: baseline_manifest,
expires_at: expires_at
})
with {:ok, encoded} <- BuildProtocol.encode_request(request),
:ok <- File.mkdir_p(Path.dirname(response_path)),
:ok <- BuildProtocol.atomic_write(request_path, encoded, mode: 0o640) do
await_response(request, response_path, timeout_ms, timeout_ms)
else
{:error, reason} ->
{:error, %{code: "request_write_failed", output: bound_output(inspect(reason))}}
end
rescue
error ->
{:error,
%{
code: "request_invalid",
output: bound_output("build request invalid: " <> Exception.message(error))
}}
end
@doc "Repository URL with no embedded credential, query, or fragment."
def repo_url(repo) do
base =
Application.get_env(
:openagents,
:forge_internal_git_url,
"http://127.0.0.1:8080/OpenAgentsInc"
)
URI.to_string(URI.parse(base)) <> "/" <> repo <> ".git"
end
@doc "Queue root used by both the serving adapter and builder."
def queue_dir do
Application.get_env(
:openagents,
:forge_build_queue_dir,
"/var/lib/openagents/workspace/build-queue"
)
end
@doc "Builder-owned immutable artifact and output root."
def artifact_dir do
Application.get_env(:openagents, :forge_artifact_dir, "/var/lib/openagents/artifacts")
end
@doc false
def request_path(queue, build_id), do: Path.join([queue, "requests", build_id <> ".json"])
@doc false
def response_path(queue, build_id), do: Path.join([queue, "responses", build_id <> ".json"])
defp await_response(request, _response_path, remaining_ms, timeout_ms) when remaining_ms <= 0 do
File.rm(request_path(queue_dir(), request["build_id"]))
{:error,
%{
code: "build_timeout",
output: "build #{request["build_id"]} timed out after #{timeout_ms}ms"
}}
end
defp await_response(request, response_path, remaining_ms, timeout_ms) do
case File.read(response_path) do
{:ok, bytes} ->
File.rm(response_path)
finish(request, bytes)
{:error, :enoent} ->
delay = min(@poll_interval_ms, remaining_ms)
Process.sleep(delay)
await_response(request, response_path, remaining_ms - delay, timeout_ms)
{:error, reason} ->
{:error, %{code: "response_read_failed", output: bound_output(inspect(reason))}}
end
end
defp finish(request, response_bytes) do
with {:ok, response} <- BuildProtocol.decode_response(response_bytes),
true <- response["build_id"] == request["build_id"] or {:error, :build_id_mismatch} do
case response["status"] do
"ok" ->
finish_ok(request, response)
status ->
output =
[response["error"] || "isolated build #{status}", response["output_excerpt"]]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join("\n")
{:error,
%{
code: response["error_code"] || "build_#{status}",
output: bound_output(output),
duration_ms: response["duration_ms"],
output_digest: response["output_digest"],
output_ref: response["output_ref"]
}}
end
else
{:error, reason} ->
{:error, %{code: "invalid_response", output: bound_output(inspect(reason))}}
end
end
defp finish_ok(request, response) do
artifact_path = safe_artifact_path!(response["artifact_ref"])
with {:ok, bytes} <- File.read(artifact_path),
{:ok, verified} <-
BuildArtifact.verify(bytes,
digest: response["artifact_digest"],
repo: request["repo"],
source_sha: request["source_sha"],
build_id: request["build_id"]
) do
{:ok,
%{
artifact_bytes: bytes,
artifact_digest: verified.digest,
manifest: verified.manifest,
beams: verified.beams,
warnings: bound_output(response["output_excerpt"] || ""),
tests: nil,
duration_ms: response["duration_ms"],
output_digest: response["output_digest"],
output_ref: response["output_ref"]
}}
else
{:error, reason} ->
{:error, %{code: "artifact_verification_failed", output: bound_output(inspect(reason))}}
end
rescue
error ->
{:error,
%{
code: "artifact_reference_invalid",
output: bound_output(Exception.message(error))
}}
end
defp safe_artifact_path!("artifacts/" <> basename = ref) do
if ref == "artifacts/" <> Path.basename(basename) do
Path.join(artifact_dir(), ref)
else
raise ArgumentError, "unsafe artifact reference"
end
end
defp safe_artifact_path!(_ref), do: raise(ArgumentError, "invalid artifact reference")
end