symbiont_ex/lib/symbiont/api.ex

271 lines
9.0 KiB
Elixir

defmodule Symbiont.API do
@moduledoc """
HTTP API for Symbiont — drop-in replacement for the Python FastAPI server.
Endpoints:
POST /task — Submit and execute a task immediately
POST /queue — Add a task to the persistent queue
GET /status — Health check + system overview
GET /ledger — Recent inference calls
GET /ledger/stats — Aggregate cost & usage
GET /health — Simple liveness probe
Engram endpoints:
GET /engram/sitrep — Situation report (world state + active sessions)
GET /engram/world — Raw world state markdown
PUT /engram/world — Update world state
POST /engram/sessions — Register a new session
GET /engram/sessions — List active sessions
GET /engram/sessions/recent — Recently completed sessions
GET /engram/sessions/:id — Get a single session
POST /engram/sessions/:id/heartbeat — Update heartbeat
POST /engram/sessions/:id/complete — Mark session completed
POST /engram/sessions/:id/log — Add a log entry
GET /engram/sessions/:id/logs — Get session logs
POST /engram/locks — Lock a resource
DELETE /engram/locks — Release a resource lock
GET /engram/locks/:resource — Check locks on a resource
GET /engram/stats — Engram stats
"""
use Plug.Router
plug(Plug.Logger)
plug(:match)
plug(Plug.Parsers, parsers: [:json], json_decoder: Jason)
plug(:dispatch)
# ── Task Endpoints ──────────────────────────────────────────────────────
post "/task" do
task = conn.body_params["task"]
force_tier = conn.body_params["force_tier"]
if is_nil(task) or task == "" do
send_json(conn, 400, %{error: "missing 'task' field"})
else
opts = if force_tier, do: [force_tier: force_tier], else: []
case Symbiont.Router.route_and_execute(task, opts) do
{:ok, result} ->
response = %{
id: "task-#{System.system_time(:second)}",
task: task,
model: result.model,
result: result.result,
elapsed_seconds: result.elapsed_seconds,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
estimated_cost_usd: result.estimated_cost_usd,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601()
}
send_json(conn, 200, response)
{:error, reason} ->
send_json(conn, 500, %{error: inspect(reason)})
end
end
end
# ── Queue Endpoints ─────────────────────────────────────────────────────
post "/queue" do
task = conn.body_params["task"]
priority = conn.body_params["priority"] || "normal"
if is_nil(task) or task == "" do
send_json(conn, 400, %{error: "missing 'task' field"})
else
{:ok, task_id} = Symbiont.Queue.enqueue(task, priority)
queue_size = Symbiont.Queue.size()
send_json(conn, 200, %{
id: task_id,
status: "queued",
position: queue_size
})
end
end
# ── Status Endpoints ────────────────────────────────────────────────────
get "/status" do
ledger_stats = Symbiont.Ledger.stats()
queue_size = Symbiont.Queue.size()
last_heartbeat = Symbiont.Heartbeat.last_snapshot()
response = %{
status: "healthy",
runtime: "elixir/otp",
queue_size: queue_size,
last_heartbeat: last_heartbeat && last_heartbeat["timestamp"],
total_calls: ledger_stats["total_calls"],
total_cost_estimated_usd: ledger_stats["total_cost_estimated_usd"],
by_model: ledger_stats["by_model"]
}
send_json(conn, 200, response)
end
get "/ledger" do
entries = Symbiont.Ledger.recent(50)
send_json(conn, 200, %{entries: entries, count: length(entries)})
end
get "/ledger/stats" do
stats = Symbiont.Ledger.stats()
send_json(conn, 200, stats)
end
get "/health" do
send_json(conn, 200, %{status: "ok", runtime: "elixir/otp"})
end
# ── Engram Endpoints ────────────────────────────────────────────────────
get "/engram/sitrep" do
report = Symbiont.Engram.sitrep()
send_json(conn, 200, %{sitrep: report})
end
get "/engram/world" do
content = Symbiont.Engram.get_world_state()
send_json(conn, 200, %{content: content})
end
put "/engram/world" do
content = conn.body_params["content"]
updated_by = conn.body_params["updated_by"]
if is_nil(content) do
send_json(conn, 400, %{error: "missing 'content' field"})
else
:ok = Symbiont.Engram.set_world_state(content, updated_by)
send_json(conn, 200, %{status: "ok"})
end
end
post "/engram/sessions" do
session_type = conn.body_params["session_type"]
summary = conn.body_params["summary"]
metadata = conn.body_params["metadata"]
if is_nil(session_type) or is_nil(summary) do
send_json(conn, 400, %{error: "missing 'session_type' and/or 'summary'"})
else
{:ok, sid} = Symbiont.Engram.register(session_type, summary, metadata)
send_json(conn, 200, %{session_id: sid, status: "active"})
end
end
get "/engram/sessions" do
sessions = Symbiont.Engram.get_active_sessions()
send_json(conn, 200, %{sessions: sessions, count: length(sessions)})
end
get "/engram/sessions/recent" do
hours = parse_int(conn.query_params["hours"], 24)
sessions = Symbiont.Engram.get_recent_sessions(hours)
send_json(conn, 200, %{sessions: sessions, count: length(sessions)})
end
# Note: this must come AFTER /engram/sessions/recent to avoid matching "recent" as :id
get "/engram/sessions/:id" do
case Symbiont.Engram.get_session(id) do
{:ok, session} -> send_json(conn, 200, session)
{:error, :not_found} -> send_json(conn, 404, %{error: "session not found"})
end
end
post "/engram/sessions/:id/heartbeat" do
summary = conn.body_params["summary"]
:ok = Symbiont.Engram.heartbeat(id, summary)
send_json(conn, 200, %{status: "ok"})
end
post "/engram/sessions/:id/complete" do
completion_summary = conn.body_params["summary"]
if is_nil(completion_summary) do
send_json(conn, 400, %{error: "missing 'summary' field"})
else
:ok = Symbiont.Engram.complete(id, completion_summary)
send_json(conn, 200, %{status: "completed"})
end
end
post "/engram/sessions/:id/log" do
entry = conn.body_params["entry"]
if is_nil(entry) do
send_json(conn, 400, %{error: "missing 'entry' field"})
else
:ok = Symbiont.Engram.log(id, entry)
send_json(conn, 200, %{status: "ok"})
end
end
get "/engram/sessions/:id/logs" do
limit = parse_int(conn.query_params["limit"], 20)
logs = Symbiont.Engram.get_session_logs(id, limit)
send_json(conn, 200, %{logs: logs, count: length(logs)})
end
post "/engram/locks" do
session_id = conn.body_params["session_id"]
resource = conn.body_params["resource"]
note = conn.body_params["note"]
if is_nil(session_id) or is_nil(resource) do
send_json(conn, 400, %{error: "missing 'session_id' and/or 'resource'"})
else
:ok = Symbiont.Engram.lock_resource(session_id, resource, note)
send_json(conn, 200, %{status: "locked"})
end
end
delete "/engram/locks" do
session_id = conn.body_params["session_id"]
resource = conn.body_params["resource"]
if is_nil(session_id) or is_nil(resource) do
send_json(conn, 400, %{error: "missing 'session_id' and/or 'resource'"})
else
:ok = Symbiont.Engram.release_resource(session_id, resource)
send_json(conn, 200, %{status: "released"})
end
end
get "/engram/locks/:resource" do
locks = Symbiont.Engram.check_locks(URI.decode(resource))
send_json(conn, 200, %{locks: locks, count: length(locks)})
end
get "/engram/stats" do
stats = Symbiont.Engram.stats()
send_json(conn, 200, stats)
end
# ── Fallback ────────────────────────────────────────────────────────────
match _ do
send_json(conn, 404, %{error: "not found"})
end
# ── Helpers ─────────────────────────────────────────────────────────────
defp send_json(conn, status, body) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
defp parse_int(nil, default), do: default
defp parse_int(str, default) do
case Integer.parse(str) do
{n, _} -> n
:error -> default
end
end
end