271 lines
9.0 KiB
Elixir
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
|