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)
|
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 ───────────────────────────────────────────────────────────
|
// ── Request handler ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// NOTE: GET / is intercepted by the El HTTP runtime before reaching this
|
// 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\"}"
|
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 ─────────────
|
// ── My plan: server-side waitlist read with JWT verification ─────────────
|
||||||
// POST { "access_token": "<user_jwt>" }. We verify the JWT via Supabase
|
// POST { "access_token": "<user_jwt>" }. We verify the JWT via Supabase
|
||||||
// /auth/v1/user, extract the email, then read the waitlist row with the
|
// /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 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_str: String = json_get(body, "is_last_question")
|
||||||
let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" }
|
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
|
// Look up the configured chat model from public.neuron_config
|
||||||
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 + "}}"
|
// (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
|
// Escape inner for the outer content field
|
||||||
let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"")
|
let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"")
|
||||||
// Build dharma envelope with per-user channel
|
// Build dharma envelope with per-user channel
|
||||||
|
|||||||
Reference in New Issue
Block a user