feat(settings): detect stale writers with a revision, and announce raw changes

The remaining P1 from the #939 review, plus the P2 it shares a mechanism with.

Nothing carried a version, so two tabs editing one namespace silently
overwrote each other — reproduced as tab B's `reasoning` lost to tab A's
older draft. The seam's per-namespace write queue orders writes; it cannot
tell a fresh writer from one replaying a snapshot a predecessor superseded.

Each namespace now carries a monotonic `revision` over its RAW section. A
write may send `expectedRevision`, checked at the FRONT of the queue (not at
call time, which would race the very predecessor it guards against); a
mismatch rejects with `SettingsConflictError` → `settings-conflict` on the
wire, carrying both revisions. The editor captures the revision it opened at
and, on conflict, asks the user to reopen rather than replaying its snapshot.

The same counter fixes the missing broadcast. `settings/updated` is gated on
the resolved value — correct for consumers, wrong for configuration surfaces:
storing an override equal to the composition base leaves the resolved value
alone while changing what the document says (the field is now overridden, not
inherited) and moving every open editor's revision. `settings/document-updated
(ns, revision)` fires on any raw-section change, in-process or external, and
`host/settings-changed` now rides it.

That event also closes the stale model picker: editing a provider's `models`
changes no route, so `llm/adapters-updated` never fired and an open picker
kept serving the old catalog. A change to an exposed provider namespace now
emits `host/models-changed` too — that namespace holds the catalog.

Docs: both sides of the five touched README pairs, a type-equiv block for
`SettingsPathOp`, and an Agent Note recording what the plane exposes and who
may overwrite what. The deferred wire-redaction gaps (secrets behind
union/intersection/transform, `.default(...)` in the served envelope, schema
text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are
recorded as TODO(settings-wire-redaction) and in Known Limitations rather than
half-fixed.
This commit is contained in:
Yichen Jiang
2026-07-30 19:24:21 +08:00
parent 9f996be8e3
commit e6483f0afc
49 changed files with 598 additions and 78 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 7ee55f5232049806be6d5256d0e2dbbe948a6de5
README.zh.md: afb896452dd21c79ffe9ebd1ba473125595bff2a
README.md: 30eb4a3a10caf961d50517048ca05490dac02799
README.zh.md: b58a0adb388d247ade7fe375f5daba2964467a61

View File

@@ -6,7 +6,7 @@ Models settings section plugin: the provider configuration page. It joins three
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model Experience
@@ -18,8 +18,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **A reset can drop a stored literal secret in the same subtree** — a replace-carried removal cannot re-supply secrets the wire never returned; store keys behind `credentials.*` references (the product default) and the case cannot arise.
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal replaces the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -6,7 +6,7 @@
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己过期的快照重放上去。
## 模型体验
@@ -18,8 +18,7 @@
## 已知限制与暂缓事项
- **重置可能丢弃同一子树中已存储的字面 secret**:经 replace 承载的删除无法重新提供协议从未返回过的 secret;把密钥放在 `credentials.*` 引用背后(产品默认做法),该情形便不会出现。
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除替换的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -127,6 +127,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// The revision this card opened at. A write carrying it is refused if
// anything else — another tab, an external edit of settings.yaml — moved the
// namespace meanwhile, instead of silently overwriting that change.
const [openedAt] = useState(() => namespace.revision)
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
const fallback = getPath(namespace.value, settingsPath)
@@ -175,8 +179,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}
const ops = pathOps(settingsPath, original, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops })
if (!response.result.ok) return response.result.error.message
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
if (!response.result.ok) {
return response.result.error.code === 'settings-conflict'
? t('conflict')
: response.result.error.message
}
}
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })

View File

@@ -16,6 +16,7 @@ export const en = {
applying: 'Applying…',
readOnly: 'The settings document is read-only in this deployment.',
loadFailed: 'Loading the provider directory failed',
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
retry: 'Retry',
keyInput: 'API key',
keyPlaceholder: 'Enter your API key',
@@ -45,6 +46,7 @@ export const zh: typeof en = {
applying: '保存中…',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
retry: '重试',
keyInput: 'API 密钥',
keyPlaceholder: '输入 API 密钥',

View File

@@ -44,6 +44,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
user: { reasoningEffort: 'high' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
},
{
ns: 'llm-plain',
@@ -53,6 +54,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
value: {},
applies: 'live',
secrets: [],
revision: 0,
},
{
ns: 'llm-pi-ai',
@@ -61,6 +63,7 @@ function wireNamespaces(): SettingsNamespaceView[] {
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
applies: 'live',
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
revision: 0,
},
]
}
@@ -225,6 +228,7 @@ describe('ModelsSection', () => {
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
expectedRevision: 0,
})
})
@@ -243,6 +247,7 @@ describe('ModelsSection', () => {
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
expectedRevision: 0,
})
})
@@ -254,6 +259,7 @@ describe('ModelsSection', () => {
value: {},
applies: 'live',
secrets: [],
revision: 0,
}
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
@@ -308,6 +314,7 @@ describe('ModelsSection', () => {
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
expectedRevision: 0,
})
})
@@ -330,6 +337,7 @@ describe('ModelsSection', () => {
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
expectedRevision: 0,
})
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
})
@@ -363,6 +371,19 @@ describe('ModelsSection', () => {
expect(set).not.toHaveBeenCalled()
})
it('tells the user to reopen when another writer moved the namespace first', async () => {
// The stale-draft overwrite: two tabs open the same card, the other saves,
// and this one must be refused rather than replay its opening snapshot.
const { set } = await mountSection({
mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(en.conflict)
expect(set).not.toHaveBeenCalled()
})
it('surfaces a shadowed credential write on the card', async () => {
await mountSection({
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),

View File

@@ -26,6 +26,7 @@ const NAMESPACES = [
base: { baseURL: 'https://base' },
applies: 'live' as const,
secrets: [{ path: ['apiKey'], set: false }],
revision: 0,
},
{
ns: 'llm-pi-ai',
@@ -34,6 +35,7 @@ const NAMESPACES = [
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
applies: 'live' as const,
secrets: [],
revision: 0,
},
]
@@ -151,6 +153,7 @@ describe('edge joins', () => {
value: { providers: { weird: 'oops' } },
applies: 'live' as const,
secrets: [],
revision: 0,
}] as never,
})),
providers: () => Promise.resolve(ok({
@@ -170,7 +173,7 @@ describe('edge joins', () => {
const { face, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [] }] as never,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
})),
providers: () => Promise.resolve(ok({
providers: [