feat: phase 1 of runtime config store (neuron_config table, chat.model)
New Supabase table neuron_config keyed on (key, scope) with jsonb value column. Web tier reads chat.model via /api/demo with 60s TTL caching, passes to soul via dharma envelope payload.model. No more revision-rollout-per-model-swap. Admin read endpoint at /api/admin/config gated by NEURON_ADMIN_TOKEN. Write surface and Realtime subscription land in Phase 2. Backlog: bl-6eb51893
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- 20260502171044_neuron_config.sql
|
||||
--
|
||||
-- Phase 1 of the runtime config store. Backs bl-6eb51893.
|
||||
--
|
||||
-- Adds public.neuron_config: a (key, scope) -> jsonb runtime config table.
|
||||
-- The web tier reads it at request time with a 60s TTL cache and passes
|
||||
-- chat.model down to soul via the dharma envelope payload, so swapping
|
||||
-- models becomes a row update (not a Cloud Run revision rollout).
|
||||
--
|
||||
-- Phase 2 will add Realtime subscription + write surface + admin UI.
|
||||
|
||||
create table if not exists public.neuron_config (
|
||||
key text primary key,
|
||||
value jsonb not null,
|
||||
scope text not null default 'prod',
|
||||
updated_at timestamptz not null default now(),
|
||||
updated_by text
|
||||
);
|
||||
|
||||
alter table public.neuron_config enable row level security;
|
||||
|
||||
-- No policies. Service-role bypasses RLS. Public anon has no access. The
|
||||
-- table is server-side only; the web tier reads it with the service key.
|
||||
|
||||
insert into public.neuron_config (key, value, scope, updated_by) values
|
||||
('chat.model', '"claude-sonnet-4-5"'::jsonb, 'prod', 'system'),
|
||||
('chat.format', '"anthropic"'::jsonb, 'prod', 'system'),
|
||||
('chat.url', '"https://api.anthropic.com/v1/messages"'::jsonb, 'prod', 'system')
|
||||
on conflict (key) do nothing;
|
||||
+100
-2
@@ -414,6 +414,65 @@ fn read_asset(abs_path: String) -> String {
|
||||
return fs_read(abs_path)
|
||||
}
|
||||
|
||||
// ── Runtime config store (Phase 1) ────────────────────────────────────────────
|
||||
//
|
||||
// Reads a single (key, scope='prod') row from public.neuron_config in
|
||||
// Supabase. Caches the unwrapped string value in process state for 60s
|
||||
// before re-fetching. Empty string on miss / on Supabase error so callers
|
||||
// can safely fall back to a hardcoded default.
|
||||
//
|
||||
// jsonb wire format: PostgREST returns the value column as JSON. A jsonb
|
||||
// string like '"claude-sonnet-4-5"' arrives as the literal six-byte
|
||||
// payload "claude-sonnet-4-5" - already unquoted by json_get. Numbers and
|
||||
// objects are left as-is.
|
||||
//
|
||||
// Why this exists: chat.model used to be NEURON_LLM_0_MODEL on the Cloud
|
||||
// Run revision. Swapping models meant a redeploy. With this row, swapping
|
||||
// is one PATCH and a 60s wait. Phase 2 brings Realtime so propagation is
|
||||
// near-instant.
|
||||
fn config_get(key: String) -> String {
|
||||
let cache_key: String = "__cfg__" + key
|
||||
let cache_at: String = "__cfg_at__" + key
|
||||
let cached: String = state_get(cache_key)
|
||||
let cached_at_str: String = state_get(cache_at)
|
||||
let now: Int = unix_timestamp()
|
||||
let cached_at: Int = if str_eq(cached_at_str, "") { 0 } else { str_to_int(cached_at_str) }
|
||||
if !str_eq(cached, "") && (now - cached_at) < 60 {
|
||||
return cached
|
||||
}
|
||||
let sb_url: String = state_get("__supabase_project_url__")
|
||||
let sb_key: String = state_get("__supabase_service_key__")
|
||||
if str_eq(sb_url, "") || str_eq(sb_key, "") {
|
||||
return cached
|
||||
}
|
||||
let q: String = "neuron_config?select=value&key=eq." + key + "&scope=eq.prod&limit=1"
|
||||
let resp: String = supabase_get(sb_url, sb_key, q)
|
||||
if str_eq(resp, "") || str_eq(resp, "[]") {
|
||||
return cached
|
||||
}
|
||||
// PostgREST returns [{"value":<jsonb>}]. Pull the first row, then the
|
||||
// value field. json_get unwraps a jsonb string to its bare payload.
|
||||
let row: String = json_array_get(resp, 0)
|
||||
if str_eq(row, "") {
|
||||
return cached
|
||||
}
|
||||
let val: String = json_get(row, "value")
|
||||
if str_eq(val, "") {
|
||||
// value could be a non-string jsonb (number/array/object). Fall
|
||||
// back to the raw form so callers at least see something.
|
||||
let val_raw: String = json_get_raw(row, "value")
|
||||
if str_eq(val_raw, "") {
|
||||
return cached
|
||||
}
|
||||
state_set(cache_key, val_raw)
|
||||
state_set(cache_at, int_to_str(now))
|
||||
return val_raw
|
||||
}
|
||||
state_set(cache_key, val)
|
||||
state_set(cache_at, int_to_str(now))
|
||||
return val
|
||||
}
|
||||
|
||||
// ── Request handler ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// NOTE: GET / is intercepted by the El HTTP runtime before reaching this
|
||||
@@ -634,6 +693,38 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return "{\"status\":\"ok\",\"service\":\"neuron-web\"}"
|
||||
}
|
||||
|
||||
// ── Admin: read-only config snapshot (Phase 1) ────────────────────────────
|
||||
// POST { "admin_token": "<NEURON_ADMIN_TOKEN>" } - returns the full
|
||||
// neuron_config table for verification. Phase 2 adds POST/PUT and an
|
||||
// auth-gated admin page; for now this is the only surface.
|
||||
//
|
||||
// Token in the body keeps this consistent with the rest of /api/* (the
|
||||
// El runtime does not surface request headers to the handler today).
|
||||
if str_eq(path, "/api/admin/config") {
|
||||
if !str_eq(method, "POST") {
|
||||
return "{\"__status__\":405,\"error\":\"POST required\"}"
|
||||
}
|
||||
let admin_token_in: String = json_get(body, "admin_token")
|
||||
let admin_token_expected: String = env("NEURON_ADMIN_TOKEN")
|
||||
if str_eq(admin_token_expected, "") {
|
||||
return "{\"__status__\":503,\"error\":\"admin_token_not_configured\"}"
|
||||
}
|
||||
if !str_eq(admin_token_in, admin_token_expected) {
|
||||
return "{\"__status__\":401,\"error\":\"unauthorized\"}"
|
||||
}
|
||||
let ac_sb_url: String = state_get("__supabase_project_url__")
|
||||
let ac_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(ac_sb_url, "") || str_eq(ac_service, "") {
|
||||
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
|
||||
}
|
||||
let ac_q: String = "neuron_config?select=key,value,scope,updated_at,updated_by&order=key"
|
||||
let ac_resp: String = supabase_get(ac_sb_url, ac_service, ac_q)
|
||||
if str_eq(ac_resp, "") {
|
||||
return "{\"rows\":[]}"
|
||||
}
|
||||
return "{\"rows\":" + ac_resp + "}"
|
||||
}
|
||||
|
||||
// ── My plan: server-side waitlist read with JWT verification ─────────────
|
||||
// POST { "access_token": "<user_jwt>" }. We verify the JWT via Supabase
|
||||
// /auth/v1/user, extract the email, then read the waitlist row with the
|
||||
@@ -960,8 +1051,15 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let qrem_safe: String = if str_eq(qrem_str, "") { "10" } else { qrem_str }
|
||||
let is_last_str: String = json_get(body, "is_last_question")
|
||||
let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" }
|
||||
// Build inner content with history and engram context for thread context
|
||||
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + "}}"
|
||||
// Look up the configured chat model from public.neuron_config
|
||||
// (Phase 1 runtime config store). 60s TTL caching, falls back
|
||||
// to the hardcoded default on Supabase miss / error.
|
||||
let configured_model: String = config_get("chat.model")
|
||||
let model_safe: String = if str_eq(configured_model, "") { "claude-sonnet-4-5" } else { configured_model }
|
||||
// Build inner content with history and engram context for thread context.
|
||||
// soul-demo unwraps payload from the dharma envelope, then reads
|
||||
// model with json_get(body, "model") - so this propagates end to end.
|
||||
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + ",\"model\":\"" + model_safe + "\"}}"
|
||||
// Escape inner for the outer content field
|
||||
let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"")
|
||||
// Build dharma envelope with per-user channel
|
||||
|
||||
Reference in New Issue
Block a user