Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
conflictSettings,
|
||||
useSettings,
|
||||
useUpdateSettings,
|
||||
type EffectiveAgent,
|
||||
type InstanceSettings,
|
||||
} from '@/lib/settings'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
|
||||
// form models it as three named choices so "inherit" is a deliberate pick, not
|
||||
// an empty control.
|
||||
type EnabledChoice = 'inherit' | 'on' | 'off'
|
||||
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
|
||||
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
|
||||
|
||||
/** Admin-only instance settings (#79). The one that matters today is the agent
|
||||
* model; the API key stays in the environment and is only ever reported as
|
||||
* present/absent, never shown or edited. */
|
||||
export function SettingsPage() {
|
||||
usePageTitle('Settings')
|
||||
const settings = useSettings()
|
||||
const update = useUpdateSettings()
|
||||
|
||||
if (settings.isPending) {
|
||||
return <p className="text-sm text-muted">Loading settings…</p>
|
||||
}
|
||||
if (settings.isError) {
|
||||
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
|
||||
}
|
||||
return <SettingsForm data={settings.data} update={update} />
|
||||
}
|
||||
|
||||
function SettingsForm({
|
||||
data,
|
||||
update,
|
||||
}: {
|
||||
data: { settings: InstanceSettings; effective: EffectiveAgent }
|
||||
update: ReturnType<typeof useUpdateSettings>
|
||||
}) {
|
||||
const [model, setModel] = useState(data.settings.agentModel)
|
||||
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
|
||||
const [version, setVersion] = useState(data.settings.version)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Rebase the form when the query cache updates (e.g. a successful save writes
|
||||
// the new row back), so the version we submit is never stale.
|
||||
useEffect(() => {
|
||||
setVersion(data.settings.version)
|
||||
}, [data.settings.version])
|
||||
|
||||
const eff = data.effective
|
||||
|
||||
const save = () => {
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
|
||||
{
|
||||
onSuccess: () => toast.info('Settings saved.'),
|
||||
onError: (err) => {
|
||||
const current = conflictSettings(err)
|
||||
if (current) {
|
||||
// Someone else saved first. Adopt their row so the next attempt is
|
||||
// clean, and say so rather than silently discarding this edit.
|
||||
setModel(current.agentModel)
|
||||
setEnabled(toChoice(current.agentEnabled))
|
||||
setVersion(current.version)
|
||||
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err, "Couldn't save settings."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-2xl flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-fg">Settings</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Instance-wide, admin-only. Changes take effect immediately — no restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
The model runs against your Ollama Cloud key, which is set in the environment and never
|
||||
shown here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AgentStatus eff={eff} />
|
||||
|
||||
<TextField
|
||||
label="Model"
|
||||
name="agentModel"
|
||||
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
hint={
|
||||
model.trim() === ''
|
||||
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
|
||||
: 'A majordomo model spec. A comma-separated list is a failover chain.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Enabled"
|
||||
name="agentEnabled"
|
||||
value={enabled}
|
||||
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'Inherit from environment' },
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={save} disabled={update.isPending}>
|
||||
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
|
||||
// a key does not guarantee the assistant is actually running (an unresolvable
|
||||
// model leaves it down), and that's precisely what an admin needs to see.
|
||||
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
|
||||
const [tone, text] = eff.agentLive
|
||||
? (['ok', `Live on ${eff.model}`] as const)
|
||||
: !eff.hasApiKey
|
||||
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
|
||||
: !eff.enabled
|
||||
? (['warn', 'Turned off.'] as const)
|
||||
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={
|
||||
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user